diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d7a75e48 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.patch -whitespace +compat/e2b/spec/** -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1b624d0..dcb36d8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: name: Format runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: git -c http.extraheader= submodule update --init --recursive - uses: dtolnay/rust-toolchain@stable @@ -30,7 +30,7 @@ jobs: name: Clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: git -c http.extraheader= submodule update --init --recursive - uses: dtolnay/rust-toolchain@stable @@ -54,7 +54,7 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: git -c http.extraheader= submodule update --init --recursive - uses: dtolnay/rust-toolchain@stable @@ -71,10 +71,81 @@ jobs: workspaces: src - run: cd src && cargo test --workspace --lib + # ── Pinned E2B protocol and SDK surface drift gate ──────────── + e2b-contract: + name: E2B Contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src + - name: Verify pinned contracts and SDK exports + run: cd src && cargo run -p a3s-box-compat --bin a3s-box-e2b-contract -- verify + + # ── Pinned official E2B client wire compatibility gate ──────── + e2b-official-clients: + name: E2B Official Clients + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/setup-node@v7 + with: + node-version: '20' + - uses: astral-sh/setup-uv@v8.3.2 + with: + version: '0.11.8' + enable-cache: false + - uses: dtolnay/rust-toolchain@stable + - name: Build Rust lifecycle fixture server + run: cd src && cargo build -p a3s-box-compat --bin a3s-box-e2b-fixture-server + - name: Test production client harness + run: python3 compat/e2b/fixtures/official-clients/test_run_production.py -v + - name: Verify official client lifecycle requests + run: >- + python3 compat/e2b/fixtures/official-clients/run_fixtures.py verify + --rust-server-bin src/target/debug/a3s-box-e2b-fixture-server + + # ── Native Python and TypeScript SDK package gate ───────────── + sdk-packages: + name: SDK Packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/setup-node@v7 + with: + node-version: '20' + cache: npm + cache-dependency-path: sdk/typescript/package-lock.json + - name: Build and test Python package + run: | + python -m pip install build + python -m build sdk/python + python -m venv /tmp/a3s-box-python-sdk + /tmp/a3s-box-python-sdk/bin/pip install sdk/python/dist/*.whl + /tmp/a3s-box-python-sdk/bin/python -m unittest discover -s sdk/python/tests + - name: Build and test TypeScript package + working-directory: sdk/typescript + run: | + npm ci + npm run build + npm test + npm pack --dry-run + # ── Build check (compile only, no artifacts) ──────────────────── build-check: name: Build Check (${{ matrix.target }}) - needs: [fmt, clippy, test] + needs: [fmt, clippy, test, e2b-contract, e2b-official-clients, sdk-packages] strategy: matrix: include: @@ -86,7 +157,7 @@ jobs: os: macos-14 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: git -c http.extraheader= submodule update --init --recursive @@ -115,15 +186,16 @@ jobs: cargo check --release -p a3s-box-cli -p a3s-box-shim if [ "${{ runner.os }}" = "Linux" ]; then cargo check --release -p a3s-box-cri + cargo check --release -p a3s-box-compat --bin a3s-box-e2b fi # ── Windows native WHPX build check ──────────────────────────── build-windows: name: Build Windows WHPX - needs: [fmt, clippy, test] + needs: [fmt, clippy, test, e2b-contract, e2b-official-clients, sdk-packages] runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: git -c http.extraheader= submodule update --init --recursive - uses: dtolnay/rust-toolchain@stable @@ -139,7 +211,7 @@ jobs: cd src cargo build --release -p a3s-box-cli -p a3s-box-shim --target x86_64-pc-windows-msvc - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: windows-whpx path: | @@ -162,7 +234,7 @@ jobs: # Until both are done this job is SKIPPED (inert) and never blocks a PR. integration-kvm: name: Integration (real microVM, KVM) - needs: [fmt, clippy, test] + needs: [fmt, clippy, test, e2b-contract, e2b-official-clients, sdk-packages] if: vars.KVM_CI == 'true' runs-on: [self-hosted, linux, kvm] timeout-minutes: 75 @@ -173,7 +245,7 @@ jobs: env: A3S_REGISTRY_MIRRORS: ${{ vars.KVM_CI_REGISTRY_MIRRORS || 'docker.io=docker.m.daocloud.io' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: git -c http.extraheader= submodule update --init --recursive # The self-hosted runner already has a working rustup/cargo. Using @@ -210,6 +282,23 @@ jobs: unset A3S_DEPS_STUB cd src cargo test --release -p a3s-box-cli --test core_smoke -- --ignored --nocapture --test-threads=1 + # Keep the cached foreground no-op inside a bounded production envelope. + # Docker is intentionally unavailable on this runner; the original + # macOS/HVF Docker ratio remains a separate hardware-specific comparison. + - name: Foreground latency — cached no-op p50 regression gate + env: + A3S_BOX: ${{ github.workspace }}/src/target/release/a3s-box + IMAGE: ${{ vars.KVM_CI_AGENT_IMAGE || 'docker.m.daocloud.io/library/alpine:latest' }} + FOREGROUND_RUNS: "10" + FOREGROUND_WARMUPS: "1" + FOREGROUND_DOCKER: "0" + # Default includes headroom for normal runner noise; dedicated + # runners can tighten it with the repository variable. + FOREGROUND_MAX_P50_MS: ${{ vars.KVM_CI_FOREGROUND_MAX_P50_MS || '3200' }} + run: | + unset A3S_DEPS_STUB + chmod +x bench/bench.sh + bench/bench.sh foreground - name: CRI crictl smoke — full pod/container lifecycle env: A3S_BOX_CRI_SMOKE: "1" diff --git a/.github/workflows/e2b-runtime-image.yml b/.github/workflows/e2b-runtime-image.yml new file mode 100644 index 00000000..ba2e297e --- /dev/null +++ b/.github/workflows/e2b-runtime-image.yml @@ -0,0 +1,63 @@ +name: E2B Runtime Image + +on: + push: + branches: + - main + - "feat/e2b-*" + paths: + - "deploy/e2b/**" + - ".github/workflows/e2b-runtime-image.yml" + workflow_dispatch: {} + +permissions: + contents: read + packages: write + +concurrency: + group: e2b-runtime-image-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Test envd initialization + run: python3 -m unittest discover -s deploy/e2b -p 'test_*.py' + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image metadata + id: metadata + uses: docker/metadata-action@v5 + with: + images: ghcr.io/a3s-lab/box-e2b-runtime + tags: | + type=sha,format=long + type=raw,value=edge,enable={{is_default_branch}} + + - name: Build and push the runtime image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/e2b/Dockerfile + platforms: ${{ github.ref == 'refs/heads/main' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + cache-from: type=gha,scope=e2b-runtime + cache-to: type=gha,mode=max,scope=e2b-runtime + provenance: mode=max + sbom: true diff --git a/.github/workflows/publish-libkrun-sys.yml b/.github/workflows/publish-libkrun-sys.yml index 678d4a97..6bb8dd8e 100644 --- a/.github/workflows/publish-libkrun-sys.yml +++ b/.github/workflows/publish-libkrun-sys.yml @@ -20,7 +20,7 @@ jobs: name: Build Windows krun.dll runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: submodules: recursive @@ -61,47 +61,47 @@ jobs: # Create README @" -# a3s-libkrun-sys Windows Build + # a3s-libkrun-sys Windows Build -Version: $VERSION -Platform: Windows x86_64 (WHPX) -Built: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC") + Version: $VERSION + Platform: Windows x86_64 (WHPX) + Built: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC") -## Contents + ## Contents -- lib/krun.dll - libkrun Windows WHPX backend + - lib/krun.dll - libkrun Windows WHPX backend -## Usage + ## Usage -Add to your Cargo.toml: + Add to your Cargo.toml: -``````toml -[dependencies] -a3s-libkrun-sys = "$VERSION" -`````` + ``````toml + [dependencies] + a3s-libkrun-sys = "$VERSION" + `````` -The krun.dll must be in your PATH or in the same directory as your executable. + The krun.dll must be in your PATH or in the same directory as your executable. -## Features + ## Features -- Windows Hypervisor Platform (WHPX) backend -- virtiofs passthrough filesystem -- virtio-net TCP backend -- virtio-blk block device -- virtio-console -- TSI (Transparent Socket Impersonation) for vsock + - Windows Hypervisor Platform (WHPX) backend + - virtiofs passthrough filesystem + - virtio-net TCP backend + - virtio-blk block device + - virtio-console + - TSI (Transparent Socket Impersonation) for vsock -## Requirements + ## Requirements -- Windows 10/11 with Hyper-V Platform enabled -- Run: ``Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform`` -"@ | Out-File -FilePath $DIR\README.md -Encoding UTF8 + - Windows 10/11 with Hyper-V Platform enabled + - Run: ``Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform`` + "@ | Out-File -FilePath $DIR\README.md -Encoding UTF8 # Create zip Compress-Archive -Path $DIR -DestinationPath "$DIR.zip" echo "ASSET=$DIR.zip" >> $env:GITHUB_ENV - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: windows-dll path: ${{ env.ASSET }} @@ -112,7 +112,7 @@ The krun.dll must be in your PATH or in the same directory as your executable. needs: build-windows-dll runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: submodules: recursive @@ -138,9 +138,9 @@ The krun.dll must be in your PATH or in the same directory as your executable. needs: [build-windows-dll, publish-crate] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: name: windows-dll path: artifacts @@ -181,4 +181,3 @@ The krun.dll must be in your PATH or in the same directory as your executable. artifacts/*.zip draft: false prerelease: false - diff --git a/.github/workflows/publish-winget.yml b/.github/workflows/publish-winget.yml index ec3c0cfa..3caf76af 100644 --- a/.github/workflows/publish-winget.yml +++ b/.github/workflows/publish-winget.yml @@ -1,8 +1,6 @@ name: Publish to winget on: - release: - types: [published] workflow_dispatch: inputs: version: @@ -18,7 +16,7 @@ jobs: name: Publish to winget runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get version id: version @@ -65,6 +63,11 @@ jobs: # Update installer URL and SHA256 sed -i "s|InstallerUrl: .*|InstallerUrl: https://github.com/${REPO}/releases/download/${TAG}/a3s-box-${TAG}-windows-x86_64.zip|" .winget/A3SLab.Box.installer.yaml sed -i "s/InstallerSha256: .*/InstallerSha256: $SHA256/" .winget/A3SLab.Box.installer.yaml + sed -i "s|PublisherUrl: .*|PublisherUrl: https://github.com/A3S-Lab|" .winget/A3SLab.Box.locale.en-US.yaml + sed -i "s|PublisherSupportUrl: .*|PublisherSupportUrl: https://github.com/${REPO}/issues|" .winget/A3SLab.Box.locale.en-US.yaml + sed -i "s|PackageUrl: .*|PackageUrl: https://github.com/${REPO}|" .winget/A3SLab.Box.locale.en-US.yaml + sed -i "s|LicenseUrl: .*|LicenseUrl: https://github.com/${REPO}/blob/main/LICENSE|" .winget/A3SLab.Box.locale.en-US.yaml + sed -i "s|ReleaseNotesUrl: .*|ReleaseNotesUrl: https://github.com/${REPO}/releases/tag/${TAG}|" .winget/A3SLab.Box.locale.en-US.yaml # Update nested installer path sed -i "s|RelativeFilePath: a3s-box-v[0-9.]*-windows-x86_64|RelativeFilePath: a3s-box-${TAG}-windows-x86_64|g" .winget/A3SLab.Box.installer.yaml @@ -74,24 +77,43 @@ jobs: cat .winget/A3SLab.Box.installer.yaml cat .winget/A3SLab.Box.locale.en-US.yaml + - name: Check winget package status + id: winget_package + shell: bash + run: | + PACKAGE_URL="https://github.com/microsoft/winget-pkgs/tree/master/manifests/a/A3SLab/Box" + STATUS=$(curl -sL -o /dev/null -w "%{http_code}" "$PACKAGE_URL") + if [ "$STATUS" = "200" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "A3SLab.Box exists in winget-pkgs; publishing update." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::warning title=winget first submission required::A3SLab.Box is not in winget-pkgs yet. Skipping automatic update; submit the generated manifests as the first package version." + fi + - name: Submit to winget-pkgs + id: submit_winget + if: steps.winget_package.outputs.exists == 'true' + continue-on-error: true uses: vedantmgoyal9/winget-releaser@main with: identifier: A3SLab.Box version: ${{ steps.version.outputs.version }} installers-regex: 'a3s-box-v.*-windows-x86_64\.zip$' + release-tag: ${{ steps.version.outputs.tag }} + release-notes-url: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }} token: ${{ secrets.WINGET_TOKEN }} fork-user: ${{ secrets.WINGET_FORK_USER }} max-versions-to-keep: 5 # Alternative: Manual PR creation if vedantmgoyal9/winget-releaser doesn't work - name: Create winget-pkgs PR (fallback) - if: failure() + if: steps.winget_package.outputs.exists != 'true' || steps.submit_winget.outcome == 'failure' shell: bash run: | VERSION="${{ steps.version.outputs.version }}" - echo "Automatic submission failed. Please manually submit to winget-pkgs:" + echo "::warning::Automatic winget submission did not complete. Please manually submit to winget-pkgs:" echo "" echo "1. Fork https://github.com/microsoft/winget-pkgs" echo "2. Create directory: manifests/a/A3SLab/Box/$VERSION/" @@ -100,6 +122,3 @@ jobs: echo " - .winget/A3SLab.Box.installer.yaml" echo " - .winget/A3SLab.Box.locale.en-US.yaml" echo "4. Create PR to microsoft/winget-pkgs" - echo "" - echo "Or use wingetcreate:" - echo "wingetcreate update A3SLab.Box -v $VERSION -u https://github.com/${GITHUB_REPOSITORY}/releases/download/v$VERSION/a3s-box-v$VERSION-windows-x86_64.zip -t ${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfb9e5c5..4c7d6519 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: env: A3S_DEPS_STUB: "1" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: git -c http.extraheader= submodule update --init --recursive - uses: dtolnay/rust-toolchain@stable @@ -37,6 +37,41 @@ jobs: workspaces: src - run: cd src && cargo test --workspace --lib + # ── Build native SDK release assets ─────────────────────────── + sdk: + name: Build SDK packages + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: actions/setup-node@v7 + with: + node-version: '20' + cache: npm + cache-dependency-path: sdk/typescript/package-lock.json + - name: Build and verify Python package + run: | + mkdir -p sdk-artifacts + python -m pip install build + python -m build sdk/python --outdir "$GITHUB_WORKSPACE/sdk-artifacts" + python -m venv /tmp/a3s-box-python-sdk + /tmp/a3s-box-python-sdk/bin/pip install sdk-artifacts/*.whl + /tmp/a3s-box-python-sdk/bin/python -m unittest discover -s sdk/python/tests + - name: Build and verify TypeScript package + working-directory: sdk/typescript + run: | + npm ci + npm run build + npm test + npm pack --pack-destination "$GITHUB_WORKSPACE/sdk-artifacts" + - uses: actions/upload-artifact@v6 + with: + name: sdk-packages + path: sdk-artifacts/* + # ── Build matrix ─────────────────────────────────────────────── build: name: Build (${{ matrix.target }}) @@ -62,7 +97,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: | @@ -98,6 +133,7 @@ jobs: cargo build --release -p a3s-box-cli -p a3s-box-shim if [ "${{ matrix.build_cri }}" = "true" ]; then cargo build --release -p a3s-box-cri + cargo build --release -p a3s-box-compat --bin a3s-box-e2b fi - name: Build guest binaries @@ -124,6 +160,7 @@ jobs: if [ "${{ matrix.build_cri }}" = "true" ]; then cp src/target/release/a3s-box-cri "$DIR/" + cp src/target/release/a3s-box-e2b "$DIR/" fi cp "src/target/${{ matrix.guest_target }}/release/a3s-box-guest-init" "$DIR/" @@ -150,6 +187,7 @@ jobs: done if [ "${{ matrix.build_cri }}" = "true" ]; then patchelf --set-rpath '$ORIGIN/lib' "$DIR/a3s-box-cri" 2>/dev/null || true + patchelf --set-rpath '$ORIGIN/lib' "$DIR/a3s-box-e2b" 2>/dev/null || true fi fi @@ -163,86 +201,18 @@ jobs: tar czf "${DIR}.tar.gz" "$DIR" echo "ASSET=${DIR}.tar.gz" >> "$GITHUB_ENV" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: ${{ matrix.target }} path: ${{ env.ASSET }} # ── Create GitHub Release ────────────────────────────────────── - - # ── Windows native WHPX build ────────────────────────────────── - build-windows: - name: Build Windows (WHPX Backend) - needs: [test, build] - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - name: Init submodules - run: git -c http.extraheader= submodule update --init --recursive - - uses: dtolnay/rust-toolchain@stable - with: - targets: x86_64-pc-windows-msvc - - uses: Swatinem/rust-cache@v2 - with: - workspaces: src - key: release-windows - - - name: Build Windows native binaries - run: | - cd src - cargo build --release -p a3s-box-cli -p a3s-box-shim --target x86_64-pc-windows-msvc - - - name: Download Linux guest init artifact - uses: actions/download-artifact@v4 - with: - name: linux-x86_64 - path: linux-x86_64 - - - name: Package Windows release - shell: bash - run: | - TAG="${GITHUB_REF#refs/tags/}" - DIR="a3s-box-${TAG}-windows-x86_64" - mkdir -p "$DIR/lib" - - cp src/target/x86_64-pc-windows-msvc/release/a3s-box.exe "$DIR/" - cp src/target/x86_64-pc-windows-msvc/release/a3s-box-shim.exe "$DIR/" - - tar -xzf "linux-x86_64/a3s-box-${TAG}-linux-x86_64.tar.gz" -C linux-x86_64 - cp "linux-x86_64/a3s-box-${TAG}-linux-x86_64/a3s-box-guest-init" "$DIR/" - - if [ -f "src/target/x86_64-pc-windows-msvc/release/krun.dll" ]; then - cp src/target/x86_64-pc-windows-msvc/release/krun.dll "$DIR/lib/" - elif [ -f "src/deps/libkrun-sys/prebuilt/x86_64-pc-windows-msvc/krun.dll" ]; then - cp src/deps/libkrun-sys/prebuilt/x86_64-pc-windows-msvc/krun.dll "$DIR/lib/" - else - echo "krun.dll is required for the native WHPX package" >&2 - exit 1 - fi - - # Copy README and LICENSE - cp README.md "$DIR/" 2>/dev/null || true - cp LICENSE "$DIR/" 2>/dev/null || true - - # List package contents - echo "Package contents:" - ls -lhR "$DIR/" - - # Create zip archive (Windows-friendly) - powershell Compress-Archive -Path "$DIR" -DestinationPath "${DIR}.zip" - echo "ASSET=${DIR}.zip" >> "$GITHUB_ENV" - - - uses: actions/upload-artifact@v4 - with: - name: windows-x86_64 - path: ${{ env.ASSET }} - release: name: GitHub Release - needs: [build, build-windows] + needs: [build, sdk] runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: path: artifacts @@ -256,7 +226,8 @@ jobs: generate_release_notes: true files: | artifacts/**/*.tar.gz - artifacts/**/*.zip + artifacts/**/*.whl + artifacts/**/*.tgz # ── Publish to crates.io ─────────────────────────────────────── publish-crates: @@ -266,7 +237,7 @@ jobs: env: A3S_DEPS_STUB: "1" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Init submodules run: git -c http.extraheader= submodule update --init --recursive - uses: dtolnay/rust-toolchain@stable @@ -285,7 +256,7 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} # NOTE: crates.io is NOT the primary distribution channel — the GitHub - # Release tarballs + Homebrew + winget are. This step is best-effort but + # Release tarballs + Homebrew are. This step is best-effort but # MUST NOT silently report success on a real failure (it used to: # `cargo publish || echo "Skipped"` masked 403s / version-requirement # errors as "may already be published"). Now an already-published @@ -309,7 +280,7 @@ jobs: echo "✓ published $crate@$VERSION" sleep 30 # let the sparse index update before dependents publish else - echo "::warning title=crates.io publish failed::$crate@$VERSION failed (token/auth or version requirement). GitHub Release + Homebrew + winget are unaffected." + echo "::warning title=crates.io publish failed::$crate@$VERSION failed (token/auth or version requirement). GitHub Release + Homebrew are unaffected." FAILED="$FAILED $crate" fi done @@ -348,7 +319,7 @@ jobs: done - name: Checkout homebrew-tap - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: A3S-Lab/homebrew-tap token: ${{ secrets.HOMEBREW_TAP_TOKEN }} @@ -389,6 +360,7 @@ jobs: bin.install "a3s-box-shim" bin.install "a3s-box-guest-init" bin.install "a3s-box-cri" if File.exist?("a3s-box-cri") + bin.install "a3s-box-e2b" if File.exist?("a3s-box-e2b") lib.install Dir["lib/*"] if Dir.exist?("lib") end @@ -408,29 +380,3 @@ jobs: git add Formula/a3s-box.rb git commit -m "chore: update a3s-box to ${{ steps.version.outputs.tag }}" git push - - # ── Trigger winget publishing ─────────────────────────────────── - trigger-winget: - name: Trigger winget Publishing - needs: release - runs-on: ubuntu-latest - steps: - - name: Trigger winget workflow - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const tag = context.ref.replace('refs/tags/', ''); - const version = tag.replace('v', ''); - - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'publish-winget.yml', - ref: 'main', - inputs: { - version: version - } - }); - - console.log(`Triggered winget publishing for version ${version}`); diff --git a/.winget/A3SLab.Box.installer.yaml b/.winget/A3SLab.Box.installer.yaml index 6da47c61..86a7c9b0 100644 --- a/.winget/A3SLab.Box.installer.yaml +++ b/.winget/A3SLab.Box.installer.yaml @@ -16,7 +16,7 @@ NestedInstallerFiles: - RelativeFilePath: a3s-box-v0.8.0-windows-x86_64\lib\krun.dll Installers: - Architecture: x64 - InstallerUrl: https://github.com/AI45Lab/Box/releases/download/v0.8.0/a3s-box-v0.8.0-windows-x86_64.zip + InstallerUrl: https://github.com/A3S-Lab/Box/releases/download/v0.8.0/a3s-box-v0.8.0-windows-x86_64.zip InstallerSha256: FA85AF0AC8A0BEBAA2A3BB51FE9FE3E966F366746484AFCA3FA72FE540015B3E Dependencies: WindowsFeatures: diff --git a/.winget/A3SLab.Box.locale.en-US.yaml b/.winget/A3SLab.Box.locale.en-US.yaml index 305a8362..f9f3fc5d 100644 --- a/.winget/A3SLab.Box.locale.en-US.yaml +++ b/.winget/A3SLab.Box.locale.en-US.yaml @@ -5,12 +5,12 @@ PackageIdentifier: A3SLab.Box PackageVersion: 0.8.0 PackageLocale: en-US Publisher: A3S Lab -PublisherUrl: https://github.com/AI45Lab -PublisherSupportUrl: https://github.com/AI45Lab/Box/issues +PublisherUrl: https://github.com/A3S-Lab +PublisherSupportUrl: https://github.com/A3S-Lab/Box/issues PackageName: a3s-box -PackageUrl: https://github.com/AI45Lab/Box +PackageUrl: https://github.com/A3S-Lab/Box License: MIT -LicenseUrl: https://github.com/AI45Lab/Box/blob/main/LICENSE +LicenseUrl: https://github.com/A3S-Lab/Box/blob/main/LICENSE ShortDescription: MicroVM sandbox runtime with native Windows WHPX support Description: |- a3s-box is a Docker-like MicroVM runtime for Linux OCI workloads. The Windows @@ -45,6 +45,6 @@ ReleaseNotes: |- - Requires Windows Hypervisor Platform. - Does not require WSL. - Windows CRI is unsupported. -ReleaseNotesUrl: https://github.com/AI45Lab/Box/releases/tag/v0.8.0 +ReleaseNotesUrl: https://github.com/A3S-Lab/Box/releases/tag/v0.8.0 ManifestType: defaultLocale ManifestVersion: 1.6.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd333be..6e35fe13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,298 @@ All notable changes to A3S Box will be documented in this file. ### Added +- **Owner-scoped E2B filesystem Snapshots.** The compatibility service now + provides durable capture, source-filtered listing, restore, and delete with + startup reconciliation, generation-fenced source quiescing, copy-on-write + restores, resolved OCI-default fidelity, Unix ownership/mode preservation, + and in-use deletion conflicts. Official and A3S Python sync/async and + TypeScript clients pass the same real-`crun` matrix on A3S OS. +- **Owner-scoped E2B Volumes.** The compatibility service now provides durable + create, connect, list, and delete operations plus authenticated + volume-content directory, file, path, and metadata routes, with startup + reconciliation for interrupted transitions. Official and A3S Python + sync/async and TypeScript clients prove bidirectional Sandbox mounts, UID/GID + mapping, in-use deletion conflicts, and cleanup against real `crun` + executions on A3S OS. +- **Runtime-backed E2B Sandbox logs.** The compatibility service now exposes + generation-fenced v1 and v2 Sandbox log routes over the canonical structured + runtime logs, including cursor, direction, level, search, and limit + semantics. Rotated gzip files are read oldest-first with decompression + bounds, live partial tails are ignored safely, and responses are stably + ordered by timestamp across concurrent stdout/stderr writers. +- **Memory-preserving E2B Sandbox pause and resume.** The compatibility service + now exposes generation-fenced pause/resume transitions backed by certified + `crun`, preserves paused state across listing and reconciliation, and resumes + through `connect` without shortening the existing TTL. Official and A3S + Python sync/async and TypeScript clients prove that an already-running + process survives the cycle. Filesystem-only pause remains explicitly + unsupported. +- Added canonical `compose.acl` project files with automatic discovery, a + closed A3S ACL schema for services, health checks, volumes, and networks, + and `env("NAME")` resolution. Explicit Docker Compose-compatible YAML files + remain supported. +- Expanded Compose project operations with `start`, `stop`, `restart`, `rm`, + `kill`, `pause`, `unpause`, `wait`, `exec`, `top`, `port`, `cp`, `images`, + `pull`, `ls`, and `volumes`, all resolved through durable project/service + labels. Lifecycle, process, copy, and pull operations reuse the canonical + single-box paths, while project views remain read-only. +- Exposed typed Rust SDK create, start, run, inspect, pause, resume, restart, + kill, and reconciliation operations through the canonical generation-fenced + execution manager, with complete caller-policy parity coverage. +- Added production E2B credential providers with salted PBKDF2-SHA256 account + hashes and scope-bound AES-256-GCM sandbox tokens backed by independent HMAC + verification and versioned key rotation. +- Added durable E2B sandbox route policies, strict wildcard/shared route + parsing, and immutable leases fenced by sandbox and execution generations, + expiry, exact port policy, and token scope. +- Added the ACL-configured `a3s-box-e2b` production control service, composing + hashed account authentication, rotating encrypted sandbox tokens, SQLite + lifecycle state, the canonical runtime manager, startup reconciliation, + periodic expiry maintenance, and graceful shutdown. An opt-in A3S OS smoke + covers HTTP lifecycle, process restart recovery, and runtime cleanup. +- Added the production E2B wildcard TLS gateway with bounded HTTP/1.1 and + HTTP/2 proxying, direct/shared route validation, CORS preflight, credential + stripping, upgrade bridging, and a generation- and PID-fenced connector into + real Sandbox network namespaces. The production smoke now covers TLS routing, + scope denial, restart recovery, and stale-route fencing. +- Added an opt-in production lifecycle gate for the checksum-pinned, unchanged + official Python sync, Python async, TypeScript, and Code Interpreter clients, + with real Sandbox cleanup verification after every client flow. +- Added a host-side authenticated envd `GET /health` broker on port `49983`. + It re-inspects the generation-fenced execution before returning `204`, while + all non-envd routes continue through the Sandbox namespace connector. +- Added a backend-neutral execution-session interface for generation-fenced + command, PTY, and file access. Local sessions bind the runtime endpoint + before their final generation and process-identity check. +- Added the first E2B Process Connect JSON broker with generation-scoped + synthetic process IDs, ordered output streams, stdin and SIGKILL control, + PTY start/resize support, and a focused official-client foreground-command + production gate. +- Added typed per-template envd placement (`broker` or `runtime`) and a pinned + multi-architecture runtime image definition. Runtime-mode templates proxy + authenticated health, Process, Filesystem, and file HTTP requests to envd + inside the exact generation-fenced Sandbox. +- Added typed Python (`a3s-box`) and TypeScript (`@a3s-lab/box`) convenience + packages that re-export pinned official E2B clients, provide per-call A3S + endpoint configuration, and are built and tested as CI and release assets. +- Added an opt-in production smoke mode for immutable E2B runtime images, + covering runtime-mode envd readiness and in-Sandbox Code Interpreter health + while retaining restart, fencing, and cleanup assertions. +- Expanded the unchanged official-client runtime-image gate across Python + sync/async and TypeScript with Filesystem mutation and metadata, background + stdin, process listing, PTY resize, and Code Interpreter context execution. +- Added an opt-in replay of the production runtime-image matrix through the + native Python and TypeScript packages, including a regression test that keeps + their TypeScript build bound to the pinned official dependencies. + +### Changed + +- Made `compose up` convergent through deterministic effective-configuration + digests. Unchanged active services are reused, changed or inactive services + are recreated, service selection includes transitive dependencies, and + foreground mode now attaches logs and handles Ctrl-C instead of silently + behaving like detached mode. + +### Fixed + +- **Legacy filesystem Snapshot restore fails closed.** Snapshot records from + older builds that lack resolved OCI image defaults remain listable, + inspectable, and deletable, but restore is rejected before execution + reservation because the historical entrypoint, environment, user, and + working directory cannot be reconstructed safely. +- Started E2B Sandbox expiry after both the runtime and envd control path are + ready, so cold startup no longer consumes the requested usable timeout. + Startup reconciliation applies the same rule while preserving recovery of + historical records that are already expired. +- Normalized generated resolver/account files and cached standalone + `/etc/hosts` to `0644`, so non-root Sandbox users can resolve names and read + essential identity files even after restrictive image or host umasks. +- Delayed runtime-mode sandbox publication until a generation-fenced envd port + probe succeeds, and now stop and hide runtimes that never become ready. The + pinned runtime image also waits for Jupyter and Code Interpreter health + before starting envd. +- Preserved OCI layer directory modes in recursive cache copies and normalized + host-created rootfs roots to `0755`, so restrictive service umasks do not + prevent non-root image users from traversing the container filesystem. +- Made authenticated envd health return the official-client terminal `502` + after kill without reopening a live route lease. Invalid tokens remain + unauthorized, ordinary traffic stays fenced, and the production client gate + now checks running-state methods before and after termination. +- Applied `${...}` expansion directly to string values in the parsed Compose + ACL document instead of round-tripping ACL source through YAML. Canonical + files beginning with `service` now retain their syntax and environment + interpolation works without a leading blank line. +- Made `compose down` detach both Linux overlay mounts and platform-backed + writable rootfs mounts before deleting service storage, preventing leaked + macOS APFS mounts after a project is removed. +- Kept explicit service selections scoped to the requested services across + lifecycle commands and foreground `up`, rejected unknown service names in + `logs` and `cp`, and stopped Compose copy operations from resolving boxes + outside the current project. +- Made Compose teardown use atomic state removal, exact project network names, + deduplicated named volumes, and shared partial-start cleanup so failures do + not leave service directories or platform rootfs mounts behind. +- Made the host-integration runner invoke the canonical ACL Compose smoke and + remove its temporary stub-libkrun directory on both ordinary and soak exits. + +## [3.0.9] — 2026-07-11 + +### Added + +- **macOS fault-injection endurance runner.** A new isolated Apple Silicon/HVF + harness supports staged 2-hour, 24-hour, and 72-hour soak validation with + shim/CLI termination, recovery assertions, resource sampling, admission + gates, and machine-readable evidence. + +### Changed + +- **Native Node.js 24 GitHub Actions.** Checkout and artifact actions now use + their native Node.js 24 releases, removing deprecation warnings from CI and + release workflows. +- **Faster and more predictable runtime paths.** Package-cache preparation, + warm-pool routing, bounded `info`, and macOS BuildKit VM execution have been + tightened for repeated development and CI workloads. + +### Fixed + +- **Runtime correctness across lifecycle, networking, and storage.** Fixes + include detached health scheduling, Compose variable defaults, quoted build + arguments, commit metadata preservation, bridge peer and published Redis data + paths, case-sensitive APFS rootfs handling, and virtiofs descriptor lifetime. +- **Cross-platform builds.** OCI metadata and warm-pool clients now compile on + Windows, with Unix-only commit and health paths correctly gated. +- **Release automation.** The libkrun publish workflow is valid YAML again and + no longer creates failed zero-job runs on every push. + +## [3.0.8] — 2026-07-09 + +### Changed + +- **Release automation temporarily skips Windows.** GitHub Actions releases now + publish Linux x86_64, Linux arm64, and macOS arm64 artifacts without waiting + for the Windows WHPX runner or triggering winget publishing. + +## [3.0.7] — 2026-07-09 + +### Fixed + +- **SDK crates.io publishing metadata.** `a3s-box-sdk` now declares crates.io + version requirements for its internal Box dependencies, allowing release + automation to publish the SDK crate. +- **Winget release automation clarity.** The winget workflow now uses the + requested release tag for workflow-dispatch runs and reports a non-blocking + first-submission warning when `A3SLab.Box` has not yet been added to + `microsoft/winget-pkgs`. + +## [3.0.6] — 2026-07-09 + +### Added + +- **BuildKit VM backend for macOS Dockerfile `RUN`.** `a3s-box build` now + supports `--builder auto|host|buildkit-vm`; on macOS, Dockerfiles containing + `RUN` automatically delegate to BuildKit inside an A3S Linux VM unless the + unsafe host-run escape hatch is explicitly enabled. The BuildKit VM backend can + load OCI output back into the A3S image store or push directly with + `--push --plain-http`. +- **Large workspace verification profile.** `a3s-box run` now supports + `--package-cache pnpm|npm` and per-run `--virtiofs-cache`, with documented + pnpm/npm cache, tmpfs, and virtio-fs settings for package-manager-heavy + release checks. + +### Changed + +- **Faster cached rootfs copies on APFS.** macOS rootfs copy fallback now prefers + copy-on-write cloning before byte-copying, reducing startup cost for + short-lived cached-image boxes. +- **Nested runtime readiness inside guests.** Guest init prepares cgroup v2 + earlier so BuildKit/runc can start build containers inside the helper VM. + +### Fixed + +- **macOS release builds no longer require unsafe host `RUN`.** Dockerfile builds + with `RUN` now have a supported isolated local path on Apple Silicon, including + `linux/amd64` BuildKit builds. + +## [3.0.5] — 2026-07-08 + +### Added + +- **Explicit plain-HTTP registry push.** `a3s-box push` now supports + `--plain-http`, `--insecure`, and Docker-compatible `--tls-verify=false` for + trusted private registries. The Rust SDK exposes the same protocol selection + through `RegistryProtocol` and `PushImage::plain_http(true)`. +- **CI-safe foreground runs.** `a3s-box run` now closes guest stdin by default, + accepts `--no-stdin` for explicit non-interactive runs, and adds + `--timeout ` for foreground commands. Timed-out runs stop/remove the + box through the normal cleanup path and return exit code 124. + +### Changed + +- **Exec readiness waits are bounded and diagnosable.** Boot-time exec-server + readiness probing now defaults to a 15s safety cap, logs progress with the + socket path, exits early when the guest has already persisted an exit code, + and can be tuned with `A3S_EXEC_READY_TIMEOUT_MS`. +- **More useful pnpm package caches.** `--package-cache pnpm` now also persists + Corepack, `PNPM_HOME`, and npm cache data, disables Corepack's download prompt, + and prefers offline package resolution by default. `a3s-box info` reports the + pnpm cache volume status and size. +- **Stable host-volume traversal.** Guest virtio-fs mounts default to + `cache=none` for safer large host tree traversal on macOS/HVF. Set + `A3S_VIRTIOFS_CACHE=auto`, `always`, or `default` to override. + +### Fixed + +- **Rootfs writes through `/etc` symlinks.** Rootfs setup now writes generated + files such as `/etc/nsswitch.conf` inside the guest rootfs even when `/etc` is + an absolute symlink, fixing images such as `quay.io/skopeo/stable`. +- **Dockerfile build blockers.** Linux `RUN` now honors `WORKDIR` inside the + chroot, declared `ARG` values are visible to `RUN`, unsafe macOS host-run + propagates the build environment, `RUN chown`-only changes produce a layer, + cached layers are copied into the active build directory before export, and + layer-copy errors include the missing source/destination context. +- **Layer extraction directory-to-symlink replacements.** OCI layer extraction + now prepares symlink destinations so a later layer can replace an existing + directory with a symlink without failing. + +## [3.0.4] — 2026-07-08 + +### Added + +- **pnpm install benchmark parity.** `bench/bench.sh pnpm` and `just bench-pnpm` + now benchmark a real project or the reduced `bench/fixtures/pnpm` fixture, + split install time into VM boot, Corepack/pnpm setup, `pnpm fetch`, offline + `node_modules` materialization on the project mount, tmpfs materialization, + and full frozen install. When Docker is available, the harness also reports + cold/hot Docker baselines and A3S/Docker ratios. + +### Fixed + +- **pnpm package-cache toolchain reuse.** `--package-cache pnpm` now persists + Corepack's prepared pnpm toolchain with `COREPACK_HOME=/a3s-cache/pnpm/corepack` + in addition to the pnpm store, avoiding repeated toolchain downloads across + throwaway boxes. + +## [3.0.2] — 2026-07-07 + +### Fixed + +- **Dockerfile BuildKit cache mounts.** `a3s-box build` now parses + `RUN --mount=type=cache,target=... ` instead of passing the + `--mount` flag to `/bin/sh`, and fails clearly for unsupported mount types. +- **Foreground run lifecycle.** `a3s-box run --rm` now observes persisted guest + exit codes and handles `SIGTERM` the same cleanup path as Ctrl-C, preventing + interrupted foreground runs from leaving active box records behind. +- **OCI entrypoint resolution.** Relative image entrypoints such as + `docker-entrypoint.sh` are resolved through the container `PATH`, matching + common Docker image behavior. +- **Image store state errors.** Image index write/lock failures now include the + affected path and an `A3S_HOME` hint so restricted environments can point Box + at a writable state directory. + +## [3.0.0] — 2026-07-06 + +### Added + - **Programmable-CI pipeline: parallel fan-out + typed JSON report (`a3s-box-sdk`).** `Base::run_parallel(steps, max_concurrency)` runs steps concurrently as isolated copy-on-write MicroVM forks (bounded, collect-all, results in input order) and returns a diff --git a/README.md b/README.md index fe0d7d85..d5ff442f 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,76 @@ # A3S Box

- A kernel per workload — at container speed. + OCI Workload Runtime for MicroVMs and Sandboxes

- A Docker-like runtime that runs each Linux OCI workload inside its own libkrun MicroVM. VM-grade isolation — a real kernel per box, with optional hardware TEE — brought to container-class startup and density by native Copy-on-Write snapshot-fork. + Run Linux OCI workloads in a hardware-backed MicroVM by default, or explicitly choose a low-overhead shared-kernel Sandbox on certified Linux hosts.

--- -## Why A3S Box +## Overview -**The tradeoff every runtime forces on you.** Containers are fast and dense, but they share the host kernel — one kernel bug or escape crosses every tenant on the box. Virtual machines isolate with their own kernel, but they are slow and heavy to start and to scale. You pick *speed* or *isolation*. +A3S Box is an OCI workload runtime with a Docker-like CLI and two explicit +execution backends. The default path boots each workload in its own +[libkrun](https://github.com/containers/libkrun) MicroVM. Linux operators can +instead request `--isolation sandbox` to run through certified +[crun](https://github.com/containers/crun) with namespaces, seccomp, +capabilities, `no_new_privs`, and cgroup v2. -**A3S Box collapses that tradeoff.** Every box is a real MicroVM with its own Linux kernel — yet native Copy-on-Write **snapshot-fork** clones a *booted* template instead of cold-booting each one, so a VM starts and scales like a container. Strong isolation stops being a thing you pay for in latency and footprint. +The two modes are deliberately not presented as equivalent. A MicroVM has a +separate guest kernel and a hardware-virtualization boundary. A Sandbox shares +the host Linux kernel and is intended for agent tools, benchmarks, and +development automation whose threat model does not include a working kernel +exploit. Box never falls back from MicroVM to Sandbox when virtualization is +unavailable. -Measured on a `/dev/kvm` host (not aspirational): - -| | A3S Box | Why it matters | -| --- | --- | --- | -| **Isolation** | A real Linux kernel per workload, optional AMD SEV-SNP confidential computing | A guest kernel bug stays in the guest — unlike a shared-kernel container escape | -| **Cold start** | ~200 ms | Already VM-fast, before forking | -| **Snapshot-fork** | ~110 ms per fork · 100 forks in **under ~1 s** (~8 ms amortized) · ~13 MB RSS each | VM density and startup at *container* scale | -| **Warm pool** | a pre-booted box served in ~73 ms (~23× vs cold), CoW-filled | Sub-100 ms acquire for bursty/agent workloads | -| **Developer surface** | `run` / `build` / `exec` / `logs` / `compose`, OCI images, Kubernetes CRI, a Rust SDK (programmable CI pipelines) | No new mental model — your Docker workflow, unchanged | - -In one line: **the isolation of a VM, the startup and density of a container, the ergonomics of Docker.** That is the core of A3S Box. Everything below is an honest account of how far each surface is actually built. +The local CLI and Rust SDK are the primary product surfaces. OCI Sandbox +execution, the E2B protocol service, Kubernetes integration, TEE workflows, +and Windows support have different maturity and host requirements; the status +table below states those boundaries explicitly. ## Current status -A3S Box is built toward production use, but it is not a full Docker, containerd, or Kubernetes replacement yet. The local CLI runtime is the primary product surface. Kubernetes CRI, hardware TEE, and Windows support exist in code paths but should be treated as integration surfaces that need host-specific validation before production use. - -As of **v2.4.0**, three adversarial audits — production-operability (24 findings), untrusted-input security (4, including a critical registry-digest path-traversal), and concurrency/atomicity (4) — have been closed, every fix verified on real microVMs. The merged tree is validated end-to-end: a composed-main CI integration run on a real `/dev/kvm` host, a **2-hour / 4584-operation endurance soak with zero resource leak**, and complex **stateful** workloads (named-volume persistence across stop/start and restart, a stateful database surviving a restart, and a web server). Net: the local CLI runtime is suitable for **controlled production** with trusted-to-semi-trusted workloads; adversarial multi-tenant deployment at large scale still benefits from independent scale testing and an external security review. +A3S Box is not a full Docker, containerd, or Kubernetes replacement. An +implemented API is also not automatically a production guarantee for every +host or threat model. Release claims require the host-backed gates documented +in [Host Integration](docs/host-integration.md), while cluster and CRI gaps +remain explicit in +[Production Cluster Tests](docs/production-cluster-tests.md) and +[CRI Conformance](docs/cri-conformance.md). | Area | Status today | | --- | --- | | Local CLI runtime | Implemented for macOS Apple Silicon/HVF and Linux/KVM style hosts. Real macOS HVF core and host smoke suites have passed with Alpine pulled from the registry; offline archive runs remain the release-gate default. | | OCI images | Pull, load, save, tag, inspect, history, remove, and local cache resolution are implemented. Push and cosign signing/verification paths exist and require registry access for end-to-end validation. | -| Dockerfile build | Honest subset. `FROM`, metadata instructions, `COPY`/`ADD`, and shell-form `RUN` are implemented. `RUN` is isolated with Linux `chroot` and requires root-capable Linux; macOS fails by default unless explicitly unsafe host execution is enabled. | +| Dockerfile build | Honest subset. `FROM`, metadata instructions, `COPY`/`ADD`, and shell/exec-form `RUN` are implemented by the host engine on Linux. `--run-pool` can execute `RUN` through a leased warm-pool VM by mounting the mutable build rootfs into the guest. On macOS, auto `RUN` builds still delegate to BuildKit inside an A3S Linux VM (`--builder=buildkit-vm`) unless `--run-pool` is selected; unsafe host execution remains an explicit experiment-only escape hatch. | | Lifecycle and exec | `run`, `create`, `start`, `stop`, `restart`, `rm`, `wait`, foreground/detached runs, non-PTY exec, PTY exec, logs, stats, and inspect are implemented. | +| OCI Sandbox | Linux-only, explicit `--isolation sandbox` shared-kernel execution through certified `crun`. Structured `json-file` logs preserve stdout/stderr identity for foreground, detached, natural-exit, stop, kill, and auto-remove paths. Generation-owned log workers are PID-start-time fenced, drained before archival, and recovered during cleanup. The security-negative matrix and performance gate remain release work; this mode does not claim MicroVM-equivalent isolation. | +| E2B protocol preview | The ACL-configured service covers durable lifecycle, memory-preserving pause/resume, owner-scoped filesystem Snapshots and Volumes, v1/v2 running/paused listing and runtime-backed structured logs, current control metrics, TLS routing, terminal health, and runtime envd file/environment operations. The immutable runtime-image gate drives pinned official and native A3S Python sync/async and TypeScript clients through Snapshot capture/list/restore/delete with filesystem, Unix-metadata, and OCI-default fidelity; bidirectional Volume mounts; Filesystem, Process, PTY, logs, and Code Interpreter flows on real `crun` Sandboxes. Typed source packages are built but unpublished. Templates/builds, filesystem-only pause, historical metrics, sustained log-retention/rotation races, deeper Snapshot/Volume failure recovery, multi-file and large-file behavior, signed-file, public-port, rich interpreter, MCP, and full release matrices remain incomplete; `full_compatibility=false`. | | Warm pool and snapshot-fork | A warm pool serves pre-booted sandboxes over a socket. Native snapshot-fork (Copy-on-Write microVM cloning) snapshots one booted template and restores many forks from it, each mapping the template RAM `MAP_PRIVATE`. Verified on `/dev/kvm`: ~4× faster than a cold boot per fork, 100 forks in under ~1 s (~8 ms amortized each). Requires `/dev/kvm`; opt in with `pool start --snapshot-fork` or the `KRUN_SNAPSHOT_*` / `KRUN_RESTORE_FROM` env. | | Networking | Default TSI networking, TCP `host:guest` publishing, user-defined bridge networks, network inspect/connect/disconnect/rm, and `/etc/hosts` peer discovery are implemented with documented platform boundaries. | -| Compose | A useful local subset is implemented: image, command, entrypoint, env, env_file, ports, volumes, depends_on, networks, DNS, tmpfs, workdir, hostname, extra_hosts, labels, healthcheck, restart, CPU/memory, capabilities, and privileged mode. | +| Compose | Canonical `compose.acl` applications and an explicit Docker Compose-compatible YAML subset are implemented, including convergent `up`, project-scoped lifecycle commands, dependency conditions, health checks, networks, volumes, ports, and runtime/security settings. | | TEE | AMD SEV-SNP-oriented attestation, RA-TLS, sealing, and secret injection flows exist, plus simulation mode for development. Hardware-backed operation depends on SEV-SNP-capable hosts and libkrun support. TDX is not a productized path. | | Kubernetes CRI | Reachable by `crictl`/kubelet over its Unix socket. Verified on a `/dev/kvm` host: pod + container lifecycle (`RunPodSandbox` → `CreateContainer` → `StartContainer` → `Stop`/`Remove`), `exec` over Kubernetes SPDY/3.1 `remotecommand` (TTY and non-TTY, stdin/stdout/stderr, exit codes), and container log capture to `log_path`. Not yet conformant: `attach` and the stricter `critest` specs (log format, Linux SecurityContext, seccomp/AppArmor, namespaces, mount propagation). Linux-only; not the core completion target. **RuntimeClass:** a one-command per-node installer (`deploy/scripts/install-runtimeclass.sh`) registers the `io.containerd.a3s-box.v2` runtime, and `runtimeClassName: a3s-box` is validated end-to-end (pod start + `kubectl exec`) across a 5-node cluster — see [Deploy as a Kubernetes RuntimeClass](#deploy-as-a-kubernetes-runtimeclass). | -| Windows | Native WHPX backend through libkrun. The Windows package runs directly on Windows with Windows Hypervisor Platform enabled; it does not require WSL. Windows CRI is intentionally out of scope. | +| Windows | Native x86_64 WHPX/libkrun code paths exist and do not require WSL. Windows remains a host-specific integration surface; standard release automation currently focuses on Linux and macOS, and Windows CRI is out of scope. | + +## Isolation model -## What A3S Box is +A3S Box takes a Linux OCI image and resolves it to either the default MicroVM +backend or the explicitly selected shared-kernel Sandbox backend. Backend +selection is deterministic, persisted with managed executions, and never +silently falls back. Use the default MicroVM backend when a separate guest +kernel or hardware virtualization boundary is required. -A3S Box is a **MicroVM runtime**. It takes a Linux OCI image, prepares a root filesystem, boots a small VM with libkrun, and runs the image process under guest-init. It is designed for stronger isolation than a namespace-only container while keeping a Docker-like developer workflow. +| Property | Default MicroVM | `--isolation sandbox` | +| --- | --- | --- | +| Runtime | libkrun | Certified `crun` | +| Isolation class | Hardware VM with a dedicated guest kernel | Shared host kernel | +| Intended workload | Stronger tenant boundaries and untrusted workloads | Trusted or semi-trusted tools, benchmarks, and automation | +| TEE, warm pool, snapshot-fork | Supported on qualifying hosts | Rejected | +| Automatic fallback | Never | Never | A3S Box is not: @@ -69,7 +90,7 @@ The ignored `core_smoke` suite covers the core CLI path on a real MicroVM host: - TCP published ports with host loopback HTTP reachability; - bridge network endpoint allocation, peer `/etc/hosts`, connect/disconnect, and force removal cleanup; - named volumes, `cp`, `diff`, `export`, `commit`, `snapshot`, restart-policy monitor recovery, and Compose health/volume flow; -- warm pool (`pool start`/`pool run`): pre-warmed sandboxes served over a socket, with backpressure and multi-image lazy pools; `--deferred` runs each command as the box's real main for full box semantics (real exit code + json-file console logs) with no cold boot; `--snapshot-fork` fills the pool by Copy-on-Write restore from one booted template instead of cold booting each sandbox. +- warm pool (`pool start`/`pool run`/`run --pool`): pre-warmed sandboxes served over a socket, with backpressure and multi-image lazy pools; `A3S_BOX_RUN_POOL_SOCKET` can auto-route compatible foreground `run --rm` commands through the daemon; `--deferred` runs each command as the box's real main for full box semantics (real exit code + json-file console logs) with no cold boot; `--snapshot-fork` fills the pool by Copy-on-Write restore from one booted template instead of cold booting each sandbox. The most recent local record, on June 29, 2026: all 15 ignored `core_smoke` tests passed on macOS Apple Silicon/HVF with Alpine pulled from the registry, @@ -80,6 +101,12 @@ produced a passing evidence bundle at 4 resource samples, zero failed iterations, and no shim/mount/socket/box-dir growth. +On July 18, 2026, the focused canonical `compose.acl` host smoke also passed on +macOS arm64/HVF with `docker.io/library/alpine:latest`. It covered unchanged +`up` convergence, pull/project views, exec/top/port/copy, stop/start/restart, +pause/unpause, kill/wait/remove, `down -v`, and final Box/socket cleanup. This +focused result does not replace the full macOS/Linux host matrix release gate. + For **v2.4.0**, the merged tree was additionally validated on a real Linux `/dev/kvm` host: the composed-main CI integration suite passed; a **2-hour endurance soak of 4584 real-microVM operations** (high-frequency @@ -105,17 +132,22 @@ brew install a3s-lab/tap/a3s-box # From source git clone https://github.com/A3S-Lab/Box.git -cd Box/src -cargo build --release +cd Box +just release ``` +For development builds that you plan to run locally, build the static Linux +guest init as well: `just build-guest debug`. `a3s-box` refreshes this binary +into each guest rootfs as PID 1; without it, cached images may keep an older +guest init and miss newer runtime behavior such as staged environment variables. + On macOS, use Apple Silicon. On Linux, use a host with KVM/libkrun support. On Windows, enable Windows Hypervisor Platform for the native WHPX backend: ```powershell Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform ``` -Run `a3s-box info` first; it reports virtualization, platform, bridge backend, port-publishing support, and TEE availability. +Run `a3s-box info` first; it reports virtualization, platform, bridge backend, port-publishing support, TEE availability, package-cache state, the current virtio-fs cache mode, and any reachable warm-pool daemon on the default or configured sockets. ## Quick start @@ -172,12 +204,30 @@ a3s-box wait BOX [BOX...] Important supported options: - `--name`, `--label`, `--restart no|always|on-failure[:N]|unless-stopped`; -- `--cpus`, `--memory`, `--timeout`, `--pids-limit`, `--cpuset-cpus`, `--ulimit`, CPU quota/shares, memory reservation/swap; +- `--cpus`, `--memory`, `--timeout ` for foreground runs, `--pids-limit`, `--cpuset-cpus`, `--ulimit`, CPU quota/shares, memory reservation/swap; - `-e/--env`, `--env-file`, `--entrypoint`, `-u/--user`, `-w/--workdir`, `--hostname`, `--add-host`; +- `-i/--interactive` to keep stdin open; non-interactive runs close guest stdin by default, and `--no-stdin` makes that explicit; +- `--package-cache pnpm|npm` to mount persistent package-manager caches for repeated throwaway Node boxes; - `--health-cmd`, `--health-interval`, `--health-timeout`, `--health-retries`, `--health-start-period`, `--no-healthcheck`; - `--stop-signal`, `--stop-timeout`, `--persistent`, `--log-driver json-file|none`; - `--cap-add`, `--cap-drop`, `--security-opt seccomp=default|seccomp=unconfined|no-new-privileges`, `--privileged`. +For CI-style one-shot commands, prefer foreground `run --rm --timeout ` and avoid `-i` unless the command truly needs stdin. A timed-out foreground run stops/removes the box according to the usual `--rm` behavior and exits with code 124. `a3s-box wait` prints a low-frequency stderr keepalive while it is blocking so long CI or SSH sessions do not look idle; use `--no-heartbeat` or `--heartbeat-interval ` to tune it. + +The manager-backed `create` path treats `start` as first activation of its +nonterminal reservation. Once that managed execution is stopped or failed, +ordinary `start` rejects it instead of reusing a stale generation. Explicit +managed restart persists separate teardown and startup phases, advances the +generation only after the old runtime is terminal, and retains its operation ID +for idempotent crash recovery. CLI `restart` uses that manager path for managed +records; legacy records retain their existing stop-and-boot behavior. + +Health checks for detached `run`, Compose services, and boxes brought back by +`start`/`restart` are owned by a generation-fenced background worker, not by the +short-lived creating CLI. The worker stops when that box generation stops or is +replaced; an installed `a3s-box monitor` detects the worker lock and does not +duplicate its probes. + Unsupported or guarded options fail early instead of being silently stored: host devices, GPUs, AppArmor labels, SELinux labels, custom seccomp profiles, unsupported users, invalid workdirs, unsupported port syntax, and unsupported network policies. ## Images and builds @@ -193,10 +243,23 @@ a3s-box tag alpine:latest local-alpine:dev a3s-box save -o alpine.tar alpine:latest a3s-box load -i alpine.tar --tag local-alpine:dev a3s-box push registry.example/org/image:v1 +a3s-box push --plain-http localhost:5000/org/image:v1 ``` Docker Hub aliases share cache resolution, so `alpine`, `alpine:latest`, and `docker.io/library/alpine:latest` can resolve to the same local image when unambiguous. Digest-only references resolve locally when the digest matches exactly or by unique prefix. +Authenticated pulls use credentials from `a3s-box login`, Docker configuration, +or `REGISTRY_USERNAME` / `REGISTRY_PASSWORD`. If a registry advertises Basic +authentication only after a protected manifest or blob request, Box retries an +unauthorized request with preemptive Basic authentication when both credential +fields are non-empty. Manifest, config, and layer digests are verified; layers +remain streamed to disk. Same-origin redirects retain authentication, while +cross-origin redirects never receive the registry Authorization header. The +same pull path is used by explicit `pull` and an implicit image pull during +`run`. + +Use `a3s-box push --plain-http` for an explicit HTTP registry. `--insecure` is accepted as an alias, and `--tls-verify=false` maps to the same behavior for Docker-compatible scripts. + Build support is intentionally explicit: ```bash @@ -204,16 +267,24 @@ a3s-box build -t app:dev . a3s-box build -t app:dev -f Containerfile . a3s-box build -t app:dev --build-arg VERSION=1.2.3 --platform linux/amd64 . a3s-box build -t builder --target builder --no-cache . # stop at a stage, skip the cache +a3s-box build --builder=buildkit-vm --platform linux/arm64 -t app:dev . # safe macOS RUN path +a3s-box build --builder=buildkit-vm --push --plain-http -t 10.0.0.2:5000/app:v1 . +a3s-box pool start --image alpine:latest --size 1 --socket /tmp/a3s-build-pool.sock +a3s-box build --run-pool --run-pool-socket /tmp/a3s-build-pool.sock -t app:dev . ``` -Supported Dockerfile subset: `FROM` including `scratch`, shell-form `RUN`, shell-form `COPY`/`ADD` (incl. `COPY --from=`, `COPY`/`ADD --chown=user[:group]`), `WORKDIR`, `ENV`, `ENTRYPOINT`, `CMD`, `EXPOSE`, `LABEL`, `USER`, `ARG`, `SHELL`, `STOPSIGNAL`, `HEALTHCHECK`, `ONBUILD` metadata triggers, and `VOLUME`. A context-root `.dockerignore` is honored. +Supported Dockerfile subset: `FROM` including `scratch`, shell/exec-form `RUN` (including `RUN --mount=type=cache,target=...` with Docker's default `sharing=shared`, or explicit `sharing=locked`, optional `from=,source=...` cache seeding, `RUN --mount=type=bind,source=...,target=...` from the build context or `RUN --mount=type=bind,from=,source=...,target=...`, `RUN --mount=type=tmpfs,target=...` without `size=`, and Docker's no-op defaults `RUN --network=default` / `RUN --security=sandbox`), shell-form `COPY`/`ADD` (incl. `COPY --from=`, `COPY`/`ADD --chown=user[:group]`), `WORKDIR`, `ENV`, `ENTRYPOINT`, `CMD`, `EXPOSE`, `LABEL`, `USER`, `ARG`, `SHELL`, `STOPSIGNAL`, `HEALTHCHECK`, `ONBUILD` metadata triggers, and `VOLUME`. A context-root `.dockerignore` is honored. -Build flags: `-t/--tag`, `-f/--file`, `--build-arg`, `--platform`, `--target ` (build only up to a stage), `--no-cache` (rebuild every layer), `-q/--quiet`. +Build flags: `-t/--tag`, `-f/--file`, `--build-arg`, `--platform`, `--target ` (build only up to a stage), `--no-cache` (rebuild every layer), `--builder auto|host|buildkit-vm`, `--push`, `--plain-http`, `--buildkit-image `, `--buildkit-cpus `, `--buildkit-memory `, `--run-pool`, `--run-pool-socket `, `--run-pool-autostart`, `--run-pool-image `, `--run-pool-cpus `, `--run-pool-memory `, `--run-pool-timeout `, `--run-cache-dir `, `-q/--quiet`. Boundaries: - `RUN` uses isolated Linux `chroot`, requires root-capable Linux, validates shell/workdir preconditions, and has a Linux-only ignored smoke test; -- macOS `RUN` fails by default; `A3S_BOX_UNSAFE_HOST_RUN=1` enables unsafe host-side experiments only; +- `--run-pool` is the built-in engine's isolated VM path for `RUN`: it leases one warm-pool VM per build stage, mounts that stage's mutable rootfs at `/run/a3s/build-rootfs`, executes shell-form RUN through the configured shell and exec-form RUN as argv with the Dockerfile `WORKDIR`, `ENV`, and `USER`, then diffs the host rootfs into OCI layers. It requires a running `a3s-box pool start` daemon. `RUN --mount=type=cache` is treated as a persistent cache overlay keyed by `id=` (or by `target=` when `id` is omitted); cache contents are visible during matching `RUN` commands but are restored before layer diffing, so they are not committed to the image. Successful RUNs publish cache writes; failed RUNs restore the rootfs without publishing partial cache contents. A new cache can be seeded from `from=,source=`; once the persistent cache exists, it is not re-seeded. The warm-pool path accepts Docker's default omitted `sharing=shared`, explicit `sharing=shared`, and `sharing=locked`; because the host overlay hydrates and publishes cache directories around each RUN, access to the same cache key is serialized across builds to avoid writeback races. Cache-root `mode=`, `uid=`, and `gid=` are supported; cache `sharing=private` remains unsupported. `RUN --mount=type=bind` can mount sources from the build context, a previous build stage, or an external image with `from=`, defaults `source=.` when omitted, resolves relative targets from `WORKDIR`, honors `.dockerignore` only for context sources, and discards writes before layer diffing. `RUN --mount=type=tmpfs` creates an empty temporary target, resolves relative targets from `WORKDIR`, restores the original target after RUN, and discards writes before layer diffing; `tmpfs size=` is not supported yet. `RUN --network=default` and `RUN --security=sandbox` are accepted as Docker's default no-op values; non-default per-RUN network/security modes are rejected until the warm-pool exec path can enforce them. +- macOS `RUN` auto-selects the BuildKit VM backend unless `--run-pool` or `A3S_BOX_UNSAFE_HOST_RUN=1` is set; explicit `--builder=host` keeps the built-in host engine behavior; +- the BuildKit VM backend loads `type=oci` output back into the A3S image store by default; `--push` writes directly to the tagged registry reference, uses the same credentials as `a3s-box push`, and `--plain-http` marks that registry as trusted HTTP for BuildKit; +- Apple Silicon `--builder=buildkit-vm --platform linux/amd64` is handled by BuildKit's Linux builder path and may use emulation, so expect slower builds than native `linux/arm64`; +- `A3S_BOX_UNSAFE_HOST_RUN=1` enables unsafe macOS host-side experiments only; - `--platform` records one target platform; multi-platform image indexes are not implemented. Builds use a Docker/BuildKit-style **layer cache**: each instruction extends a @@ -228,6 +299,9 @@ and everything after it. The cache lives at `~/.a3s/buildcache` and is size-capp ```bash a3s-box volume create data a3s-box run -d --name app -v data:/data alpine:latest -- sleep 3600 +a3s-box run --rm --cpus 4 --memory 4g --package-cache pnpm --virtiofs-cache=always \ + -v "$PWD:/work" -w /work --tmpfs /work/node_modules:size=4g \ + node:22-alpine -- sh -lc 'corepack enable && pnpm install --frozen-lockfile' a3s-box cp ./file.txt app:/data/file.txt a3s-box diff app a3s-box export app -o rootfs.tar @@ -237,13 +311,44 @@ a3s-box snapshot restore checkpoint-1 --name restored-app a3s-box snapshot prune --keep 5 # bound disk: keep the 5 newest ``` +`--package-cache pnpm` creates/reuses the named volume `a3s-cache-pnpm` and sets cache-friendly defaults: `PNPM_CONFIG_STORE_DIR=/a3s-cache/pnpm/store`, `npm_config_store_dir=/a3s-cache/pnpm/store`, `COREPACK_HOME=/a3s-cache/pnpm/corepack`, `PNPM_HOME=/a3s-cache/pnpm/home`, `npm_config_cache=/a3s-cache/pnpm/npm-cache`, `PNPM_CONFIG_PREFER_OFFLINE=true`, `npm_config_prefer_offline=true`, and `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`. Dependency downloads and the Corepack-prepared pnpm toolchain survive across `--rm` boxes without making the whole rootfs persistent. `--package-cache npm` creates/reuses `a3s-cache-npm` and sets `npm_config_cache=/a3s-cache/npm/cache` plus `npm_config_prefer_offline=true` for npm-only jobs. Override any of those with `-e KEY=VALUE` when a build needs a specific registry or cache policy. For throwaway install/build jobs, mounting `node_modules` as tmpfs avoids pushing thousands of small files through the project bind mount; prime the named cache volume before a release window when cold registry downloads or project-level supply-chain policy checks are known to dominate the first run. Use `bench/bench.sh pnpm` or `just bench-pnpm` to compare A3S project-mount, A3S tmpfs, and Docker cold/hot baselines. Auto-removed boxes also archive their last logs under `~/.a3s/removed-logs/`, and `a3s-box logs ` can read that archive after the box directory is gone. + +Host directory volumes are mounted with virtio-fs `cache=none` by default to favor stable traversal on macOS/HVF workloads with large source trees. Use `--virtiofs-cache=always` for release verification jobs where the host source tree is not changing during the run, or `--virtiofs-cache=auto|default` for local experiments; `A3S_VIRTIOFS_CACHE` remains available as a process-wide fallback and `a3s-box info` prints the active fallback setting. On macOS, each Linux rootfs lives below a private directory in a case-sensitive APFS sparse image. Cached sparse images are cloned with APFS copy-on-write, preserving Linux path identity without exposing APFS volume-management entries to the guest. + +Filesystem snapshots capture configuration and rootfs state, not live RAM or +device state. On overlay-capable hosts, restore uses a read-only snapshot lower +plus a private writable upper; in-use snapshots are protected from pruning. +Snapshots created by current builds also retain resolved OCI image defaults +and Unix rootfs metadata. Older records missing those defaults remain visible +for inspection and deletion, but restore fails closed because the original +entrypoint, environment, user, and working directory cannot be reconstructed +safely. Live MicroVM memory cloning is the separate snapshot-fork mechanism +below. + +Image extraction and `commit` preserve Linux uid, gid, mode, and symlink +metadata even when the macOS backing filesystem cannot represent OCI ownership +directly. Box records rootless layer metadata during extraction and guest-init +replays it before mounting any host workspace or volume; stopped persistent +boxes use a guest-captured terminal manifest so a committed image reflects the +container's final Linux-visible metadata rather than the host user's APFS +ownership. + The `snapshot` command produces configuration/filesystem-oriented Box snapshots, not a live RAM checkpoint. The live RAM Copy-on-Write facility is a separate, lower-level mechanism described in [Warm pool and snapshot-fork](#warm-pool-and-snapshot-fork). `snapshot restore` is **copy-on-write**: the restored box shares the snapshot's rootfs as a read-only overlay lower with its own per-box upper, so forking a warmed snapshot is near-instant, space-cheap (a few MB per fork), and isolated — this is what the [SDK](#sdk) pipeline API forks per step. (On a non-overlay host it falls back to a full copy.) `snapshot create` still deep-copies the box rootfs into the store, so a scheduled snapshot workflow can fill the disk: `snapshot prune --keep N` / `--max-bytes B` evicts the oldest beyond a cap, and `A3S_BOX_MAX_SNAPSHOTS` / `A3S_BOX_MAX_SNAPSHOT_BYTES` auto-prune on every `create` (unset = unbounded). Because a restored box keeps referencing its snapshot, `snapshot rm` / `prune` refuse to delete a snapshot still in use by a box (`--force` overrides). ## SDK -`a3s-box-sdk` is the Rust SDK for A3S Box, published to crates.io. Today it provides a **programmable CI/CD pipeline** API (`a3s_box_sdk::pipeline`): a pipeline is a Rust program and each step runs in its **own MicroVM** (one kernel per step), forking a warmed snapshot via copy-on-write `snapshot restore`. It is a dependency-free wrapper over the `a3s-box` CLI — the DAG is your code, not YAML. +`a3s-box-sdk` is the Rust SDK for A3S Box, published to crates.io. Its default +`A3sBoxClient` calls runtime stores, sockets, and the same generation-fenced +execution manager as the CLI without spawning the CLI. It exposes typed managed +lifecycle, image, volume, network, snapshot, diagnostics, exec, and file APIs; +see [`src/sdk/README.md`](src/sdk/README.md) for the direct client. + +The optional `pipeline-cli` feature provides a **programmable CI/CD pipeline** +API (`a3s_box_sdk::pipeline`): a pipeline is a Rust program and each step runs +in its **own MicroVM** (one kernel per step), forking a warmed snapshot via +copy-on-write `snapshot restore`. The DAG is your code, not YAML. ```rust use a3s_box_sdk::pipeline::{warm_base, WarmBase, FileCache, Step}; @@ -259,6 +364,544 @@ base.dispose(); The former MicroVM workload-execution SDK (`ExecutionRegistry`/`VmExecutor`, for embedding Box into higher-level runtimes such as a3s-lambda) is now the **`a3s-box-lambda`** crate. +### E2B protocol and Python/TypeScript SDK compatibility + +E2B compatibility is under active development and is not yet a released +compatibility claim. The first implementation gate pins the official control, +envd, volume-content, Process, Filesystem, MCP, Python, TypeScript, and Code +Interpreter contracts under [`compat/e2b/`](compat/e2b/README.md). CI regenerates +their endpoint, field, error, descriptor, and public-export inventories and +rejects unreviewed protocol drift. + +| Client | Pinned version | +| --- | ---: | +| Python `e2b` | 2.32.0 | +| TypeScript `e2b` | 2.33.0 | +| Python `e2b-code-interpreter` | 2.8.1 | +| TypeScript `@e2b/code-interpreter` | 2.6.1 | + +The typed [`a3s-box` Python package](sdk/python/README.md) and +[`@a3s-lab/box` TypeScript package](sdk/typescript/README.md) re-export those +pinned official SDK surfaces and provide per-call A3S endpoint configuration. +CI builds, installs, and tests both packages, and release automation produces +wheel, source, and npm tarball artifacts. They are source-tree previews and +are not yet published to PyPI or npm. The destructive production runner can +repeat its complete runtime-image matrix through both A3S packages after the +unchanged official clients pass. + +Native SDK users configure `A3S_BOX_ENDPOINT` and `A3S_BOX_API_KEY`; conventional +`https://api.` endpoints derive the Sandbox routing domain automatically. +Lifecycle responses advertise the public direct Sandbox authority, including a +configured non-standard TLS port, so normal deployments do not require a +process-global Sandbox URL override. Native A3S SDK applications use only +`A3S_BOX_*` connection settings and do not read `E2B_API_URL`; that variable is +reserved for the optional unchanged-official-SDK migration path. + +#### Self-hosted usage + +`a3s-box-e2b` is a network service. The Python and TypeScript packages connect +to that service; they do not start a local Box runtime. The production-tested +preview requires a Linux Sandbox host with the certified `crun` runtime, a +public control-plane address, and a TLS Sandbox gateway with wildcard DNS. +See [Host Sandbox Backend Design](docs/host-sandbox-backend-design.md) for the +host boundary and [E2B-Compatible SDK Design](docs/e2b-compatible-sdk-design.md) +for the complete protocol and release gates. + +The simplest single-host production topology uses two public listeners: + +| Public address | Purpose | Service destination | +| --- | --- | --- | +| `https://api.box.example.com` | Control API used by `A3S_BOX_ENDPOINT` | TLS reverse proxy to `api_listen`, for example `127.0.0.1:3000` | +| `https://-.box.example.com:8443` | Direct Sandbox data plane | `gateway.listen`, for example `0.0.0.0:8443` | +| `https://sandbox.box.example.com:8443` | Shared Sandbox route form | The same `gateway.listen` | + +Create explicit DNS for `api.box.example.com` and wildcard DNS for +`*.box.example.com`. The Sandbox gateway terminates TLS itself and its +certificate must cover `*.box.example.com`. Port `8443` avoids competing with +the control-plane TLS proxy on a single IP. A deployment with separate IP +addresses or an SNI-aware load balancer can use port `443` for both. + +The client and server settings map as follows: + +| Client setting | What to enter | Matching server setting | +| --- | --- | --- | +| `A3S_BOX_ENDPOINT` | The externally reachable control API origin, including `http://` or `https://` and any non-default port | `e2b_compat.api_public_url` | +| `A3S_BOX_API_KEY` | The **raw** API key issued to this client | The raw key whose PBKDF2-SHA256 encoding is stored in `account.hash` | +| `A3S_BOX_DOMAIN` | Optional Sandbox wildcard suffix, with no scheme or port | `e2b_compat.sandbox_domain` | +| `A3S_BOX_SANDBOX_URL` | Single-Sandbox fixture override only; normally unset | Not a production multi-Sandbox setting | + +Use the bare control origin for `A3S_BOX_ENDPOINT`. Do not append `/api`, +`/v1`, `/sandboxes`, a query string, or a fragment, and omit the trailing +slash. For example: + +| Deployment | `A3S_BOX_ENDPOINT` | Additional setting | +| --- | --- | --- | +| Standard HTTPS | `https://api.box.example.com` | None; the SDK derives `box.example.com` | +| Non-default control port | `https://api.box.example.com:8444` | None; the SDK still derives `box.example.com` | +| Custom/LAN hostname | `https://box-api.lab.example:8444` | `A3S_BOX_DOMAIN=sandboxes.lab.example` | +| Loopback development | `http://127.0.0.1:3000` | Set `A3S_BOX_DOMAIN` to the configured local Sandbox DNS suffix | + +Plain HTTP is appropriate only on loopback. A loopback control endpoint does +not remove the data-plane requirements: Filesystem, Process, PTY, health, and +Code Interpreter calls still need wildcard DNS, a trusted TLS certificate, and +reachability to the configured Sandbox gateway. + +##### 1. Generate the client API key and server hash + +The current compatibility service requires API keys in the form +`e2b_[0-9a-f]+`. The native A3S packages disable the upstream client-side +validator, but the self-hosted server still enforces this compatibility form. +Generate a high-entropy key and its server-side hash on a trusted +administrative machine: + +```bash +python3 - <<'PY' +import hashlib +import secrets + +iterations = 210_000 +salt = secrets.token_bytes(16) +api_key = f"e2b_{secrets.token_hex(32)}" +digest = hashlib.pbkdf2_hmac( + "sha256", + api_key.encode("utf-8"), + salt, + iterations, + dklen=32, +) +encoded = ( + f"pbkdf2-sha256${iterations}${salt.hex()}${digest.hex()}" +) + +print("Store this raw value in the client secret manager:") +print(f"A3S_BOX_API_KEY={api_key}") +print() +print("Paste only this encoded value into the server account.hash:") +print(encoded) +PY +``` + +The command prints the raw key once. Store the `A3S_BOX_API_KEY=...` value in +the client secret manager. Paste only the `pbkdf2-sha256$...` value into the +server ACL. Do not put the raw key in the ACL, and do not put the encoded hash +in `A3S_BOX_API_KEY`. + +Sandbox tokens use two different 32-byte service keys. Generate and persist +them separately; they are not account API keys: + +```bash +export A3S_BOX_E2B_TOKEN_ENCRYPTION_KEY_V1="$(openssl rand -hex 32)" +export A3S_BOX_E2B_TOKEN_DIGEST_KEY_V1="$(openssl rand -hex 32)" +``` + +Keep these values stable across service restarts. Losing or changing them +without a versioned rotation makes existing Sandbox tokens fail closed. + +##### 2. Configure the self-hosted service + +A3S Box accepts only ACL configuration parsed by `a3s-acl`. The following +single-host example keeps the plaintext control listener on loopback, exposes +the Sandbox TLS gateway on port `8443`, and defines a broker-mode base +template. Replace the account hash, certificate paths, runtime paths, and image +with values for the deployment. Pin the image by digest for production. + +```acl +e2b_compat { + api_listen = "127.0.0.1:3000" + api_public_url = "https://api.box.example.com" + sandbox_domain = "box.example.com" + sandbox_public_domain = "box.example.com:8443" + database_path = "/var/lib/a3s-box/e2b/lifecycle.sqlite3" + runtime_home = "/var/lib/a3s" + runtime_state_path = "/var/lib/a3s-box/e2b/managed-executions.json" + + gateway { + listen = "0.0.0.0:8443" + tls_certificate_path = "/etc/a3s-box/tls/sandbox-chain.pem" + tls_private_key_path = "/etc/a3s-box/tls/sandbox-key.pem" + max_connections = 4096 + handshake_timeout_ms = 5000 + connect_timeout_ms = 2000 + drain_timeout_seconds = 30 + } + + supervisor { + interval_seconds = 5 + batch_size = 100 + reconciliation_page_size = 100 + } + + account "primary" { + scheme = "api_key" + owner_id = "production-team" + client_id = "production-client" + hash = "pbkdf2-sha256$210000$$" + } + + token_key "2026-07" { + version = 1 + active = true + encryption_key = env("A3S_BOX_E2B_TOKEN_ENCRYPTION_KEY_V1") + digest_key = env("A3S_BOX_E2B_TOKEN_DIGEST_KEY_V1") + } + + template_policy "a3s-base" { + image = "docker.io/library/alpine:3.20" + envd_version = "0.1.3" + envd_mode = "broker" + isolation = "sandbox" + network = "none" + command = ["/bin/sh", "-c", "while :; do sleep 3600; done"] + + resources { + vcpus = 2 + memory_mb = 512 + disk_mb = 1024 + } + + route { + port = 49983 + token_scope = "envd" + } + } +} +``` + +`api_public_url` is the value clients use as `A3S_BOX_ENDPOINT`; +`api_listen` is an internal bind address and normally must not be given to +remote clients. The TLS reverse proxy in front of `api_listen` must forward +paths unchanged and preserve `X-API-Key`. The separate Sandbox gateway must be +reachable on the port advertised by `sandbox_public_domain`. + +For the in-Sandbox envd and Code Interpreter template, use the immutable +runtime image and template policy described in +[`deploy/e2b/README.md`](deploy/e2b/README.md). + +##### 3. Start and verify the service + +Release archives install `a3s-box-e2b`. To build the same binary from this +repository: + +```bash +cd src +cargo build --locked --release -p a3s-box-compat --bin a3s-box-e2b + +RUST_LOG=a3s_box_compat=info \ + ./target/release/a3s-box-e2b --config /etc/a3s-box/e2b.acl +``` + +The two token-key environment variables referenced by the ACL must be present +in the service process. Run the service under a supervisor and ensure its user +can read the TLS private key and write the database, runtime home, and runtime +state paths. + +Configure a client with the **raw** API key: + +```bash +export A3S_BOX_ENDPOINT="https://api.box.example.com" +export A3S_BOX_API_KEY="e2b_" + +# Only for a non-conventional control hostname: +# export A3S_BOX_DOMAIN="sandboxes.lab.example" + +unset A3S_BOX_SANDBOX_URL +``` + +First verify the control API independently of Sandbox routing: + +```bash +curl --fail --show-error --silent \ + --header "X-API-Key: ${A3S_BOX_API_KEY}" \ + "${A3S_BOX_ENDPOINT}/v2/sandboxes" +``` + +A successful request returns HTTP `200`. This proves the control endpoint, +TLS trust, reverse-proxy path, and account key. It does not prove wildcard +Sandbox DNS or the data-plane gateway; create a Sandbox and run a command for +that end-to-end check. + +##### 4. Use the native A3S SDKs + +The native packages are currently source-tree previews. Build or install them +as described in the +[`a3s-box` Python package](sdk/python/README.md) and +[`@a3s-lab/box` TypeScript package](sdk/typescript/README.md). + +Python synchronous client: + +```python +from a3s_box import A3SConnectionConfig, Sandbox + +connection = A3SConnectionConfig.from_environment() +sandbox = Sandbox.create( + "a3s-base", + **connection.python_options(), +) + +try: + result = sandbox.commands.run("printf 'hello from A3S Box\\n'") + print(result.stdout) +finally: + sandbox.kill() +``` + +Python asynchronous client: + +```python +import asyncio + +from a3s_box import A3SConnectionConfig, AsyncSandbox + + +async def main() -> None: + connection = A3SConnectionConfig.from_environment() + sandbox = await AsyncSandbox.create( + "a3s-base", + **connection.python_options(), + ) + async with sandbox: + result = await sandbox.commands.run( + "printf 'hello from A3S Box\\n'" + ) + print(result.stdout) + + +asyncio.run(main()) +``` + +TypeScript client: + +```typescript +import { A3SConnectionConfig, Sandbox } from '@a3s-lab/box' + +const connection = A3SConnectionConfig.fromEnvironment(process.env) +const sandbox = await Sandbox.create('a3s-base', { + ...connection.typescriptOptions(), + timeoutMs: 60_000, +}) + +try { + const result = await sandbox.commands.run( + "printf 'hello from A3S Box\\n'" + ) + console.log(result.stdout) +} finally { + await sandbox.kill() +} +``` + +`A3SConnectionConfig` passes the API key to the pinned E2B client, which sends +it as `X-API-Key`. It derives `A3S_BOX_DOMAIN` only when the endpoint hostname +starts with `api.`; otherwise set the domain explicitly. + +##### 5. Use unchanged official E2B SDKs + +The unchanged official SDKs use their own environment names. Point them at the +same A3S Box service without setting a process-global Sandbox URL: + +```bash +export E2B_API_URL="${A3S_BOX_ENDPOINT}" +export E2B_API_KEY="${A3S_BOX_API_KEY}" +export E2B_DOMAIN="box.example.com" +unset E2B_SANDBOX_URL +``` + +The generated `e2b_` key passes the official clients' default API-key +validation. Do not set `E2B_SANDBOX_URL` to the shared +`sandbox.` endpoint in a multi-Sandbox deployment: it is a fixed URL, +and file-transfer URLs would lose the Sandbox identity. The native A3S +packages intentionally ignore these `E2B_*` connection variables. + +##### Troubleshooting + +| Symptom | Check | +| --- | --- | +| `A3S_BOX_ENDPOINT is required` | Export it in the same process that starts the application; the native package does not read `E2B_API_URL`. | +| HTTP `401` | `A3S_BOX_API_KEY` must be the raw `e2b_...` value, not `account.hash`; also confirm the reverse proxy preserves `X-API-Key`. | +| Official SDK rejects the key before sending a request | Regenerate it in the required `e2b_[0-9a-f]+` form. Uppercase hex and `a3s_` prefixes are rejected by the current compatibility service. | +| HTTP `404` from every SDK operation | Remove `/api`, `/v1`, `/sandboxes`, and the trailing slash from `A3S_BOX_ENDPOINT`; ensure the reverse proxy does not add or strip a path prefix. | +| Control listing works but commands, files, PTY, or health fail | Check wildcard DNS, gateway firewall/port, wildcard certificate trust, and `sandbox_public_domain`. | +| TLS hostname mismatch on a Sandbox URL | The certificate must cover `*.sandbox_domain`; `A3S_BOX_DOMAIN` contains only the DNS suffix, without a scheme or port. | +| A custom control hostname routes Sandbox calls to the wrong domain | Set `A3S_BOX_DOMAIN` exactly to `e2b_compat.sandbox_domain`. | +| Sandbox creation reports an unknown template | Pass a template ID declared by `template_policy`, such as `a3s-base`. | +| Existing Sandbox tokens fail after a restart | Restore the token encryption/digest key version used to issue them; rotate with a new retained version instead of replacing key material in place. | + +Use HTTPS everywhere outside loopback, never commit raw account or token keys, +and keep credentials out of logs. To rotate an account key without an outage, +add a second `account` block with the same `owner_id`, a new label and +`client_id`, and the new hash; restart the service, update clients, then remove +the old account in a later restart. + +#### Current implementation status + +The Phase 2 preview includes an owner-scoped Rust lifecycle router for create, +connect, get, memory-preserving pause, connect/resume, v1/v2 running/paused +list, timeout, monotonic refresh, current single/batch metrics, +generation-fenced v1/v2 structured logs, and kill; owner-scoped Volume +create/connect/list/delete and authenticated content operations; and +owner-scoped filesystem Snapshot capture, source-filtered listing, restore, and +delete. Snapshot control records use the same durable SQLite transition model, +and the runtime quiesces the source for capture, preserves resolved OCI +defaults and Unix metadata, and restores through a private copy-on-write upper. +Older Snapshot records without the resolved image configuration remain +inspectable and deletable but fail closed on restore. A canonical runtime +`ExecutionManager` provides the production VM/Sandbox backend. CI runs the +pinned official Python sync/async, TypeScript, and Code Interpreter clients +against the router through an in-memory repository and fake execution manager. +An opt-in A3S OS gate installs those same checksum-pinned packages without +modification and runs them against the ACL-configured production process and +real `crun` Sandboxes. Python sync, Python async, and TypeScript each cover +create, memory-preserving pause, paused-state listing, connect-based resume, +survival of the same background process, filtered list, timeout replacement, +current metrics with historical-range filtering, kill, and not-found behavior. +The tested base packages are Python `e2b` 2.32.0 in sync and async modes and +TypeScript `e2b` 2.33.0. All three run one foreground `commands.run` through +the ConnectRPC JSON transport as the image's default non-root user and verify +its stdout, empty stderr, and successful exit on a real `crun` execution. Python +`e2b-code-interpreter` 2.8.1 and TypeScript +`@e2b/code-interpreter` 2.6.1 participate in the runtime-image execution gate. +That mode additionally exercises Filesystem create/read/stat/list/rename/remove, +background commands with process listing and stdin close, PTY allocation and +resize, and Code Interpreter execution plus context create/list/restart/remove. +Each Python sync/async, TypeScript, and Code +Interpreter object also calls its official `is_running`/`isRunning` method +through the production TLS gateway. Running checks follow the template's +broker/runtime placement; post-kill checks use host-resolved terminal health, +returning `true` while running and `false` after termination. + +Managed creation requests also persist a typed caller policy for names, +restart and health behavior, logging, stop behavior, and local resource +metadata. An idempotent retry therefore cannot silently reuse a reservation +with different policy, and the canonical record mapper does not replace caller +choices with runtime defaults. +Separately, an A3S OS smoke harness proves that `--isolation sandbox` create +persists a recoverable `created` reservation without allocating backend +resources, then starts through certified `crun`, preserves memory across +pause/connect-resume, proves that the same process survives, rejects +filesystem-only pause explicitly, and owns kill and cleanup without MicroVM +fallback. The same host validation proves that structured Sandbox logs retain +both stdout and stderr, drain final records before natural-exit or auto-remove +archival, and leave no generation log worker, crun state, box directory, or +socket behind. + +The first production E2B data-plane slices are now implemented, while the full +envd surface remains incomplete. CLI `create` persists its reservation and +complete caller policy through the canonical manager, and the first `start` of +that reservation consumes its persisted generation through the same manager. +The production backend prepares named-volume and network ownership +idempotently and rolls back only resources acquired by a failed start attempt. +Ordinary `start` does not revive a terminal managed execution. CLI `restart` +uses a durable two-phase operation that terminates the old runtime before +advancing the generation, recovers ambiguous kill/start responses from backend +evidence, and rebinds local resources without duplicating ownership. CLI `run` +reserves and starts through the same manager, freezes image-defined health and +stop defaults into the durable request, and leaves network, volume, rootfs, +stop, and auto-remove ownership to the managed backend. The Rust SDK exposes +typed create, start, run, inspect, pause, resume, restart, kill, and +reconciliation calls through that same manager. + +The `a3s-box-e2b` process accepts only `.acl` configuration parsed by `a3s-acl`. +It composes SQLite lifecycle state, the canonical runtime manager, production +credential providers, startup reconciliation, periodic expiry reaping, and +graceful shutdown. Account keys use salted PBKDF2-SHA256 hashes; scope-separated +sandbox tokens use AES-256-GCM, independent HMAC validation, and versioned key +rotation. Route policy is persisted with each lifecycle record, and strict +wildcard/shared parsing projects immutable leases fenced by generation, expiry, +port, and token scope without a second mutable routing state. + +Each template persists an explicit envd placement. Broker mode handles the +implemented envd routes on the host. Runtime mode forwards health, Process, +Filesystem, and file HTTP requests to port `49983` inside the exact +generation-fenced Sandbox. A runtime-mode sandbox remains unpublished until +that port accepts a fenced connection; readiness failure stops the execution +and leaves its lifecycle record hidden. + +Sandbox expiry is measured from the later of runtime start and observed envd +readiness, so cold startup does not consume the caller's requested usable +timeout. Startup reconciliation applies the same rule when recovering a +creating record whose execution was committed before the service restarted. + +Memory-preserving pause maps to certified `crun pause`; a later `connect` or +deprecated `resume` request maps to `crun resume`. The production matrix starts +a background process before pausing and proves that the same process continues +after resume. Filesystem-only pause (`memory: false`) is rejected explicitly +until cold-pause semantics are implemented. + +The v1 and v2 Sandbox log routes read the canonical generation-fenced +`json-file` runtime logs. They support cursor, direction, level, search, and +limit semantics, read bounded rotated gzip files oldest-first, ignore an +incomplete live tail, and stably order concurrent stdout/stderr entries by +timestamp. + +Owner-scoped Volume records use durable SQLite state and an independently +scoped encrypted content token. The authenticated content routes implement +directory, file, path, and metadata operations, while Sandbox creation resolves +public Volume names to runtime-managed mounts. Official and A3S Python +sync/async and TypeScript clients prove bidirectional mount I/O, public mount +metadata, UID/GID mapping, in-use deletion conflicts, and final cleanup against +real `crun` executions. + +The runtime-image smoke also validates the pinned `/metrics` schema, +create-time environment through `/envs`, metadata-preserving multipart upload, +byte-identical octet-stream download, invalid-token rejection, and cleanup +through the authenticated wildcard TLS route. + +The production wildcard TLS gateway supports HTTP/1.1 and HTTP/2 clients over +both direct and shared sandbox routes. It validates each lease, applies CORS, +strips edge credentials, and enters the real `crun` network namespace through a +generation- and PID-fenced connector. As with the official E2B sandbox proxy, +the plaintext Sandbox origin is contacted with HTTP/1.1, including when the +downstream client uses HTTP/2; the origin is not required to provide h2c. +Authenticated terminal `GET /health` remains host-resolved after kill so a +scope-valid envd token receives the `502` response expected by official SDK +running-state methods without reopening a route lease. An invalid token remains +unauthorized. Ordinary traffic continues through the fenced Sandbox +network-namespace proxy. + +The first Process broker slice is also implemented. It uses generation-scoped +synthetic process IDs and supports Start, JSON-framed Connect, List, SendInput, +CloseStdin, SIGKILL, PTY Start/resize, and ordered start, output, keepalive, and +end events. The pinned Python sync/async and TypeScript runtime-image clients +cover foreground and background commands, process listing, stdin/close, wait, +and one PTY resize flow. Client-streaming `StreamInput`, SIGTERM and other +signals, binary Connect framing, the complete PTY/reconnect/backpressure +matrix, and durable process recovery across service restart are not yet +compatibility claims. + +An A3S OS production smoke test exercises this path on a real `crun` OCI +Sandbox created with `--isolation sandbox`. It verifies lifecycle operations, +v1 running-list behavior, monotonic refresh with an optional body, current +batch metrics, generation-fenced v1/v2 structured logs with forward/backward +ordering, envd health over both TLS route forms, runtime metrics/environment and +HTTP file transfer, a real traffic-token-protected workload service on port +`49999`, invalid and scope-swapped token denial, service-restart recovery, +stale-route fencing after kill, authenticated terminal health, and complete +runtime cleanup. The default +`localhost.localdomain` wildcard is DNS- and +TLS-preflighted before a Sandbox starts. The same A3S OS gate runs the unchanged +official clients through both running and post-kill health checks and the +runtime data-plane cases described above. Those client paths additionally prove +v2 paused-state listing and memory-preserving pause/connect-resume with +same-process survival, owner-scoped Volume create/connect/list/content/delete, +bidirectional Sandbox mounts, UID/GID mapping, in-use deletion conflicts, and +filesystem Snapshot capture/list/restore/delete. Snapshot clients prove source +state preservation, restore after source termination, file content, +ownership/mode and OCI-default fidelity, writable private restores, in-use +deletion conflicts, and final cleanup. +Failed runs can preserve the Sandbox PID, `crun` state, OCI bundle, and service +logs for diagnosis. Filesystem-only pause, historical metrics, multi-file and +large-file behavior, deeper Snapshot and Volume failure/recovery, +concurrent-mutation cases, exhaustive Process and PTY matrices, Filesystem +watches and signed URLs, official public-port coverage, rich multi-language +Code Interpreter behavior, MCP, native package publication, and the complete +production package matrix remain open release gates. + +The server, native Python/TypeScript packages, and unchanged-official-client +black-box suites follow the phased design in +[`docs/e2b-compatible-sdk-design.md`](docs/e2b-compatible-sdk-design.md). Until +that complete matrix passes, generated manifests explicitly report +`full_compatibility=false`. + ## Warm pool and snapshot-fork A **warm pool** keeps a set of sandboxes pre-booted and serves them over a Unix @@ -269,13 +912,55 @@ json-file console logs). ```bash a3s-box pool start --image alpine:latest --size 8 # pre-warm 8 sandboxes +a3s-box pool start --image alpine:latest --lease-ttl 30m # reclaim abandoned leases a3s-box pool start --image alpine:latest --size 8 --snapshot-fork # CoW fill a3s-box pool start --image alpine:latest --metrics-addr 127.0.0.1:9101 # + Prometheus /metrics a3s-box pool run alpine:latest -- echo hi # served from the pool +a3s-box run --pool --rm alpine:latest -- echo hi # Docker-like run shape +a3s-box run --pool-autostart --rm alpine:latest -- echo hi +a3s-box run --pool --rm -v "$PWD:/work:ro" -w /work alpine:latest -- cat README.md +a3s-box build --run-pool --run-pool-socket /tmp/a3s-box-pool.sock -t app:dev . +a3s-box build --run-pool-autostart --run-pool-image alpine:latest -t app:dev . a3s-box pool status a3s-box pool stop ``` +`run --pool` is intentionally a foreground one-shot path today: it requires +`--rm` and supports the common hot-loop dimensions (`--user`, `--workdir`, +`--env`, `--env-file`, `--volume`, `--cpus`, `--memory`, and +`--package-cache`, plus foreground `--timeout`). Image, volumes, vCPUs, and +memory are part of the warm-pool key because virtio-fs mounts and VM resources +are fixed at boot. Set +`A3S_BOX_RUN_POOL_SOCKET=/path/to/pool.sock` to auto-route compatible +foreground `run --rm` commands through the same daemon; incompatible runs keep +the normal cold-start path unless `--pool` was requested explicitly. Use +`--pool-autostart` to start a daemon on `--pool-socket` when one is not already +running. Options that require persistent box state or a named lifecycle, such as +`--name`, stay on the normal run path instead of being silently ignored by the +one-shot pool path. + +`build --run-pool` uses the same daemon but with a lease protocol instead of a +one-shot sandbox: a build stage keeps one warm VM while it executes every +Dockerfile `RUN` in that stage with the current Dockerfile `WORKDIR`, `ENV`, and +`USER`, then releases it. The stage rootfs remains the single source of truth; +the VM only provides isolated Linux execution. Because each stage rootfs mount is +unique and short-lived, volume-bound build leases are filled on demand instead +of pre-warming a whole idle pool for every stage. Use +`--run-pool-autostart --run-pool-image ` when the build command +should start the helper daemon itself. + +`pool status` reports idle sandboxes, active checked-out sandboxes, and active +leases per pool key, which is useful when Dockerfile `RUN` stages are holding a +warm VM. `a3s-box info` also performs a best-effort daemon probe against +`A3S_BOX_RUN_POOL_SOCKET`, `A3S_BOX_BUILD_RUN_POOL_SOCKET`, and the default +socket, then prints the aggregate max/idle/active/leased counts when one is +reachable. `pool start --lease-ttl ` reclaims unreleased internal +leases that have been idle for too long (default: `1h`, `0` disables this); +running lease exec requests are never reclaimed mid-command. `pool stop` sends a +stop request over the daemon socket, drains idle and leased VMs, removes the +socket, and exits. It succeeds when no daemon is running so cleanup scripts can +call it unconditionally. + `pool start --metrics-addr` serves a Prometheus `/metrics` endpoint with warm-pool hit/miss, VM-boot, and cache metrics for the long-running daemon (alongside `monitor --metrics-addr`'s box-state metrics + `/healthz`). **Snapshot-fork** (`--snapshot-fork`, Linux `/dev/kvm` only) is native @@ -310,8 +995,8 @@ A3S Box has three network modes: | Mode | What it does | Current boundary | | --- | --- | --- | -| TSI default | Guest socket operations are proxied through the host. Use this for simple outbound access. | No user-defined peer network, and **no in-guest loopback** — a container cannot reach its own services over `localhost`/`127.0.0.1` (e.g. a `localhost` health check or `exec curl localhost` fails). Use a bridge network when you need working localhost. | -| Bridge | Creates a real guest network interface for user-defined networks and peer discovery. | Linux uses `passt` with outbound NAT. macOS uses built-in `netproxy` for peer networking and published TCP ports; macOS bridge outbound NAT is unsupported. | +| TSI default | Guest socket operations are proxied through the host. Use this for simple outbound access. On macOS, publishing a TCP port automatically selects an isolated netproxy-backed interface so application bytes and guest loopback remain reliable while the CLI/network-mode contract stays unchanged. | Plain TSI boxes have no user-defined peer network and no in-guest loopback. Use a bridge network for peer discovery; publishing a port on macOS activates the isolated compatibility data path automatically. | +| Bridge | Creates a real guest network interface for user-defined networks and peer discovery. | Linux uses `passt` with outbound NAT. macOS uses built-in `netproxy` for peer networking, published TCP ports, DNS forwarding, and outbound TCP connections through the host stack. Non-DNS outbound UDP and ICMP are not proxied on macOS. | | None | No network. | Useful for intentionally isolated workloads. | ```bash @@ -327,17 +1012,71 @@ a3s-box port api Published ports support TCP only in `host_port:guest_port[/tcp]` form. UDP, host-IP binds such as `127.0.0.1:8080:80`, single-port shorthand, and ranges are rejected during CLI or Compose validation. `network connect` and `network disconnect` apply to inactive boxes; live hot-plug is not implemented. Strict/custom network policy modes are rejected until packet filtering is implemented. -## Compose subset +## Compose applications + +`compose.acl` is the canonical project file and is discovered automatically: + +```acl +service "api" { + image = "ghcr.io/a3s-lab/api:latest" + command = ["serve"] + environment = { PORT = "8080" } + ports = ["8080:8080"] + depends_on = ["db"] +} + +service "db" { + image = "postgres:17" + volumes = ["data:/var/lib/postgresql/data"] +} + +volume "data" { + driver = "local" +} +``` ```bash -a3s-box compose -f compose.yaml config -a3s-box compose -f compose.yaml up -d -a3s-box compose -f compose.yaml ps -a3s-box compose -f compose.yaml logs -f -a3s-box compose -f compose.yaml down +a3s-box compose config +a3s-box compose up -d +a3s-box compose ps +a3s-box compose logs -f +a3s-box compose exec api -- sh +a3s-box compose restart api +a3s-box compose stop +a3s-box compose start +a3s-box compose down ``` -Supported Compose keys: `image`, `command`, `entrypoint`, `environment`, `env_file`, `ports`, `volumes`, `depends_on` with `service_started` or `service_healthy`, `networks`, `dns`, `tmpfs`, `working_dir`, `hostname`, `extra_hosts`, `labels`, `healthcheck`, `restart`, `cpus`, `mem_limit`, `cap_add`, `cap_drop`, and `privileged`. +The project command surface includes `up`, `down`, `ps`, `logs`, `config`, +`start`, `stop`, `restart`, `rm`, `kill`, `pause`, `unpause`, `wait`, `exec`, +`top`, `port`, `cp`, `images`, `pull`, `ls`, and `volumes`. Service-scoped +operations resolve the immutable project and service labels, then reuse the +same lifecycle commands as individual boxes instead of maintaining a second +state machine. + +`compose up` is convergent. It records a deterministic digest of the effective +service and runtime configuration, reuses an unchanged running service, and +recreates a changed or inactive service. Supplying service names limits the +operation to those services and their transitive dependencies. Without `-d`, +`up` attaches to prefixed project logs and stops the selected services on +Ctrl-C; detached mode returns after convergence. + +Supported Compose keys: `image`, `command`, `entrypoint`, `environment`, `env_file`, `ports`, `volumes`, `depends_on` with `service_started`, `service_healthy`, or `service_completed_successfully`, `networks`, `dns`, `tmpfs`, `working_dir`, `hostname`, `extra_hosts`, `labels`, `healthcheck`, `restart`, `cpus`, `mem_limit`, `cap_add`, `cap_drop`, and `privileged`. + +A3S ACL uses a closed schema: unknown root blocks, nested blocks, attributes, +types, and functions are rejected instead of being silently ignored. Explicit +`compose.yaml`, `compose.yml`, `docker-compose.yaml`, and `docker-compose.yml` +files remain supported as a local Docker Compose-compatible subset, not as a +claim of full Compose Specification parity. + +Compose scalar values support `$VAR`, `${VAR}`, `${VAR-default}`, +`${VAR:-default}`, `${VAR+replacement}`, and `${VAR:+replacement}` (plus the +standard required-value forms). Values come from the project `.env` file next +to the selected Compose file, with the invoking shell environment taking +precedence. Expansion happens before typed service and port validation; +mapping keys are not expanded, and `$$` emits a literal dollar sign. +ACL values may also use `env("NAME")`; it resolves from that same merged +environment and fails when the variable is absent. ## TEE workflows @@ -471,7 +1210,7 @@ curl -fsSL https://raw.githubusercontent.com/A3S-Lab/Box/main/deploy/scripts/ins # or from a checkout: sudo deploy/scripts/install-runtimeclass.sh # default version -sudo deploy/scripts/install-runtimeclass.sh --version v2.6.0 # pin a version +sudo deploy/scripts/install-runtimeclass.sh --version v3.0.2 # pin a version ``` Then label the node from a machine with `kubectl`: @@ -597,11 +1336,12 @@ Crates: | Crate | Purpose | | --- | --- | | `core` | Shared config, errors, events, port/network/volume/PTY/DNS/workload types | +| `compat` | Pinned external protocol inventories and compatibility service | | `runtime` | VM lifecycle, image store, rootfs preparation, Compose, networking, TEE clients | | `cli` | `a3s-box` command line | | `shim` | libkrun bridge subprocess | | `guest/init` | guest PID 1 and guest services | -| `netproxy` | macOS user-space bridge proxy and published TCP forwarding | +| `netproxy` | macOS user-space bridge, DNS, inbound TCP, and outbound TCP proxy | | `cri` | experimental CRI server | | `sdk` | Rust execution registry abstractions for Box workloads | @@ -612,10 +1352,12 @@ Run checks from `crates/box/src`, not the monorepo root. ```bash cd crates/box/src cargo fmt --all +cargo run -p a3s-box-compat --bin a3s-box-e2b-contract -- verify cargo test -p a3s-box-runtime --lib --quiet cargo test -p a3s-box-cli --test command_coverage --quiet cargo test -p a3s-box-cli --test host_smoke --quiet cargo test -p a3s-box-cli --test core_smoke --quiet +cargo test -p a3s-box-cli --test host_smoke test_real_compose_acl_smoke -- --ignored --exact --nocapture ``` Or run the macOS/Linux validation ladder from `crates/box`: @@ -645,16 +1387,22 @@ sudo -E scripts/host-integration-smoke.sh --linux-run --no-pure The Linux `RUN` smoke must run as root on a root-capable Linux builder. See `docs/host-integration.md` for the macOS HVF, Linux KVM, host command -matrix, CRI smoke, and host soak procedures. +matrix, warm-pool Dockerfile `RUN` smoke, CRI smoke, and host soak procedures. ## Environment variables | Variable | Description | | --- | --- | | `A3S_HOME` | Data directory. Default: `~/.a3s`. | +| `A3S_BOX_ENDPOINT` | Native Python/TypeScript SDK control-plane origin for the E2B-compatible service, for example `https://api.box.example.com`. Do not append an API path or trailing slash. | +| `A3S_BOX_API_KEY` | Raw self-hosted compatibility API key sent as `X-API-Key`. It must match `e2b_[0-9a-f]+`; do not use the PBKDF2 hash stored in the server ACL. | +| `A3S_BOX_DOMAIN` | Optional Sandbox wildcard DNS suffix when it cannot be derived from a conventional `https://api.` endpoint. Do not include a scheme or port. | +| `A3S_BOX_SANDBOX_URL` | Fixed single-Sandbox fixture override. Leave unset for normal self-hosted multi-Sandbox deployments. | | `A3S_IMAGE_CACHE_SIZE` | Image cache size. Default: `10g`. | | `A3S_TEE_SIMULATE` | Enables simulated TEE report behavior. | -| `A3S_REGISTRY_PROTOCOL` | Registry protocol override for local/insecure registry tests. | +| `A3S_REGISTRY_PROTOCOL` | Legacy registry protocol override for local/insecure registry tests. Prefer `a3s-box push --plain-http` for push. | +| `A3S_EXEC_READY_TIMEOUT_MS` | Safety cap for guest exec-server readiness probing during boot. Default: 15000. | +| `A3S_VIRTIOFS_CACHE` | Process-wide fallback virtio-fs cache mode for host directory volumes: `none` by default, or `auto`, `always`, `default`. Prefer per-run `--virtiofs-cache` when scripting release verification. | | `A3S_BOX_CRI_AGENT_IMAGE` | Default CRI sandbox agent/rootfs image. | | `A3S_BOX_SMOKE_IMAGE_TAR` | OCI archive used by the ignored core MicroVM smoke suite. | | `A3S_BOX_TEST_ALPINE_TAR` | Shared offline Alpine OCI archive for core and host smoke suites. | @@ -676,6 +1424,12 @@ matrix, CRI smoke, and host soak procedures. | `A3S_BOX_CLUSTER_SOAK_VERIFY_MIN_SAMPLE_SPAN_SECS` | Optional RuntimeClass soak evidence gate for first-to-last sample span. | | `A3S_BOX_CLUSTER_SOAK_VERIFY_MAX_SAMPLE_GAP_SECS` | Optional RuntimeClass soak evidence gate for maximum consecutive sample gap. | | `A3S_BOX_CLUSTER_SOAK_CLEANUP_TIMEOUT_SECS` | RuntimeClass cleanup wait before collecting `post-cleanup-counts.tsv`. Default: 300. | +| `A3S_BOX_BUILDKIT_IMAGE` | BuildKit image used by `--builder=buildkit-vm`. Default: `moby/buildkit:latest`. | +| `A3S_BOX_BUILDKIT_CPUS` | CPU count for the BuildKit VM helper box. Default: `4`. | +| `A3S_BOX_BUILDKIT_MEMORY` | Memory limit for the BuildKit VM helper box. Default: `8g`. | +| `A3S_BOX_RUN_POOL_SOCKET` | Auto-route compatible foreground `a3s-box run --rm` commands through the warm-pool daemon at this socket. Explicit `--pool-socket` still applies when `--pool` is passed. | +| `A3S_BOX_BUILD_RUN_POOL_SOCKET` | Enable Dockerfile `RUN` warm-pool execution and use this pool daemon socket, equivalent to `a3s-box build --run-pool-socket `. | +| `A3S_BOX_BUILD_RUN_CACHE_DIR` | Override the persistent Dockerfile `RUN --mount=type=cache` directory used by the warm-pool build path. Defaults to `~/.a3s/buildcache/run-cache`. | | `A3S_BOX_UNSAFE_HOST_RUN` | Opt into unsafe macOS host execution for Dockerfile `RUN` experiments. | | `A3S_BOX_BUILDCACHE_MAX_BYTES` | Cap on the total size of cached build layers at `~/.a3s/buildcache` (oldest evicted first). Default: 2 GiB. | | `A3S_BOX_MAX_LAYER_BYTES` | Cap on total decompressed bytes per OCI image layer during `pull` (decompression-bomb guard). Default: 16 GiB. | diff --git a/bench/README.md b/bench/README.md index 7173bc7a..98bf2cb4 100644 --- a/bench/README.md +++ b/bench/README.md @@ -9,17 +9,22 @@ their own hardware instead of trusting a number in a doc. ## Requirements -A Linux host with **`/dev/kvm`** (real microVMs only boot there) and `a3s-box` -on `PATH` (or set `A3S_BOX`). The boot benchmarks are meaningless without KVM. +Most modes require a Linux host with **`/dev/kvm`** and `a3s-box` on `PATH` +(or set `A3S_BOX`). The `foreground` comparison also supports macOS/HVF, which +is the environment used by the original foreground-latency regression report. ## Usage ```bash -bench/bench.sh # all four benchmarks +bench/bench.sh # default Linux/KVM suite, including foreground latency bench/bench.sh cold # cold-boot latency only +bench/bench.sh foreground # cached foreground no-op, optionally versus Docker +bench/bench.sh sandbox # phased hot Sandbox lifecycle, optionally versus Docker bench/bench.sh warm # warm-pool acquire latency bench/bench.sh fork # snapshot-fork pool fill (cold-fill vs CoW restore) bench/bench.sh leak # churn + leak assertion (exit != 0 on leak) +PNPM_PROJECT=/path/to/app bench/bench.sh pnpm +just bench-pnpm # reduced pnpm fixture ``` Tunables (env): @@ -29,13 +34,48 @@ Tunables (env): | `A3S_BOX` | `a3s-box` | binary under test | | `IMAGE` | `alpine:latest` | OCI image to benchmark | | `RUNS` | `20` | samples per latency benchmark | +| `FOREGROUND_RUNS` | `RUNS` | recorded cached foreground no-op samples per runtime | +| `FOREGROUND_WARMUPS` | `1` | warm-up runs per runtime before foreground sampling | +| `FOREGROUND_DOCKER` | `1` | compare Docker when its CLI and daemon are available | +| `FOREGROUND_MAX_P50_MS` | `0` | optional absolute a3s-box p50 gate; `0` reports without gating | +| `FOREGROUND_MAX_DOCKER_RATIO` | `0` | optional p50 ratio gate; `0` reports without gating | +| `SANDBOX_RUNS` | `RUNS` | recorded hot Sandbox lifecycle samples | +| `SANDBOX_WARMUPS` | `1` | unmeasured warm-up runs before Sandbox sampling | +| `SANDBOX_DOCKER` | `1` | alternate matching Docker samples when its daemon is available | +| `SANDBOX_RESULTS` | `/tmp/a3s-box-sandbox-lifecycle-.csv` | machine-readable per-sample output | +| `SANDBOX_LOG_DIR` | `/tmp/a3s-box-sandbox-lifecycle-` | raw opt-in JSONL profile logs | | `POOL_SIZE` | `16` | warm-pool / fork fill size | | `CHURN` | `30` | create/run/remove cycles for the leak test | +| `PNPM_PROJECT` | unset | project directory with `package.json` and `pnpm-lock.yaml` for `pnpm` mode | +| `PNPM_IMAGE` | `node:22-alpine` | Node image used by `pnpm` mode | +| `PNPM_VERSION` | `10.30.3` | version passed to `corepack prepare` | +| `PNPM_RUNS` | `3` | samples for the `pnpm` benchmark | +| `PNPM_CACHE` | `1` | use `--package-cache pnpm`; set `0` for a cold store path | +| `PNPM_CPUS` | `4` | CPUs assigned to pnpm boxes and Docker baselines | +| `PNPM_MEMORY` | `4g` | memory assigned to pnpm boxes and Docker baselines | +| `PNPM_NODE_MODULES` | `both` | benchmark `project`, `tmpfs`, or `both` `node_modules` targets | +| `PNPM_TMPFS_SIZE` | `4g` | tmpfs size for `/work/node_modules` when tmpfs mode is enabled | +| `PNPM_DOCKER` | `1` | compare Docker cold/hot baselines when Docker is available | +| `PNPM_RESET_A3S_CACHE` | `0` | set `1` to remove `a3s-cache-pnpm` before cold A3S samples | ## What it measures - **cold** — `run --rm IMAGE -- true` wall-clock, reported as p50 / p90 / min over `RUNS` samples. +- **foreground** — the latency-sensitive `run --rm --no-stdin --timeout 180 + IMAGE -- true` path with an explicit warm-up, exact samples, mean, p50, p95, + and minimum. When Docker is available, the harness runs the matching cached + Docker no-op and reports the p50 ratio. Set `FOREGROUND_MAX_DOCKER_RATIO` only + on a stable dedicated runner when the comparison should be a hard gate. +- **sandbox** — explicitly selects `--isolation sandbox`, completes image pull + and unpack before warm-up, then records the hot one-shot lifecycle as four + non-cold measurements: create/start, command execution, reconciliation, and + removal. The CSV also retains the create/start internals (capability probe, + layout, instance preparation, managed mount preparation, rootfs ownership, + OCI bundle, `crun` launch, and readiness) for regression diagnosis. When + Docker is available, samples alternate runtime order and the matching hot + `docker run --rm IMAGE true` totals are written to the same CSV. Profiling is + opt-in and emits no workload arguments, paths, or credentials. - **warm** — `pool start` then `pool run` acquire latency (p50 / p90 / min). - **fork** — `pool start --size N` fill time **without** vs **with** `--snapshot-fork`, as total + amortized-per-VM, so the CoW speedup is a @@ -44,13 +84,41 @@ Tunables (env): `a3s-box-shim` processes, overlay mounts under `~/.a3s/boxes`, box dirs), runs `CHURN` `run --rm` cycles, then asserts they return to baseline. **Exits non-zero on any leak**, so it is CI-gateable. +- **pnpm** — runs `node:22-alpine` against a real project mount or the reduced + fixture at [`fixtures/pnpm`](./fixtures/pnpm). It reports p50/p90 for VM boot, + `corepack + pnpm` setup, `pnpm fetch` (registry download plus extraction/import + into the pnpm store), + offline install to project-mounted `node_modules`, offline install to tmpfs + `node_modules`, and full `pnpm install --frozen-lockfile`. When Docker is + available it also reports Docker cold/hot baselines and A3S/Docker ratios. + +The pnpm benchmark intentionally separates the two likely slow paths: + +- store population: `pnpm fetch --frozen-lockfile` (download plus store extraction); +- filesystem materialization: `pnpm install --offline --ignore-scripts`. + +If project-mounted `node_modules` is much slower than tmpfs, the bottleneck is +small-file and metadata traffic through the project mount. Use tmpfs for +throwaway install/build jobs: + +```bash +a3s-box run --rm --cpus 4 --memory 4g --package-cache pnpm \ + -v "$PWD:/work" -w /work --tmpfs /work/node_modules:size=4g \ + node:22-alpine -- sh -lc 'corepack enable && pnpm install --frozen-lockfile' +``` + +For a true cold A3S package-cache sample, run with `PNPM_RESET_A3S_CACHE=1`; +this removes the shared `a3s-cache-pnpm` volume before cold samples, so do not +use it while another box is relying on that cache. The Docker cold baseline uses +only the benchmark-owned `a3s-bench-pnpm-store` volume. ## Wiring into CI -The leak assertion's non-zero exit makes it a natural gate on the self-hosted -KVM runner (see [`../docs/ci-kvm-runner.md`](../docs/ci-kvm-runner.md)): add a -`bench/bench.sh leak` step to the `integration-kvm` job to catch a resource leak -regression automatically, instead of relying on a manual churn run. +The self-hosted KVM job runs the foreground benchmark with an absolute p50 gate +and the leak assertion with its resource-count gate (see +[`../docs/ci-kvm-runner.md`](../docs/ci-kvm-runner.md)). Keep absolute latency +limits on a stable dedicated runner; use the Docker ratio gate for manual +macOS/HVF comparisons on the host class from issue #33. ## Updating the published numbers diff --git a/bench/bench.sh b/bench/bench.sh index 07d3217e..34c2e735 100755 --- a/bench/bench.sh +++ b/bench/bench.sh @@ -4,11 +4,11 @@ # Makes the perf claims (cold boot, snapshot-fork, warm-pool acquire) and the # leak-free claim INDEPENDENTLY REPRODUCIBLE: it drives the real `a3s-box` CLI # end-to-end and reports wall-clock latencies + a hard leak assertion, instead -# of quoting numbers from prose. Run it on a Linux host with /dev/kvm (the only -# place real microVMs boot). +# of quoting numbers from prose. Most modes target Linux with /dev/kvm; the +# foreground comparison also runs on macOS with HVF. # # Usage: -# bench/bench.sh [all|cold|warm|fork|leak|race] (default: all) +# bench/bench.sh [all|cold|foreground|sandbox|warm|fork|leak|race|pnpm] (default: all) # Env: # A3S_BOX path to the a3s-box binary (default: a3s-box on PATH) # IMAGE OCI image to benchmark (default: alpine:latest) @@ -16,6 +16,25 @@ # POOL_SIZE warm-pool / fork fill size (default: 16) # CHURN create/run/remove cycles for the leak test (default: 30) # RACE concurrent `run -d` processes for the cross-process race (default: 8) +# FOREGROUND_RUNS samples for the cached foreground no-op benchmark (default: RUNS) +# FOREGROUND_WARMUPS warm-up runs per runtime before sampling (default: 1) +# FOREGROUND_DOCKER 1 compares Docker when available, 0 skips it (default: 1) +# FOREGROUND_MAX_P50_MS optional a3s-box p50 gate; 0 only reports (default: 0) +# FOREGROUND_MAX_DOCKER_RATIO optional p50 ratio gate; 0 only reports (default: 0) +# SANDBOX_RUNS hot Sandbox lifecycle samples (default: RUNS) +# SANDBOX_WARMUPS unmeasured Sandbox warm-ups (default: 1) +# SANDBOX_DOCKER 1 alternates matching Docker samples, 0 skips (default: 1) +# SANDBOX_RESULTS machine-readable CSV output path (default: /tmp/...csv) +# SANDBOX_LOG_DIR per-sample profile logs (default: /tmp/...) +# PNPM_PROJECT project dir with package.json + pnpm-lock.yaml (required for pnpm mode) +# PNPM_IMAGE Node image for pnpm mode (default: node:22-alpine) +# PNPM_VERSION pnpm version for corepack prepare (default: 10.30.3) +# PNPM_RUNS pnpm install samples (default: 3) +# PNPM_CACHE 1 uses --package-cache pnpm, 0 disables it (default: 1) +# PNPM_CPUS CPUs for pnpm boxes/containers (default: 4) +# PNPM_MEMORY memory for pnpm boxes/containers (default: 4g) +# PNPM_DOCKER 1 compares Docker cold/hot baselines, 0 skips (default: 1) +# PNPM_RESET_A3S_CACHE 1 removes a3s-cache-pnpm before cold A3S samples (default: 0) # # Exit code is non-zero if the leak assertion fails, so it is CI-gateable # (wire it into the self-hosted KVM job — see docs/ci-kvm-runner.md). @@ -27,22 +46,61 @@ RUNS="${RUNS:-20}" POOL_SIZE="${POOL_SIZE:-16}" CHURN="${CHURN:-30}" RACE="${RACE:-8}" +FOREGROUND_RUNS="${FOREGROUND_RUNS:-$RUNS}" +FOREGROUND_WARMUPS="${FOREGROUND_WARMUPS:-1}" +FOREGROUND_DOCKER="${FOREGROUND_DOCKER:-1}" +FOREGROUND_MAX_P50_MS="${FOREGROUND_MAX_P50_MS:-0}" +FOREGROUND_MAX_DOCKER_RATIO="${FOREGROUND_MAX_DOCKER_RATIO:-0}" +SANDBOX_RUNS="${SANDBOX_RUNS:-$RUNS}" +SANDBOX_WARMUPS="${SANDBOX_WARMUPS:-1}" +SANDBOX_DOCKER="${SANDBOX_DOCKER:-1}" +SANDBOX_RESULTS="${SANDBOX_RESULTS:-/tmp/a3s-box-sandbox-lifecycle-$$.csv}" +SANDBOX_LOG_DIR="${SANDBOX_LOG_DIR:-/tmp/a3s-box-sandbox-lifecycle-$$}" +PNPM_PROJECT="${PNPM_PROJECT:-}" +PNPM_IMAGE="${PNPM_IMAGE:-node:22-alpine}" +PNPM_VERSION="${PNPM_VERSION:-10.30.3}" +PNPM_RUNS="${PNPM_RUNS:-3}" +PNPM_CACHE="${PNPM_CACHE:-1}" +PNPM_CPUS="${PNPM_CPUS:-4}" +PNPM_MEMORY="${PNPM_MEMORY:-4g}" +PNPM_DOCKER="${PNPM_DOCKER:-1}" +PNPM_TMPFS_SIZE="${PNPM_TMPFS_SIZE:-4g}" +PNPM_NODE_MODULES="${PNPM_NODE_MODULES:-both}" +PNPM_LOG_DIR="${PNPM_LOG_DIR:-/tmp/a3s-bench-pnpm}" +PNPM_DOCKER_STORE_VOLUME="${PNPM_DOCKER_STORE_VOLUME:-a3s-bench-pnpm-store}" +PNPM_A3S_CACHE_VOLUME="${PNPM_A3S_CACHE_VOLUME:-a3s-cache-pnpm}" +PNPM_RESET_A3S_CACHE="${PNPM_RESET_A3S_CACHE:-0}" MODE="${1:-all}" -now_ms() { date +%s%3N 2>/dev/null || python3 -c 'import time;print(int(time.time()*1000))'; } +now_ms() { + local ts + ts=$(date +%s%3N 2>/dev/null || true) + case "$ts" in + ''|*[!0-9]*) python3 -c 'import time;print(int(time.time()*1000))' ;; + *) echo "$ts" ;; + esac +} # Percentile of a space-separated list of integers. $1=list $2=pct(0-100) pct() { - local nums; nums=$(printf '%s\n' $1 | sort -n) - local count; count=$(printf '%s\n' $nums | wc -l | tr -d ' ') + local nums; nums=$(printf '%s\n' "$1" | tr ' ' '\n' | awk 'NF' | sort -n) + local count; count=$(printf '%s\n' "$nums" | awk 'NF { count++ } END { print count + 0 }') [ "$count" -eq 0 ] && { echo 0; return; } local idx=$(( (count * $2 + 99) / 100 )) [ "$idx" -lt 1 ] && idx=1 - printf '%s\n' $nums | sed -n "${idx}p" + printf '%s\n' "$nums" | sed -n "${idx}p" +} + +ratio() { + awk -v a="$1" -v b="$2" 'BEGIN { if (b <= 0) print "n/a"; else printf "%.2fx", a / b }' } -require_kvm() { - if [ ! -e /dev/kvm ]; then +mean_ms() { + printf '%s\n' "$1" | tr ' ' '\n' | awk 'NF { total += $1; count++ } END { if (count == 0) print 0; else printf "%.1f", total / count }' +} + +require_runtime() { + if [ "$(uname -s)" = "Linux" ] && [ ! -e /dev/kvm ]; then echo "WARNING: /dev/kvm not present — boot benchmarks measure a degraded/failed path." >&2 fi command -v "$A3S_BOX" >/dev/null 2>&1 || { echo "ERROR: a3s-box not found ($A3S_BOX)"; exit 2; } @@ -55,8 +113,7 @@ shim_count() { echo "${count:-0}" } mount_count() { mount 2>/dev/null | awk '/\/\.a3s\/boxes|\/a3s\/boxes/ { n++ } END { print n + 0 }'; } -boxdir_count() { ls -1 "${HOME}/.a3s/boxes" 2>/dev/null | wc -l | tr -d ' '; } -fd_count() { ls -1 "/proc/$$/fd" 2>/dev/null | wc -l | tr -d ' '; } +boxdir_count() { find "${HOME}/.a3s/boxes" -mindepth 1 -maxdepth 1 -print 2>/dev/null | awk 'END { print NR + 0 }'; } bench_cold() { echo "## Cold boot ($RUNS runs, $IMAGE)" @@ -70,6 +127,310 @@ bench_cold() { echo " p50=$(pct "$samples" 50)ms p90=$(pct "$samples" 90)ms min=$(pct "$samples" 1)ms" } +# Reproduce the latency-sensitive foreground path from issue #33 with an +# explicit warm-up and exact samples. Docker comparison is optional so this +# also runs on a KVM/HVF host where Docker is intentionally unavailable. +bench_foreground() { + echo "## Cached foreground no-op ($FOREGROUND_RUNS runs, $FOREGROUND_WARMUPS warm-up, $IMAGE)" + local a3s_log="/tmp/a3s-bench-foreground-a3s-$$.log" + local docker_log="/tmp/a3s-bench-foreground-docker-$$.log" + local i s e status samples="" + + "$A3S_BOX" pull "$IMAGE" >/dev/null 2>&1 || true + for i in $(seq 1 "$FOREGROUND_WARMUPS"); do + if ! "$A3S_BOX" run --rm --no-stdin --timeout 180 "$IMAGE" -- true >"$a3s_log" 2>&1; then + echo " FAIL: a3s-box warm-up $i failed" >&2 + tail -80 "$a3s_log" >&2 + rm -f "$a3s_log" "$docker_log" + return 1 + fi + done + for i in $(seq 1 "$FOREGROUND_RUNS"); do + s=$(now_ms) + "$A3S_BOX" run --rm --no-stdin --timeout 180 "$IMAGE" -- true >"$a3s_log" 2>&1 + status=$? + e=$(now_ms) + if [ "$status" -ne 0 ]; then + echo " FAIL: a3s-box sample $i failed" >&2 + tail -80 "$a3s_log" >&2 + rm -f "$a3s_log" "$docker_log" + return "$status" + fi + samples="$samples $(( e - s ))" + done + + local a3s_p50 a3s_p95 + a3s_p50=$(pct "$samples" 50) + a3s_p95=$(pct "$samples" 95) + echo " a3s-box samples (ms):$samples" + echo " a3s-box: mean=$(mean_ms "$samples")ms p50=${a3s_p50}ms p95=${a3s_p95}ms min=$(pct "$samples" 1)ms" + + if [ "$FOREGROUND_MAX_P50_MS" != "0" ] && [ "$a3s_p50" -gt "$FOREGROUND_MAX_P50_MS" ]; then + echo " FAIL: a3s-box p50 exceeds FOREGROUND_MAX_P50_MS=$FOREGROUND_MAX_P50_MS" >&2 + rm -f "$a3s_log" "$docker_log" + return 1 + fi + + if [ "$FOREGROUND_DOCKER" = "1" ] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker pull "$IMAGE" >/dev/null 2>&1 || true + for i in $(seq 1 "$FOREGROUND_WARMUPS"); do + if ! docker run --rm "$IMAGE" true >"$docker_log" 2>&1; then + echo " FAIL: Docker warm-up $i failed" >&2 + tail -80 "$docker_log" >&2 + rm -f "$a3s_log" "$docker_log" + return 1 + fi + done + + local docker_samples="" + for i in $(seq 1 "$FOREGROUND_RUNS"); do + s=$(now_ms) + docker run --rm "$IMAGE" true >"$docker_log" 2>&1 + status=$? + e=$(now_ms) + if [ "$status" -ne 0 ]; then + echo " FAIL: Docker sample $i failed" >&2 + tail -80 "$docker_log" >&2 + rm -f "$a3s_log" "$docker_log" + return "$status" + fi + docker_samples="$docker_samples $(( e - s ))" + done + + local docker_p50 docker_p95 + docker_p50=$(pct "$docker_samples" 50) + docker_p95=$(pct "$docker_samples" 95) + echo " Docker samples (ms):$docker_samples" + echo " Docker: mean=$(mean_ms "$docker_samples")ms p50=${docker_p50}ms p95=${docker_p95}ms min=$(pct "$docker_samples" 1)ms" + echo " a3s-box/Docker p50 ratio: $(ratio "$a3s_p50" "$docker_p50")" + + if [ "$FOREGROUND_MAX_DOCKER_RATIO" != "0" ] && awk \ + -v a="$a3s_p50" \ + -v d="$docker_p50" \ + -v limit="$FOREGROUND_MAX_DOCKER_RATIO" \ + 'BEGIN { exit !(d > 0 && a / d > limit) }'; then + echo " FAIL: p50 ratio exceeds FOREGROUND_MAX_DOCKER_RATIO=$FOREGROUND_MAX_DOCKER_RATIO" >&2 + rm -f "$a3s_log" "$docker_log" + return 1 + fi + elif [ "$FOREGROUND_DOCKER" = "1" ]; then + echo " Docker comparison: skipped (docker CLI or daemon unavailable)" + fi + + rm -f "$a3s_log" "$docker_log" +} + +# Parse the opt-in JSONL events produced by one profiled `a3s-box run`. +# Output order matches the machine-readable CSV columns in +# bench_sandbox_lifecycle. Reconciliation is the sum of the raw/structured log +# drains, log archival, and managed runtime reconciliation. Removal is the +# final durable-state and residual-path deletion phase. +sandbox_profile_fields() { + python3 - "$1" <<'PY' +import json +import sys + +prefix = "A3S_BOX_LIFECYCLE " +durations = {} +with open(sys.argv[1], encoding="utf-8", errors="replace") as stream: + for line in stream: + marker = line.find(prefix) + if marker < 0: + continue + try: + event = json.loads(line[marker + len(prefix):]) + except json.JSONDecodeError: + continue + if event.get("schema") != "a3s.box.lifecycle-profile.v1": + continue + phase = event.get("phase") + duration = event.get("duration_ns") + if isinstance(phase, str) and isinstance(duration, int): + durations[phase] = durations.get(phase, 0) + duration + +required = [ + "cli.create_start", + "foreground.command_execution", + "foreground.raw_log_drain", + "foreground.structured_log_drain", + "foreground.archive", + "foreground.manager_reconcile", + "foreground.removal", + "sandbox.capability", + "sandbox.layout", + "sandbox.instance_prepare", + "sandbox.mount_sources", + "sandbox.rootfs_ownership", + "sandbox.bundle", + "sandbox.launch", + "sandbox.readiness", +] +missing = [phase for phase in required if phase not in durations] +if missing: + raise SystemExit("missing lifecycle profile phases: " + ", ".join(missing)) + +def milliseconds(phase): + return durations[phase] / 1_000_000 + +reconciliation = sum(milliseconds(phase) for phase in [ + "foreground.raw_log_drain", + "foreground.structured_log_drain", + "foreground.archive", + "foreground.manager_reconcile", +]) +fields = [ + milliseconds("cli.create_start"), + milliseconds("foreground.command_execution"), + reconciliation, + milliseconds("foreground.removal"), + milliseconds("sandbox.capability"), + milliseconds("sandbox.layout"), + milliseconds("sandbox.instance_prepare"), + milliseconds("sandbox.mount_sources"), + milliseconds("sandbox.rootfs_ownership"), + milliseconds("sandbox.bundle"), + milliseconds("sandbox.launch"), + milliseconds("sandbox.readiness"), +] +print(",".join(f"{value:.3f}" for value in fields)) +PY +} + +bench_sandbox_lifecycle() { + echo "## Hot Sandbox lifecycle ($SANDBOX_RUNS runs, $SANDBOX_WARMUPS warm-up, $IMAGE)" + echo " cold image import/unpack: excluded (pull completes before warm-up)" + mkdir -p "$SANDBOX_LOG_DIR" "$(dirname "$SANDBOX_RESULTS")" + printf '%s\n' "runtime,iteration,wall_ms,create_start_ms,command_execution_ms,reconciliation_ms,removal_ms,capability_ms,layout_ms,instance_prepare_ms,mount_sources_ms,rootfs_ownership_ms,bundle_ms,launch_ms,readiness_ms,exit_code" >"$SANDBOX_RESULTS" + + local a3s_log="$SANDBOX_LOG_DIR/a3s-warmup.log" + local docker_log="$SANDBOX_LOG_DIR/docker-warmup.log" + local i status + "$A3S_BOX" pull "$IMAGE" >"$SANDBOX_LOG_DIR/a3s-pull.log" 2>&1 || { + echo " FAIL: a3s-box image preparation failed" >&2 + tail -80 "$SANDBOX_LOG_DIR/a3s-pull.log" >&2 + return 1 + } + for i in $(seq 1 "$SANDBOX_WARMUPS"); do + if ! "$A3S_BOX" run --rm --isolation sandbox --no-stdin --timeout 180 "$IMAGE" -- true >"$a3s_log" 2>&1; then + echo " FAIL: Sandbox warm-up $i failed" >&2 + tail -80 "$a3s_log" >&2 + return 1 + fi + done + + local compare_docker="$SANDBOX_DOCKER" + if [ "$compare_docker" = "1" ]; then + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker pull "$IMAGE" >"$SANDBOX_LOG_DIR/docker-pull.log" 2>&1 || { + echo " FAIL: Docker image preparation failed" >&2 + tail -80 "$SANDBOX_LOG_DIR/docker-pull.log" >&2 + return 1 + } + for i in $(seq 1 "$SANDBOX_WARMUPS"); do + if ! docker run --rm "$IMAGE" true >"$docker_log" 2>&1; then + echo " FAIL: Docker warm-up $i failed" >&2 + tail -80 "$docker_log" >&2 + return 1 + fi + done + else + compare_docker=0 + echo " Docker comparison: skipped (docker CLI or daemon unavailable)" + fi + fi + + local a3s_samples="" docker_samples="" + local create_samples="" command_samples="" reconciliation_samples="" removal_samples="" + local capability_samples="" layout_samples="" instance_samples="" mount_samples="" + local ownership_samples="" bundle_samples="" launch_samples="" readiness_samples="" + + measure_a3s_sandbox_sample() { + local iteration="$1" + local log_file="$SANDBOX_LOG_DIR/a3s-$iteration.log" + local s e wall fields + s=$(now_ms) + A3S_BOX_LIFECYCLE_PROFILE=1 "$A3S_BOX" run --rm --isolation sandbox --no-stdin --timeout 180 "$IMAGE" -- true >"$log_file" 2>&1 + status=$? + e=$(now_ms) + wall=$(( e - s )) + if [ "$status" -ne 0 ]; then + printf 'a3s,%s,%s,,,,,,,,,,,,,%s\n' "$iteration" "$wall" "$status" >>"$SANDBOX_RESULTS" + echo " FAIL: Sandbox sample $iteration failed" >&2 + tail -100 "$log_file" >&2 + return "$status" + fi + if ! fields=$(sandbox_profile_fields "$log_file"); then + echo " FAIL: incomplete lifecycle profile for Sandbox sample $iteration" >&2 + tail -120 "$log_file" >&2 + return 1 + fi + local create command reconciliation removal capability layout instance mount ownership bundle launch readiness + IFS=, read -r create command reconciliation removal capability layout instance mount ownership bundle launch readiness <>"$SANDBOX_RESULTS" + a3s_samples="$a3s_samples $wall" + create_samples="$create_samples $create" + command_samples="$command_samples $command" + reconciliation_samples="$reconciliation_samples $reconciliation" + removal_samples="$removal_samples $removal" + capability_samples="$capability_samples $capability" + layout_samples="$layout_samples $layout" + instance_samples="$instance_samples $instance" + mount_samples="$mount_samples $mount" + ownership_samples="$ownership_samples $ownership" + bundle_samples="$bundle_samples $bundle" + launch_samples="$launch_samples $launch" + readiness_samples="$readiness_samples $readiness" + } + + measure_docker_sandbox_sample() { + local iteration="$1" + local log_file="$SANDBOX_LOG_DIR/docker-$iteration.log" + local s e wall + s=$(now_ms) + docker run --rm "$IMAGE" true >"$log_file" 2>&1 + status=$? + e=$(now_ms) + wall=$(( e - s )) + printf 'docker,%s,%s,,,,,,,,,,,,,%s\n' "$iteration" "$wall" "$status" >>"$SANDBOX_RESULTS" + if [ "$status" -ne 0 ]; then + echo " FAIL: Docker sample $iteration failed" >&2 + tail -100 "$log_file" >&2 + return "$status" + fi + docker_samples="$docker_samples $wall" + } + + for i in $(seq 1 "$SANDBOX_RUNS"); do + if [ "$compare_docker" = "1" ] && [ $(( i % 2 )) -eq 0 ]; then + measure_docker_sandbox_sample "$i" || return $? + measure_a3s_sandbox_sample "$i" || return $? + else + measure_a3s_sandbox_sample "$i" || return $? + if [ "$compare_docker" = "1" ]; then + measure_docker_sandbox_sample "$i" || return $? + fi + fi + done + + echo " a3s-box total: mean=$(mean_ms "$a3s_samples")ms p50=$(pct "$a3s_samples" 50)ms p95=$(pct "$a3s_samples" 95)ms" + echo " create/start: p50=$(pct "$create_samples" 50)ms" + echo " command execution: p50=$(pct "$command_samples" 50)ms" + echo " reconciliation: p50=$(pct "$reconciliation_samples" 50)ms" + echo " removal: p50=$(pct "$removal_samples" 50)ms" + echo " start internals p50: capability=$(pct "$capability_samples" 50)ms layout=$(pct "$layout_samples" 50)ms instance=$(pct "$instance_samples" 50)ms mounts=$(pct "$mount_samples" 50)ms ownership=$(pct "$ownership_samples" 50)ms bundle=$(pct "$bundle_samples" 50)ms launch=$(pct "$launch_samples" 50)ms readiness=$(pct "$readiness_samples" 50)ms" + if [ "$compare_docker" = "1" ]; then + local a3s_p50 docker_p50 + a3s_p50=$(pct "$a3s_samples" 50) + docker_p50=$(pct "$docker_samples" 50) + echo " Docker total: mean=$(mean_ms "$docker_samples")ms p50=${docker_p50}ms p95=$(pct "$docker_samples" 95)ms" + echo " a3s-box/Docker p50: $(ratio "$a3s_p50" "$docker_p50")" + fi + echo " CSV: $SANDBOX_RESULTS" + echo " profile logs: $SANDBOX_LOG_DIR" +} + bench_warm() { echo "## Warm-pool acquire ($RUNS runs, pool size $POOL_SIZE)" local sock=/tmp/a3s-bench-pool.sock @@ -181,22 +542,210 @@ bench_race() { return "$rc" } -require_kvm +bench_pnpm() { + echo "## pnpm install benchmark ($PNPM_RUNS runs, image=$PNPM_IMAGE)" + if [ -z "$PNPM_PROJECT" ]; then + echo " ERROR: set PNPM_PROJECT to a project directory with package.json and pnpm-lock.yaml" >&2 + return 2 + fi + if [ ! -f "$PNPM_PROJECT/package.json" ] || [ ! -f "$PNPM_PROJECT/pnpm-lock.yaml" ]; then + echo " ERROR: PNPM_PROJECT must contain package.json and pnpm-lock.yaml: $PNPM_PROJECT" >&2 + return 2 + fi + case "$PNPM_NODE_MODULES" in + project|tmpfs|both) ;; + *) echo " ERROR: PNPM_NODE_MODULES must be project, tmpfs, or both" >&2; return 2 ;; + esac + + local project + project=$(cd "$PNPM_PROJECT" && pwd) + mkdir -p "$PNPM_LOG_DIR" + "$A3S_BOX" pull "$PNPM_IMAGE" >/dev/null 2>&1 || true + + echo " config: project=$project cpus=$PNPM_CPUS memory=$PNPM_MEMORY package-cache=$PNPM_CACHE node_modules=$PNPM_NODE_MODULES reset-a3s-cache=$PNPM_RESET_A3S_CACHE" + echo " logs: $PNPM_LOG_DIR" + + local prepare_cmd fetch_cmd offline_cmd full_cmd + prepare_cmd="corepack enable && corepack prepare pnpm@$PNPM_VERSION --activate >/dev/null && pnpm --version >/dev/null" + fetch_cmd="$prepare_cmd && rm -rf node_modules && pnpm fetch --frozen-lockfile --reporter append-only" + offline_cmd="$prepare_cmd && rm -rf node_modules && pnpm install --offline --frozen-lockfile --ignore-scripts --reporter append-only" + full_cmd="$prepare_cmd && rm -rf node_modules && pnpm install --frozen-lockfile --reporter append-only" + + run_a3s_pnpm() { + local log_file="$1"; shift + local guest_cmd="$1"; shift + local cache_args=() + [ "$PNPM_CACHE" = "1" ] && cache_args=(--package-cache pnpm) + "$A3S_BOX" run --rm --cpus "$PNPM_CPUS" --memory "$PNPM_MEMORY" "${cache_args[@]}" "$@" "$PNPM_IMAGE" -- sh -lc "$guest_cmd" >"$log_file" 2>&1 + } + + run_docker_pnpm() { + local log_file="$1"; shift + local guest_cmd="$1"; shift + docker run --rm --cpus "$PNPM_CPUS" --memory "$PNPM_MEMORY" \ + -v "$PNPM_DOCKER_STORE_VOLUME:/a3s-cache/pnpm" \ + -e npm_config_store_dir=/a3s-cache/pnpm/store \ + -e COREPACK_HOME=/a3s-cache/pnpm/corepack \ + "$@" "$PNPM_IMAGE" sh -lc "$guest_cmd" >"$log_file" 2>&1 + } + + MEASURED_SAMPLES="" + measure_a3s_samples() { + local label="$1"; shift + local guest_cmd="$1"; shift + local samples="" i s e status log_file + for i in $(seq 1 "$PNPM_RUNS"); do + log_file="$PNPM_LOG_DIR/a3s-$label-$i.log" + if [ "$PNPM_RESET_A3S_CACHE" = "1" ] && [ "$PNPM_CACHE" = "1" ]; then + case "$label" in + toolchain|fetch|install-*) "$A3S_BOX" volume rm -f "$PNPM_A3S_CACHE_VOLUME" >/dev/null 2>&1 || true ;; + esac + fi + s=$(now_ms) + run_a3s_pnpm "$log_file" "$guest_cmd" "$@" + status=$? + e=$(now_ms); samples="$samples $(( e - s ))" + if [ "$status" -ne 0 ]; then + echo " FAIL: A3S $label failed (see $log_file)" >&2 + tail -80 "$log_file" >&2 + return "$status" + fi + done + MEASURED_SAMPLES="$samples" + } + + measure_docker_samples() { + local label="$1"; shift + local guest_cmd="$1"; shift + local samples="" i s e status log_file + for i in $(seq 1 "$PNPM_RUNS"); do + log_file="$PNPM_LOG_DIR/docker-$label-$i.log" + case "$label" in + cold-*) docker volume rm -f "$PNPM_DOCKER_STORE_VOLUME" >/dev/null 2>&1 || true ;; + esac + s=$(now_ms) + run_docker_pnpm "$log_file" "$guest_cmd" "$@" + status=$? + e=$(now_ms); samples="$samples $(( e - s ))" + if [ "$status" -ne 0 ]; then + echo " FAIL: Docker $label failed (see $log_file)" >&2 + tail -80 "$log_file" >&2 + return "$status" + fi + done + MEASURED_SAMPLES="$samples" + } + + local boot_samples="" toolchain_samples="" install_project_samples="" install_tmpfs_samples="" + local fetch_samples="" offline_project_samples="" offline_tmpfs_samples="" + for _ in $(seq 1 "$PNPM_RUNS"); do + local s e + + s=$(now_ms) + "$A3S_BOX" run --rm --cpus "$PNPM_CPUS" --memory "$PNPM_MEMORY" "$PNPM_IMAGE" -- true >/dev/null 2>&1 + e=$(now_ms); boot_samples="$boot_samples $(( e - s ))" + done + + measure_a3s_samples "toolchain" "$prepare_cmd" || return $? + toolchain_samples="$MEASURED_SAMPLES" + + if [ "$PNPM_CACHE" = "1" ]; then + measure_a3s_samples "fetch" "$fetch_cmd" -v "$project:/work" -w /work || return $? + fetch_samples="$MEASURED_SAMPLES" + + measure_a3s_samples "offline-project" "$offline_cmd" -v "$project:/work" -w /work || return $? + offline_project_samples="$MEASURED_SAMPLES" + + if [ "$PNPM_NODE_MODULES" = "tmpfs" ] || [ "$PNPM_NODE_MODULES" = "both" ]; then + measure_a3s_samples "offline-tmpfs" "$offline_cmd" -v "$project:/work" -w /work --tmpfs "/work/node_modules:size=$PNPM_TMPFS_SIZE" || return $? + offline_tmpfs_samples="$MEASURED_SAMPLES" + fi + fi + + if [ "$PNPM_NODE_MODULES" = "project" ] || [ "$PNPM_NODE_MODULES" = "both" ]; then + measure_a3s_samples "install-project" "$full_cmd" -v "$project:/work" -w /work || return $? + install_project_samples="$MEASURED_SAMPLES" + fi + + if [ "$PNPM_NODE_MODULES" = "tmpfs" ] || [ "$PNPM_NODE_MODULES" = "both" ]; then + measure_a3s_samples "install-tmpfs" "$full_cmd" -v "$project:/work" -w /work --tmpfs "/work/node_modules:size=$PNPM_TMPFS_SIZE" || return $? + install_tmpfs_samples="$MEASURED_SAMPLES" + fi + + local boot_p50 toolchain_p50 fetch_p50 offline_project_p50 offline_tmpfs_p50 install_project_p50 install_tmpfs_p50 + boot_p50=$(pct "$boot_samples" 50) + toolchain_p50=$(pct "$toolchain_samples" 50) + fetch_p50=0 + offline_project_p50=0 + offline_tmpfs_p50=0 + install_project_p50=0 + install_tmpfs_p50=0 + [ -n "$fetch_samples" ] && fetch_p50=$(pct "$fetch_samples" 50) + [ -n "$offline_project_samples" ] && offline_project_p50=$(pct "$offline_project_samples" 50) + [ -n "$offline_tmpfs_samples" ] && offline_tmpfs_p50=$(pct "$offline_tmpfs_samples" 50) + [ -n "$install_project_samples" ] && install_project_p50=$(pct "$install_project_samples" 50) + [ -n "$install_tmpfs_samples" ] && install_tmpfs_p50=$(pct "$install_tmpfs_samples" 50) + + echo " boot baseline: p50=${boot_p50}ms p90=$(pct "$boot_samples" 90)ms" + echo " corepack+pnpm baseline: p50=${toolchain_p50}ms p90=$(pct "$toolchain_samples" 90)ms" + + if [ -n "$fetch_samples" ]; then + echo " fetch/download+extract: p50=${fetch_p50}ms p90=$(pct "$fetch_samples" 90)ms target=pnpm-store" + echo " offline install fs: p50=${offline_project_p50}ms p90=$(pct "$offline_project_samples" 90)ms target=project-mount" + if [ -n "$offline_tmpfs_samples" ]; then + echo " offline install fs: p50=${offline_tmpfs_p50}ms p90=$(pct "$offline_tmpfs_samples" 90)ms target=tmpfs" + echo " project fs overhead: p50=$(( offline_project_p50 - offline_tmpfs_p50 ))ms vs tmpfs" + fi + else + echo " fetch/offline split: skipped (requires PNPM_CACHE=1 so store survives between boxes)" + fi + + if [ -n "$install_project_samples" ]; then + echo " frozen install total: p50=${install_project_p50}ms p90=$(pct "$install_project_samples" 90)ms target=project-mount cache=$PNPM_CACHE" + fi + if [ -n "$install_tmpfs_samples" ]; then + echo " frozen install total: p50=${install_tmpfs_p50}ms p90=$(pct "$install_tmpfs_samples" 90)ms target=tmpfs cache=$PNPM_CACHE" + fi + + if [ "$PNPM_DOCKER" = "1" ] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker pull "$PNPM_IMAGE" >/dev/null 2>&1 || true + docker volume rm -f "$PNPM_DOCKER_STORE_VOLUME" >/dev/null 2>&1 || true + measure_docker_samples "cold-project" "$full_cmd" -v "$project:/work" -w /work || return $? + local docker_cold_samples="$MEASURED_SAMPLES" + measure_docker_samples "hot-project" "$full_cmd" -v "$project:/work" -w /work || return $? + local docker_hot_samples="$MEASURED_SAMPLES" + local docker_cold_p50 docker_hot_p50 + docker_cold_p50=$(pct "$docker_cold_samples" 50) + docker_hot_p50=$(pct "$docker_hot_samples" 50) + echo " Docker cold baseline: p50=${docker_cold_p50}ms p90=$(pct "$docker_cold_samples" 90)ms target=project-mount" + echo " Docker hot baseline: p50=${docker_hot_p50}ms p90=$(pct "$docker_hot_samples" 90)ms target=project-mount" + [ "$install_project_p50" -gt 0 ] && echo " A3S/Docker hot ratio: $(ratio "$install_project_p50" "$docker_hot_p50") target=project-mount" + [ "$install_tmpfs_p50" -gt 0 ] && echo " A3S tmpfs/Docker hot: $(ratio "$install_tmpfs_p50" "$docker_hot_p50")" + elif [ "$PNPM_DOCKER" = "1" ]; then + echo " Docker baseline: skipped (docker CLI or daemon unavailable)" + fi +} + +require_runtime echo "# a3s-box benchmark — $(uname -sm), image=$IMAGE" rc=0 case "$MODE" in cold) bench_cold ;; + foreground) bench_foreground || rc=$? ;; + sandbox) bench_sandbox_lifecycle || rc=$? ;; warm) bench_warm ;; fork) bench_fork ;; leak) bench_leak || rc=$? ;; race) bench_race || rc=$? ;; + pnpm) bench_pnpm || rc=$? ;; all) bench_cold + bench_foreground || rc=$? bench_warm bench_fork bench_leak || rc=$? bench_race || rc=$? ;; - *) echo "unknown mode: $MODE (use all|cold|warm|fork|leak|race)"; exit 2 ;; + *) echo "unknown mode: $MODE (use all|cold|foreground|sandbox|warm|fork|leak|race|pnpm)"; exit 2 ;; esac exit "$rc" diff --git a/bench/fixtures/pnpm/package.json b/bench/fixtures/pnpm/package.json new file mode 100644 index 00000000..0f75a39b --- /dev/null +++ b/bench/fixtures/pnpm/package.json @@ -0,0 +1,23 @@ +{ + "name": "a3s-box-pnpm-bench-fixture", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "express": "4.18.3", + "react": "18.2.0", + "react-dom": "18.2.0", + "zod": "3.23.8" + }, + "devDependencies": { + "@types/node": "20.11.30", + "@types/react": "18.2.66", + "@vitejs/plugin-react": "4.2.1", + "typescript": "5.4.5", + "vite": "5.2.0" + } +} diff --git a/bench/fixtures/pnpm/pnpm-lock.yaml b/bench/fixtures/pnpm/pnpm-lock.yaml new file mode 100644 index 00000000..c5f3735e --- /dev/null +++ b/bench/fixtures/pnpm/pnpm-lock.yaml @@ -0,0 +1,1652 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: 4.18.3 + version: 4.18.3 + react: + specifier: 18.2.0 + version: 18.2.0 + react-dom: + specifier: 18.2.0 + version: 18.2.0(react@18.2.0) + zod: + specifier: 3.23.8 + version: 3.23.8 + devDependencies: + '@types/node': + specifier: 20.11.30 + version: 20.11.30 + '@types/react': + specifier: 18.2.66 + version: 18.2.66 + '@vitejs/plugin-react': + specifier: 4.2.1 + version: 4.2.1(vite@5.2.0(@types/node@20.11.30)) + typescript: + specifier: 5.4.5 + version: 5.4.5 + vite: + specifier: 5.2.0 + version: 5.2.0(@types/node@20.11.30) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.20.2': + resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.20.2': + resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.20.2': + resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.20.2': + resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.20.2': + resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.20.2': + resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.20.2': + resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.20.2': + resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.20.2': + resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.20.2': + resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.20.2': + resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.20.2': + resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.20.2': + resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.20.2': + resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.20.2': + resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.20.2': + resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.20.2': + resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.20.2': + resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.20.2': + resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.20.2': + resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.20.2': + resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.20.2': + resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.20.2': + resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@20.11.30': + resolution: {integrity: sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react@18.2.66': + resolution: {integrity: sha512-OYTmMI4UigXeFMF/j4uv0lBBEbongSgptPrHBxqME44h9+yNov+oL6Z3ocJKo0WyXR84sQUNeyIp9MRfckvZpg==} + + '@types/scheduler@0.26.0': + resolution: {integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==} + + '@vitejs/plugin-react@4.2.1': + resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + baseline-browser-mapping@2.10.42: + resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + body-parser@1.20.2: + resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + browserslist@4.28.5: + resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001803: + resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.388: + resolution: {integrity: sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.20.2: + resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.18.3: + resolution: {integrity: sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==} + engines: {node: '>= 0.10.0'} + + finalhandler@1.2.0: + resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.1: + resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.7: + resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.11.0: + resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + react-dom@18.2.0: + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + send@0.18.0: + resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.15.0: + resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@5.2.0: + resolution: {integrity: sha512-xMSLJNEjNk/3DJRgWlPADDwaU9AgYRodDH2t6oENhJnIlmU9Hx1Q6VpjyXua/JdMw1WJRbnAgHJ9xgET9gnIAg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.5 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.20.2': + optional: true + + '@esbuild/android-arm64@0.20.2': + optional: true + + '@esbuild/android-arm@0.20.2': + optional: true + + '@esbuild/android-x64@0.20.2': + optional: true + + '@esbuild/darwin-arm64@0.20.2': + optional: true + + '@esbuild/darwin-x64@0.20.2': + optional: true + + '@esbuild/freebsd-arm64@0.20.2': + optional: true + + '@esbuild/freebsd-x64@0.20.2': + optional: true + + '@esbuild/linux-arm64@0.20.2': + optional: true + + '@esbuild/linux-arm@0.20.2': + optional: true + + '@esbuild/linux-ia32@0.20.2': + optional: true + + '@esbuild/linux-loong64@0.20.2': + optional: true + + '@esbuild/linux-mips64el@0.20.2': + optional: true + + '@esbuild/linux-ppc64@0.20.2': + optional: true + + '@esbuild/linux-riscv64@0.20.2': + optional: true + + '@esbuild/linux-s390x@0.20.2': + optional: true + + '@esbuild/linux-x64@0.20.2': + optional: true + + '@esbuild/netbsd-x64@0.20.2': + optional: true + + '@esbuild/openbsd-x64@0.20.2': + optional: true + + '@esbuild/sunos-x64@0.20.2': + optional: true + + '@esbuild/win32-arm64@0.20.2': + optional: true + + '@esbuild/win32-ia32@0.20.2': + optional: true + + '@esbuild/win32-x64@0.20.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/node@20.11.30': + dependencies: + undici-types: 5.26.5 + + '@types/prop-types@15.7.15': {} + + '@types/react@18.2.66': + dependencies: + '@types/prop-types': 15.7.15 + '@types/scheduler': 0.26.0 + csstype: 3.2.3 + + '@types/scheduler@0.26.0': {} + + '@vitejs/plugin-react@4.2.1(vite@5.2.0(@types/node@20.11.30))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@types/babel__core': 7.20.5 + react-refresh: 0.14.2 + vite: 5.2.0(@types/node@20.11.30) + transitivePeerDependencies: + - supports-color + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + array-flatten@1.1.1: {} + + baseline-browser-mapping@2.10.42: {} + + body-parser@1.20.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.11.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + browserslist@4.28.5: + dependencies: + baseline-browser-mapping: 2.10.42 + caniuse-lite: 1.0.30001803 + electron-to-chromium: 1.5.388 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.5) + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001803: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.6: {} + + cookie@0.5.0: {} + + csstype@3.2.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.388: {} + + encodeurl@1.0.2: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.20.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.20.2 + '@esbuild/android-arm': 0.20.2 + '@esbuild/android-arm64': 0.20.2 + '@esbuild/android-x64': 0.20.2 + '@esbuild/darwin-arm64': 0.20.2 + '@esbuild/darwin-x64': 0.20.2 + '@esbuild/freebsd-arm64': 0.20.2 + '@esbuild/freebsd-x64': 0.20.2 + '@esbuild/linux-arm': 0.20.2 + '@esbuild/linux-arm64': 0.20.2 + '@esbuild/linux-ia32': 0.20.2 + '@esbuild/linux-loong64': 0.20.2 + '@esbuild/linux-mips64el': 0.20.2 + '@esbuild/linux-ppc64': 0.20.2 + '@esbuild/linux-riscv64': 0.20.2 + '@esbuild/linux-s390x': 0.20.2 + '@esbuild/linux-x64': 0.20.2 + '@esbuild/netbsd-x64': 0.20.2 + '@esbuild/openbsd-x64': 0.20.2 + '@esbuild/sunos-x64': 0.20.2 + '@esbuild/win32-arm64': 0.20.2 + '@esbuild/win32-ia32': 0.20.2 + '@esbuild/win32-x64': 0.20.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.18.3: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.2 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.5.0 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.2.0 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.1 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.7 + proxy-addr: 2.0.7 + qs: 6.11.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.18.0 + serve-static: 1.15.0 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@1.2.0: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.1: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + negotiator@0.6.3: {} + + node-releases@2.0.50: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.7: {} + + picocolors@1.1.1: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.11.0: + dependencies: + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@18.2.0(react@18.2.0): + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.2 + + react-refresh@0.14.2: {} + + react@18.2.0: + dependencies: + loose-envify: 1.4.0 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + send@0.18.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.15.0: + dependencies: + encodeurl: 1.0.2 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.18.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + statuses@2.0.1: {} + + toidentifier@1.0.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.4.5: {} + + undici-types@5.26.5: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.5): + dependencies: + browserslist: 4.28.5 + escalade: 3.2.0 + picocolors: 1.1.1 + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + vite@5.2.0(@types/node@20.11.30): + dependencies: + esbuild: 0.20.2 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 20.11.30 + fsevents: 2.3.3 + + yallist@3.1.1: {} + + zod@3.23.8: {} diff --git a/compat/e2b/README.md b/compat/e2b/README.md new file mode 100644 index 00000000..8f325046 --- /dev/null +++ b/compat/e2b/README.md @@ -0,0 +1,156 @@ +# E2B Compatibility Contract Fixture + +This directory pins the public E2B contracts that A3S Box implements. It is +the source of truth for protocol generation, official-client conformance, and +the Python and TypeScript public API alignment checks. + +The fixture does not claim full compatibility. The generated manifest keeps +`full_compatibility` set to `false` until the complete black-box release gate +in `docs/e2b-compatible-sdk-design.md` passes. + +## Pinned sources + +`upstream.lock.json` records immutable repository commits, package versions, +source paths, SHA-256 digests, and the control-plane tags selected by the pinned +official SDK codegen. The vendored schemas and public export entry points retain +their upstream licenses below `spec/`. + +The same lock records the published Python wheels and npm tarballs used by +black-box fixtures. Fixture runners download those exact artifacts and verify +both SHA-256 and, for npm, the published integrity value before installing. + +The first tuple pins: + +- Python `e2b` 2.32.0 and TypeScript `e2b` 2.33.0; +- Python `e2b-code-interpreter` 2.8.1; +- TypeScript `@e2b/code-interpreter` 2.6.1. + +## Generated evidence + +The `a3s-box-e2b-contract` tool produces: + +- `inventory/contracts.json`: OpenAPI operations, parameters, response errors, + schema fields, authentication headers, Protobuf services/descriptors, and + MCP schema fields; +- `inventory/public-exports.json`: the pinned Python and TypeScript top-level + public exports for the base and Code Interpreter packages; +- `manifests/v1.json`: the tested version tuple and contract/inventory digests. + +Generate and verify from the Box repository: + +```bash +cd src +cargo run -p a3s-box-compat --bin a3s-box-e2b-contract -- generate +cargo run -p a3s-box-compat --bin a3s-box-e2b-contract -- verify +``` + +`protoc` must be available because the Protobuf inventory is generated from a +real descriptor set rather than a hand-written parser. CI verifies that the +vendored sources, inventories, and manifest remain byte-for-byte consistent. + +## Updating the pin + +1. Select explicit upstream commits and package versions. +2. Review licenses and the upstream protocol diff. +3. Replace the vendored files and update every source digest in + `upstream.lock.json`. +4. Regenerate the inventories and manifest. +5. Review the machine-readable diff and update server/SDK conformance fixtures. +6. Run the unchanged official Python sync, Python async, and TypeScript clients + before advertising the new tuple. + +Never edit generated inventories by hand or infer compatibility from matching +method names alone. + +## Production control service + +`a3s-box-e2b` composes the lifecycle router with the SQLite repository, the +canonical A3S runtime manager, production credential providers, startup +reconciliation, and periodic expiry maintenance. It requires a `.acl` file +parsed by `a3s-acl`; literal sandbox token keys are rejected in favor of +`env("VARIABLE")` references. + +Sandbox expiry is measured from the later of runtime start and observed envd +readiness, so cold startup does not consume the caller's requested usable +timeout. Startup reconciliation applies the same lifetime rule when it recovers +a creating record whose execution was committed before the service restarted. + +Memory-preserving pause maps to certified `crun pause`; a later `connect` or +deprecated `resume` request maps to `crun resume` without shortening the +existing TTL. Filesystem-only pause is rejected explicitly until cold-pause +semantics are implemented. + +Run it from the Rust workspace: + +```bash +cargo run --locked -p a3s-box-compat --bin a3s-box-e2b -- \ + --config /etc/a3s-box/e2b.acl +``` + +The validated schema and an operator example are documented in +[`docs/e2b-compatible-sdk-design.md`](../../docs/e2b-compatible-sdk-design.md#configuration). +This process exposes the lifecycle control subset plus an authenticated +wildcard TLS data-plane edge. The edge supports direct and shared route forms, +HTTP/1.1 and HTTP/2 streaming proxying, CORS preflight, upgrades, bounded +connections, and generation-fenced access to real Sandbox loopback ports. +Templates select host-broker or in-Sandbox runtime envd placement explicitly. +The host broker implements authenticated `GET /health`, including terminal +`502` without reopening a live route lease. Runtime placement proxies health, +Process, Filesystem, and file HTTP routes to port `49983` only after fail-closed +initialization and a fenced readiness connection. Invalid tokens remain +unauthorized. + +The destructive A3S OS integration harness is +[`scripts/e2b-production-smoke.sh`](../../scripts/e2b-production-smoke.sh). It +requires a dedicated runtime home and explicit acknowledgement, and verifies a +real Sandbox lifecycle, v1 running-list behavior, monotonic refresh with an +optional body, current batch metrics, generation-fenced v1/v2 structured +runtime logs with forward/backward ordering, TLS direct/shared routing, +token-scope denial, service restart recovery, envd health/metrics/environment, +metadata-preserving HTTP file upload and download, a traffic-scoped workload +service on port `49999`, stale-route fencing, authenticated terminal health, +and resource cleanup. The default `localhost.localdomain` wildcard is DNS- and +TLS-preflighted before a Sandbox starts. With +`A3S_BOX_E2B_OFFICIAL_CLIENTS=1`, it additionally runs the checksum-pinned, +unchanged Python sync, Python async, TypeScript, and Code Interpreter packages +through the production lifecycle listener, calls their official running-state +health methods through the TLS gateway before and after kill, and verifies +cleanup of every real `crun` execution. With the runtime image selected, the +three base clients also exercise Filesystem create/read/stat/list/rename/remove, +foreground and background commands, process listing, stdin close, PTY resize, +current Sandbox metrics with historical-range filtering, memory-preserving +pause, paused-state listing, connect-based resume, survival of the same +background process, owner-scoped Volume create/connect/list/content/delete, +bidirectional Sandbox mounts, UID/GID mapping, in-use deletion conflicts, and +owner-scoped filesystem Snapshot capture/list/restore/delete. Snapshot clients +prove that the source remains running after capture, restored files retain +content, ownership, and mode after the source is killed, the restored rootfs is +writable through a private copy-on-write upper, an in-use Snapshot cannot be +deleted, and final deletion releases its content. They also exercise Python +Code Interpreter execution plus context create/list/run/restart/remove. + +Snapshot control records use the same owner isolation and durable SQLite +transition model as lifecycle records. Startup reconciliation finishes or +cleans interrupted captures and deletes, while the runtime quiesces the source, +captures authoritative rootfs metadata and resolved OCI image defaults, then +returns a running or paused source to its original state. Snapshot records from +older builds that lack those OCI defaults remain inspectable and deletable but +fail closed on restore. + +The default smoke uses the small Alpine broker fixture. Set +`A3S_BOX_E2B_RUNTIME_IMAGE` to an immutable +`ghcr.io/a3s-lab/box-e2b-runtime` tag or digest to generate runtime-mode +template policies and exercise envd plus Code Interpreter health inside that +image. This mode is destructive and retains the same dedicated-home, +credential, restart, stale-route, and cleanup requirements. + +With `A3S_BOX_E2B_NATIVE_SDKS=1` and +`A3S_BOX_E2B_OFFICIAL_CLIENTS=1`, the harness repeats that matrix through the +A3S Python sync/async and TypeScript packages after removing every `E2B_*` +connection variable and configuring only `A3S_BOX_*`. This production subset +passes on A3S OS with certified `crun`, but it does not establish full protocol +compatibility. Templates/builds, signed files, filesystem-only pause, +historical metrics, MCP, additional signals, reconnect, cancellation, +backpressure, multi-file and large-file behavior, deeper Snapshot and Volume +failure/recovery cases, and other pinned edge cases remain outside the matrix, +so `full_compatibility=false` remains mandatory. diff --git a/compat/e2b/fixtures/official-clients/README.md b/compat/e2b/fixtures/official-clients/README.md new file mode 100644 index 00000000..b97c6e54 --- /dev/null +++ b/compat/e2b/fixtures/official-clients/README.md @@ -0,0 +1,107 @@ +# Official Client Lifecycle Fixtures + +These fixtures execute the published, unmodified Python sync, Python async, +TypeScript, and Code Interpreter packages pinned by `upstream.lock.json`. +Their create, memory-preserving pause, connect/resume, list, timeout, kill, and +not-found flows run against a deterministic recording server and the Rust +lifecycle router. + +The runner downloads the exact wheel and npm tarball URLs from the source lock, +verifies SHA-256 and npm integrity before installation, and records only stable +wire fields: method, path, query serialization, JSON body, authentication, +content type, and official user agent. It never disables the SDK API-key +validator; the fixture key uses the accepted `e2b_` plus lowercase hexadecimal +form. + +Run with Python 3.10 or newer, Node.js 20 or newer, npm, and internet access: + +```bash +python3 compat/e2b/fixtures/official-clients/run_fixtures.py generate +python3 compat/e2b/fixtures/official-clients/run_fixtures.py verify +``` + +The repository CI builds the Rust fixture server and makes the live router run +mandatory: + +```bash +cd src +cargo build -p a3s-box-compat --bin a3s-box-e2b-fixture-server +cd .. +python3 compat/e2b/fixtures/official-clients/run_fixtures.py verify \ + --rust-server-bin src/target/debug/a3s-box-e2b-fixture-server +``` + +The runner uses `uv` when it is available on `PATH`, then falls back to the +standard-library `venv` module. On hosts where Python was packaged without +`ensurepip`, pass a trusted pip wheel without installing it into the host: + +```bash +python3 compat/e2b/fixtures/official-clients/run_fixtures.py \ + --pip-bootstrap-wheel /path/to/pip.whl verify +``` + +`PIP_INDEX_URL`, `PIP_DEFAULT_TIMEOUT`, and `PIP_RETRIES` are honored when set. +The defaults are PyPI, 60 seconds, and five retries. + +Use `--artifact-cache /path/to/cache` to reuse downloaded SDK wheels and npm +tarballs. Every cached artifact is checked against the SHA-256 and npm +integrity values in `upstream.lock.json` before use. Direct downloads use a +120-second timeout and retry up to three times. + +Generated JSON Lines files are compatibility evidence, not server +implementations. The Rust control plane must satisfy them without adding A3S +fields to upstream requests or responses. + +## Production runtime data-plane gate + +`run_production.py` installs the same checksum-pinned artifacts and runs the +unchanged Python sync, Python async, TypeScript, and Code Interpreter packages +against an already-running production compatibility service: + +```bash +E2B_API_KEY=e2b_a1b2c3 \ +python3 compat/e2b/fixtures/official-clients/run_production.py \ + --api-url http://127.0.0.1:38081 \ + --domain localhost.localdomain \ + --template fixture-template \ + --artifact-cache /path/to/verified-artifacts +``` + +On an A3S OS host, `scripts/e2b-production-smoke.sh` can make this matrix part +of the destructive real-Sandbox gate by setting +`A3S_BOX_E2B_OFFICIAL_CLIENTS=1`. Hosts without `ensurepip` can additionally +set `A3S_BOX_E2B_PIP_BOOTSTRAP_WHEEL`; the wheel is used through `PYTHONPATH` +and is not installed into the host Python environment. +Set `A3S_BOX_E2B_RUNTIME_IMAGE` to an immutable pinned runtime-image reference +when the same gate should use in-Sandbox envd and Code Interpreter services +instead of the default Alpine broker fixture. +Set `A3S_BOX_E2B_NATIVE_SDKS=1` to repeat the matrix through the repository's +`a3s-box` and `@a3s-lab/box` packages after the unchanged official clients +pass. The native packages still use the exact pinned upstream implementations; +this pass validates their A3S endpoint configuration and package exports. + +Official-client data-plane calls use HTTPS, so the configured wildcard sandbox +domain must resolve to the gateway listener. Port `443` is the default. On a +host where another data plane reserves that port, set +`A3S_BOX_E2B_GATEWAY_SMOKE_PORT`; the smoke service advertises +`:` in lifecycle responses, so envd, Code Interpreter, MCP, and +user-service URLs keep direct wildcard routing without `E2B_SANDBOX_URL`. +The production smoke defaults to `localhost.localdomain`, whose wildcard hosts +resolve on loopback on the target A3S OS while preserving normal TLS hostname +validation. A DNS and certificate preflight fails before any Sandbox is created +when an override is not routable. + +With the immutable runtime image selected, the public-client matrix proves +production lifecycle behavior, running and post-kill envd health, Filesystem +create/read/stat/list/rename/remove, foreground and background commands, +process listing, stdin close, one PTY resize flow, memory-preserving pause, +paused-state listing, connect-based resume, survival of the same background +process, Volume control and content operations, bidirectional Sandbox mounts, +UID/GID mapping, in-use deletion conflicts, Code Interpreter execution and +context lifecycle, and cleanup. The enclosing smoke gate also validates envd +metrics/environment and HTTP file transfer directly through the authenticated +production data-plane route. Filesystem-only pause remains outside this matrix. +It does not claim exhaustive Process, Filesystem, PTY, rich-result, +multi-language, or MCP compatibility; those require the complete data-plane +suites. The repository compatibility manifest remains the source of truth for +the versions and matrix that have passed in production. diff --git a/compat/e2b/fixtures/official-clients/expected/python-async.jsonl b/compat/e2b/fixtures/official-clients/expected/python-async.jsonl new file mode 100644 index 00000000..ddf84eb6 --- /dev/null +++ b/compat/e2b/fixtures/official-clients/expected/python-async.jsonl @@ -0,0 +1,28 @@ +{"body":{"name":"fixture-data"},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/volumes","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/volumes/fixture-volume","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/volumes","query":[]} +{"body":null,"client":"python-async","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"POST","path":"/volumecontent/fixture-volume/dir","query":[["force","true"],["mode","493"],["path","/nested"]]} +{"body":"volume-value","client":"python-async","headers":{"authorization":"Bearer fixture-volume-token","content-type":"application/octet-stream","user-agent":"e2b-python-sdk/2.32.0"},"method":"PUT","path":"/volumecontent/fixture-volume/file","query":[["mode","420"],["path","/nested/value.txt"]]} +{"body":null,"client":"python-async","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"GET","path":"/volumecontent/fixture-volume/path","query":[["path","/nested/value.txt"]]} +{"body":{"mode":384},"client":"python-async","headers":{"authorization":"Bearer fixture-volume-token","content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0"},"method":"PATCH","path":"/volumecontent/fixture-volume/path","query":[["path","/nested/value.txt"]]} +{"body":null,"client":"python-async","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"GET","path":"/volumecontent/fixture-volume/dir","query":[["depth","2"],["path","/"]]} +{"body":null,"client":"python-async","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"GET","path":"/volumecontent/fixture-volume/file","query":[["path","/nested/value.txt"]]} +{"body":null,"client":"python-async","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"DELETE","path":"/volumecontent/fixture-volume/path","query":[["path","/nested"]]} +{"body":{"allow_internet_access":false,"autoPause":true,"autoPauseMemory":false,"autoResume":{"enabled":false},"envVars":{"ALPHA":"one","BETA":"two"},"metadata":{"purpose":"fixture","team":"alpha beta"},"secure":true,"templateID":"fixture-template","timeout":321,"volumeMounts":[{"name":"fixture-data","path":"/mnt/data"}]},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":{"memory":true},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/pause","query":[]} +{"body":{"memory":true},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/pause","query":[]} +{"body":{"timeout":222},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/connect","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/v2/sandboxes","query":[["limit","2"],["metadata","team=alpha%2520beta"],["nextToken","cursor-0"],["state","running,paused"]]} +{"body":{"name":"fixture-state"},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/snapshots","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/snapshots","query":[["limit","1"],["sandboxID","fixture-sandbox"]]} +{"body":{"allow_internet_access":true,"autoPause":false,"autoResume":{"enabled":false},"envVars":{},"metadata":{},"secure":true,"templateID":"fixture-team/fixture-state:default","timeout":300},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-restored","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/templates/fixture-team/fixture-state:default","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/templates/fixture-team/fixture-state:default","query":[]} +{"body":{"timeout":123},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/timeout","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-sandbox","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/missing-sandbox","query":[]} +{"body":{"timeout":300},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/missing-sandbox/connect","query":[]} +{"body":{"allow_internet_access":true,"autoPause":false,"autoResume":{"enabled":false},"envVars":{},"metadata":{},"secure":true,"templateID":"code-interpreter-v1","timeout":300},"client":"python-async","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-interpreter","query":[]} +{"body":null,"client":"python-async","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/volumes/fixture-volume","query":[]} diff --git a/compat/e2b/fixtures/official-clients/expected/python-sync.jsonl b/compat/e2b/fixtures/official-clients/expected/python-sync.jsonl new file mode 100644 index 00000000..3f3bf9e5 --- /dev/null +++ b/compat/e2b/fixtures/official-clients/expected/python-sync.jsonl @@ -0,0 +1,28 @@ +{"body":{"name":"fixture-data"},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/volumes","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/volumes/fixture-volume","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/volumes","query":[]} +{"body":null,"client":"python-sync","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"POST","path":"/volumecontent/fixture-volume/dir","query":[["force","true"],["mode","493"],["path","/nested"]]} +{"body":"volume-value","client":"python-sync","headers":{"authorization":"Bearer fixture-volume-token","content-type":"application/octet-stream","user-agent":"e2b-python-sdk/2.32.0"},"method":"PUT","path":"/volumecontent/fixture-volume/file","query":[["mode","420"],["path","/nested/value.txt"]]} +{"body":null,"client":"python-sync","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"GET","path":"/volumecontent/fixture-volume/path","query":[["path","/nested/value.txt"]]} +{"body":{"mode":384},"client":"python-sync","headers":{"authorization":"Bearer fixture-volume-token","content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0"},"method":"PATCH","path":"/volumecontent/fixture-volume/path","query":[["path","/nested/value.txt"]]} +{"body":null,"client":"python-sync","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"GET","path":"/volumecontent/fixture-volume/dir","query":[["depth","2"],["path","/"]]} +{"body":null,"client":"python-sync","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"GET","path":"/volumecontent/fixture-volume/file","query":[["path","/nested/value.txt"]]} +{"body":null,"client":"python-sync","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"e2b-python-sdk/2.32.0"},"method":"DELETE","path":"/volumecontent/fixture-volume/path","query":[["path","/nested"]]} +{"body":{"allow_internet_access":false,"autoPause":true,"autoPauseMemory":false,"autoResume":{"enabled":false},"envVars":{"ALPHA":"one","BETA":"two"},"metadata":{"purpose":"fixture","team":"alpha beta"},"secure":true,"templateID":"fixture-template","timeout":321,"volumeMounts":[{"name":"fixture-data","path":"/mnt/data"}]},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":{"memory":true},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/pause","query":[]} +{"body":{"memory":true},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/pause","query":[]} +{"body":{"timeout":222},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/connect","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/v2/sandboxes","query":[["limit","2"],["metadata","team=alpha%2520beta"],["nextToken","cursor-0"],["state","running,paused"]]} +{"body":{"name":"fixture-state"},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/snapshots","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/snapshots","query":[["limit","1"],["sandboxID","fixture-sandbox"]]} +{"body":{"allow_internet_access":true,"autoPause":false,"autoResume":{"enabled":false},"envVars":{},"metadata":{},"secure":true,"templateID":"fixture-team/fixture-state:default","timeout":300},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-restored","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/templates/fixture-team/fixture-state:default","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/templates/fixture-team/fixture-state:default","query":[]} +{"body":{"timeout":123},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/timeout","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-sandbox","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/missing-sandbox","query":[]} +{"body":{"timeout":300},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/missing-sandbox/connect","query":[]} +{"body":{"allow_internet_access":true,"autoPause":false,"autoResume":{"enabled":false},"envVars":{},"metadata":{},"secure":true,"templateID":"code-interpreter-v1","timeout":300},"client":"python-sync","headers":{"content-type":"application/json","user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-interpreter","query":[]} +{"body":null,"client":"python-sync","headers":{"user-agent":"e2b-python-sdk/2.32.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/volumes/fixture-volume","query":[]} diff --git a/compat/e2b/fixtures/official-clients/expected/typescript.jsonl b/compat/e2b/fixtures/official-clients/expected/typescript.jsonl new file mode 100644 index 00000000..c92bf3d6 --- /dev/null +++ b/compat/e2b/fixtures/official-clients/expected/typescript.jsonl @@ -0,0 +1,28 @@ +{"body":{"name":"fixture-data"},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/volumes","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/volumes/fixture-volume","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/volumes","query":[]} +{"body":null,"client":"typescript","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"undici"},"method":"POST","path":"/volumecontent/fixture-volume/dir","query":[["force","true"],["mode","493"],["path","/nested"]]} +{"body":"volume-value","client":"typescript","headers":{"authorization":"Bearer fixture-volume-token","content-type":"application/octet-stream","user-agent":"undici"},"method":"PUT","path":"/volumecontent/fixture-volume/file","query":[["mode","420"],["path","/nested/value.txt"]]} +{"body":null,"client":"typescript","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"undici"},"method":"GET","path":"/volumecontent/fixture-volume/path","query":[["path","/nested/value.txt"]]} +{"body":{"mode":384},"client":"typescript","headers":{"authorization":"Bearer fixture-volume-token","content-type":"application/json","user-agent":"undici"},"method":"PATCH","path":"/volumecontent/fixture-volume/path","query":[["path","/nested/value.txt"]]} +{"body":null,"client":"typescript","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"undici"},"method":"GET","path":"/volumecontent/fixture-volume/dir","query":[["depth","2"],["path","/"]]} +{"body":null,"client":"typescript","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"undici"},"method":"GET","path":"/volumecontent/fixture-volume/file","query":[["path","/nested/value.txt"]]} +{"body":null,"client":"typescript","headers":{"authorization":"Bearer fixture-volume-token","user-agent":"undici"},"method":"DELETE","path":"/volumecontent/fixture-volume/path","query":[["path","/nested"]]} +{"body":{"allow_internet_access":false,"autoPause":true,"autoPauseMemory":false,"autoResume":{"enabled":false},"envVars":{"ALPHA":"one","BETA":"two"},"metadata":{"purpose":"fixture","team":"alpha beta"},"secure":true,"templateID":"fixture-template","timeout":321,"volumeMounts":[{"name":"fixture-data","path":"/mnt/data"}]},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":{"memory":true},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/pause","query":[]} +{"body":{"memory":true},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/pause","query":[]} +{"body":{"timeout":222},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/connect","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/v2/sandboxes","query":[["limit","2"],["metadata","team=alpha%2520beta"],["nextToken","cursor-0"],["state","running,paused"]]} +{"body":{"name":"fixture-state"},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/snapshots","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"GET","path":"/snapshots","query":[["limit","1"],["sandboxID","fixture-sandbox"]]} +{"body":{"allow_internet_access":true,"autoPause":false,"autoResume":{"enabled":false},"secure":true,"templateID":"fixture-team/fixture-state:default","timeout":300},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-restored","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/templates/fixture-team%2Ffixture-state%3Adefault","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/templates/fixture-team%2Ffixture-state%3Adefault","query":[]} +{"body":{"timeout":123},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/fixture-sandbox/timeout","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-sandbox","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/missing-sandbox","query":[]} +{"body":{"timeout":300},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes/missing-sandbox/connect","query":[]} +{"body":{"allow_internet_access":true,"autoPause":false,"autoResume":{"enabled":false},"secure":true,"templateID":"code-interpreter-v1","timeout":300},"client":"typescript","headers":{"content-type":"application/json","user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"POST","path":"/sandboxes","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/sandboxes/fixture-interpreter","query":[]} +{"body":null,"client":"typescript","headers":{"user-agent":"e2b-js-sdk/2.33.0","x-api-key":"e2b_a1b2c3"},"method":"DELETE","path":"/volumes/fixture-volume","query":[]} diff --git a/compat/e2b/fixtures/official-clients/mock_server.py b/compat/e2b/fixtures/official-clients/mock_server.py new file mode 100644 index 00000000..32e0a6a4 --- /dev/null +++ b/compat/e2b/fixtures/official-clients/mock_server.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Record official SDK control-plane requests and return deterministic fixtures.""" + +from __future__ import annotations + +import argparse +import json +import signal +import threading +import urllib.parse +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, ClassVar + + +SANDBOX_ID = "fixture-sandbox" +RESTORED_SANDBOX_ID = "fixture-restored" +INTERPRETER_SANDBOX_ID = "fixture-interpreter" +MISSING_SANDBOX_ID = "missing-sandbox" +VOLUME_ID = "fixture-volume" +VOLUME_NAME = "fixture-data" +VOLUME_TOKEN = "fixture-volume-token" +VOLUME_CONTENT = "volume-value" +SNAPSHOT_ID = "fixture-team/fixture-state:default" + + +def sandbox_response(sandbox_id: str) -> dict[str, Any]: + return { + "clientID": "fixture-client", + "domain": "fixture.invalid", + "envdAccessToken": "fixture-envd-token", + "envdVersion": "0.1.3", + "sandboxID": sandbox_id, + "templateID": "fixture-template", + "trafficAccessToken": "fixture-traffic-token", + } + + +def listed_sandbox() -> dict[str, Any]: + return { + "clientID": "fixture-client", + "cpuCount": 2, + "diskSizeMB": 1024, + "endAt": "2026-07-14T12:05:00Z", + "envdVersion": "0.1.3", + "memoryMB": 512, + "metadata": {"team": "alpha beta"}, + "sandboxID": SANDBOX_ID, + "startedAt": "2026-07-14T12:00:00Z", + "state": "running", + "templateID": "fixture-template", + "volumeMounts": [{"name": VOLUME_NAME, "path": "/mnt/data"}], + } + + +def volume_entry(path: str, entry_type: str, size: int, mode: int) -> dict[str, Any]: + return { + "name": path.rsplit("/", 1)[-1] or "/", + "type": entry_type, + "path": path, + "size": size, + "mode": mode, + "uid": 0, + "gid": 0, + "atime": "2026-07-14T12:00:00Z", + "mtime": "2026-07-14T12:00:00Z", + "ctime": "2026-07-14T12:00:00Z", + } + + +class FixtureHandler(BaseHTTPRequestHandler): + """Capture stable request fields and implement the lifecycle fixture.""" + + capture_path: ClassVar[Path] + client_name: ClassVar[str] + capture_lock: ClassVar[threading.Lock] = threading.Lock() + create_count: ClassVar[int] = 0 + sandbox_paused: ClassVar[bool] = False + snapshot_exists: ClassVar[bool] = False + + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + self._handle() + + def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + self._handle() + + def do_DELETE(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + self._handle() + + def do_PATCH(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + self._handle() + + def do_PUT(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API + self._handle() + + def log_message(self, _format: str, *args: object) -> None: + del args + + def _handle(self) -> None: + parsed = urllib.parse.urlsplit(self.path) + body = self._read_body() + self._capture(parsed, body) + + path = urllib.parse.unquote(parsed.path) + query = urllib.parse.parse_qs(parsed.query) + requested_path = query.get("path", [None])[0] + if self.command == "POST" and path == "/volumes": + self._json( + HTTPStatus.CREATED, + {"volumeID": VOLUME_ID, "name": VOLUME_NAME, "token": VOLUME_TOKEN}, + ) + elif self.command == "GET" and path == "/volumes": + self._json(HTTPStatus.OK, [{"volumeID": VOLUME_ID, "name": VOLUME_NAME}]) + elif self.command == "GET" and path == f"/volumes/{VOLUME_ID}": + self._json( + HTTPStatus.OK, + {"volumeID": VOLUME_ID, "name": VOLUME_NAME, "token": VOLUME_TOKEN}, + ) + elif self.command == "DELETE" and path == f"/volumes/{VOLUME_ID}": + self._empty(HTTPStatus.NO_CONTENT) + elif self.command == "POST" and path == f"/volumecontent/{VOLUME_ID}/dir": + self._json( + HTTPStatus.CREATED, + volume_entry(requested_path or "/nested", "directory", 0, 0o755), + ) + elif self.command == "PUT" and path == f"/volumecontent/{VOLUME_ID}/file": + self._json( + HTTPStatus.CREATED, + volume_entry( + requested_path or "/nested/value.txt", + "file", + len(VOLUME_CONTENT), + 0o644, + ), + ) + elif self.command == "GET" and path == f"/volumecontent/{VOLUME_ID}/path": + self._json( + HTTPStatus.OK, + volume_entry( + requested_path or "/nested/value.txt", + "file", + len(VOLUME_CONTENT), + 0o644, + ), + ) + elif self.command == "PATCH" and path == f"/volumecontent/{VOLUME_ID}/path": + self._json( + HTTPStatus.OK, + volume_entry( + requested_path or "/nested/value.txt", + "file", + len(VOLUME_CONTENT), + 0o600, + ), + ) + elif self.command == "GET" and path == f"/volumecontent/{VOLUME_ID}/dir": + self._json( + HTTPStatus.OK, + [ + volume_entry("/nested", "directory", 0, 0o755), + volume_entry( + "/nested/value.txt", + "file", + len(VOLUME_CONTENT), + 0o600, + ), + ], + ) + elif self.command == "GET" and path == f"/volumecontent/{VOLUME_ID}/file": + self._bytes(HTTPStatus.OK, VOLUME_CONTENT.encode()) + elif self.command == "DELETE" and path == f"/volumecontent/{VOLUME_ID}/path": + self._empty(HTTPStatus.NO_CONTENT) + elif self.command == "POST" and path == "/sandboxes": + with self.capture_lock: + self.__class__.create_count += 1 + sandbox_id = ( + SANDBOX_ID + if self.create_count == 1 + else RESTORED_SANDBOX_ID + if self.create_count == 2 + else INTERPRETER_SANDBOX_ID + ) + self._json(HTTPStatus.CREATED, sandbox_response(sandbox_id)) + elif self.command == "POST" and path == f"/sandboxes/{SANDBOX_ID}/snapshots": + with self.capture_lock: + self.__class__.snapshot_exists = True + self._json( + HTTPStatus.CREATED, + {"snapshotID": SNAPSHOT_ID, "names": [SNAPSHOT_ID]}, + ) + elif self.command == "GET" and path == "/snapshots": + with self.capture_lock: + exists = self.__class__.snapshot_exists + self._json( + HTTPStatus.OK, + [{"snapshotID": SNAPSHOT_ID, "names": [SNAPSHOT_ID]}] + if exists + else [], + ) + elif self.command == "DELETE" and path == f"/templates/{SNAPSHOT_ID}": + with self.capture_lock: + exists = self.__class__.snapshot_exists + self.__class__.snapshot_exists = False + if exists: + self._empty(HTTPStatus.NO_CONTENT) + else: + self._json( + HTTPStatus.NOT_FOUND, + {"code": 404, "message": "Snapshot not found"}, + ) + elif self.command == "POST" and path == f"/sandboxes/{SANDBOX_ID}/pause": + with self.capture_lock: + already_paused = self.__class__.sandbox_paused + self.__class__.sandbox_paused = True + if already_paused: + self._json( + HTTPStatus.CONFLICT, + {"code": 409, "message": "Sandbox lifecycle conflict"}, + ) + else: + self._empty(HTTPStatus.NO_CONTENT) + elif self.command == "POST" and path == f"/sandboxes/{SANDBOX_ID}/connect": + with self.capture_lock: + was_paused = self.__class__.sandbox_paused + self.__class__.sandbox_paused = False + self._json( + HTTPStatus.CREATED if was_paused else HTTPStatus.OK, + sandbox_response(SANDBOX_ID), + ) + elif self.command == "GET" and path == "/v2/sandboxes": + self._json(HTTPStatus.OK, [listed_sandbox()]) + elif self.command == "POST" and path == f"/sandboxes/{SANDBOX_ID}/timeout": + self._empty(HTTPStatus.NO_CONTENT) + elif self.command == "DELETE" and path in { + f"/sandboxes/{SANDBOX_ID}", + f"/sandboxes/{RESTORED_SANDBOX_ID}", + f"/sandboxes/{INTERPRETER_SANDBOX_ID}", + }: + self._empty(HTTPStatus.NO_CONTENT) + elif MISSING_SANDBOX_ID in path: + self._json( + HTTPStatus.NOT_FOUND, + {"code": 404, "message": "Sandbox not found"}, + ) + else: + self._json( + HTTPStatus.NOT_FOUND, + {"code": 404, "message": f"Unexpected fixture route {path}"}, + ) + + def _read_body(self) -> Any: + transfer_encoding = self.headers.get("Transfer-Encoding", "") + if "chunked" in transfer_encoding.lower(): + raw = self._read_chunked_body() + else: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) if length else b"" + if not raw: + return None + content_type = self.headers.get("Content-Type", "") + if "json" in content_type: + return json.loads(raw) + return raw.decode("utf-8") + + def _read_chunked_body(self) -> bytes: + body = bytearray() + while True: + size_line = self.rfile.readline() + if not size_line: + raise EOFError("request ended before the next chunk size") + size_text = size_line.split(b";", 1)[0].strip() + try: + size = int(size_text, 16) + except ValueError as error: + raise ValueError(f"invalid HTTP chunk size: {size_text!r}") from error + if size == 0: + while True: + trailer = self.rfile.readline() + if trailer in (b"\r\n", b"\n"): + return bytes(body) + if not trailer: + raise EOFError("request ended inside chunk trailers") + chunk = self.rfile.read(size) + if len(chunk) != size: + raise EOFError(f"request chunk ended after {len(chunk)} of {size} bytes") + if self.rfile.read(2) != b"\r\n": + raise ValueError("request chunk is missing its CRLF terminator") + body.extend(chunk) + + def _capture(self, parsed: urllib.parse.SplitResult, body: Any) -> None: + selected_headers = {} + for name in [ + "authorization", + "content-type", + "user-agent", + "x-api-key", + "x-supabase-team", + "x-supabase-token", + ]: + value = self.headers.get(name) + if value is not None: + selected_headers[name] = value + record = { + "body": body, + "client": self.client_name, + "headers": selected_headers, + "method": self.command, + "path": parsed.path, + "query": sorted( + [list(item) for item in urllib.parse.parse_qsl(parsed.query, True)] + ), + } + encoded = json.dumps(record, sort_keys=True, separators=(",", ":")) + with self.capture_lock: + with self.capture_path.open("a", encoding="utf-8") as capture: + capture.write(encoded) + capture.write("\n") + + def _json(self, status: HTTPStatus, body: Any) -> None: + encoded = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def _empty(self, status: HTTPStatus) -> None: + self.send_response(status) + self.send_header("Content-Length", "0") + self.end_headers() + + def _bytes(self, status: HTTPStatus, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--capture", type=Path, required=True) + parser.add_argument("--client", required=True) + parser.add_argument("--port-file", type=Path, required=True) + args = parser.parse_args() + + FixtureHandler.capture_path = args.capture + FixtureHandler.client_name = args.client + server = ThreadingHTTPServer(("127.0.0.1", 0), FixtureHandler) + args.port_file.write_text(str(server.server_port), encoding="utf-8") + + def stop(_signal: int, _frame: object) -> None: + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, stop) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/compat/e2b/fixtures/official-clients/production_python_client.py b/compat/e2b/fixtures/official-clients/production_python_client.py new file mode 100644 index 00000000..6d65326c --- /dev/null +++ b/compat/e2b/fixtures/official-clients/production_python_client.py @@ -0,0 +1,893 @@ +#!/usr/bin/env python3 +"""Exercise unchanged official Python clients against a production service.""" + +from __future__ import annotations + +import argparse +import asyncio +import datetime +import os +from typing import Any + +from e2b.sandbox.commands.command_handle import PtySize + +NATIVE_SDK = os.environ.get("A3S_BOX_NATIVE_SDK") == "1" + +if NATIVE_SDK: + from a3s_box import ( # type: ignore[import-not-found] + A3SConnectionConfig, + AsyncSandbox, + AsyncVolume, + Sandbox, + SandboxException, + SandboxNotFoundException, + SandboxQuery, + SandboxState, + Volume, + VolumeException, + ) + from a3s_box.code_interpreter import ( # type: ignore[import-not-found] + AsyncSandbox as AsyncCodeInterpreter, + ) + from a3s_box.code_interpreter import ( # type: ignore[import-not-found] + Sandbox as CodeInterpreter, + ) +else: + from e2b import ( + AsyncSandbox, + AsyncVolume, + Sandbox, + SandboxException, + SandboxNotFoundException, + SandboxQuery, + SandboxState, + Volume, + VolumeException, + ) + from e2b_code_interpreter import AsyncSandbox as AsyncCodeInterpreter + from e2b_code_interpreter import Sandbox as CodeInterpreter + + +def connection(api_url: str, domain: str) -> dict[str, Any]: + if NATIVE_SDK: + return A3SConnectionConfig.from_environment().python_options() # type: ignore[name-defined] + api_key = os.environ.get("E2B_API_KEY") + if not api_key: + raise RuntimeError("E2B_API_KEY is required") + return {"api_key": api_key, "api_url": api_url, "domain": domain} + + +def volume_connection(api_url: str) -> dict[str, Any]: + if NATIVE_SDK: + return A3SConnectionConfig.from_environment().volume_options() # type: ignore[name-defined] + return {"api_url": api_url} + + +def assert_listed(items: list[Any], sandbox_id: str) -> Any: + for item in items: + if item.sandbox_id == sandbox_id: + return item + raise AssertionError(f"sandbox {sandbox_id} was absent from the filtered list") + + +def assert_volume_mount(item: Any, name: str, path: str) -> None: + mounts = item.volume_mounts or [] + if not any( + mount.get("name") == name and mount.get("path") == path for mount in mounts + ): + raise AssertionError( + f"sandbox list omitted volume mount {name}:{path}: {mounts!r}" + ) + + +def trace(label: str, stage: str) -> None: + print(f"{label}:{stage}", flush=True) + + +def assert_metrics(metrics: list[Any], label: str) -> None: + if not metrics: + raise AssertionError(f"{label} metrics were empty") + metric = metrics[0] + for field in ( + "timestamp", + "cpu_count", + "cpu_used_pct", + "mem_used", + "mem_total", + "disk_used", + "disk_total", + ): + if getattr(metric, field, None) is None: + raise AssertionError(f"{label} metric omitted {field}: {metric!r}") + + +def exercise_sync_data_plane(sandbox: Sandbox, label: str) -> None: + root = f"a3s-runtime-{label}" + original = f"{root}/nested/original.txt" + renamed = f"{root}/nested/renamed.txt" + content = f"{label}-filesystem" + + trace(label, "filesystem.remove-initial") + sandbox.files.remove(root) + trace(label, "filesystem.make-dir") + if not sandbox.files.make_dir(f"{root}/nested"): + raise AssertionError("fresh nested directory was reported as pre-existing") + trace(label, "filesystem.write") + written = sandbox.files.write(original, content) + if written.path != f"/home/user/{original}": + raise AssertionError(f"unexpected written path: {written.path}") + trace(label, "filesystem.read") + if sandbox.files.read(original) != content: + raise AssertionError("filesystem read did not return the written content") + trace(label, "filesystem.get-info") + info = sandbox.files.get_info(original) + if info.name != "original.txt" or info.path != f"/home/user/{original}": + raise AssertionError(f"unexpected filesystem stat result: {info!r}") + trace(label, "filesystem.list") + entries = sandbox.files.list(root, depth=2) + if not any(entry.path == f"/home/user/{original}" for entry in entries): + raise AssertionError("filesystem list omitted the written file") + trace(label, "filesystem.rename") + moved = sandbox.files.rename(original, renamed) + if moved.path != f"/home/user/{renamed}": + raise AssertionError(f"unexpected renamed path: {moved.path}") + trace(label, "filesystem.exists-renamed") + if sandbox.files.exists(original) or not sandbox.files.exists(renamed): + raise AssertionError("filesystem rename did not move the file") + trace(label, "filesystem.remove-final") + sandbox.files.remove(root) + trace(label, "filesystem.exists-final") + if sandbox.files.exists(root): + raise AssertionError("filesystem remove left the directory behind") + + payload = f"{label}-stdin" + trace(label, "process.start-background") + command = sandbox.commands.run("cat", background=True, stdin=True, timeout=20) + trace(label, "process.list") + if not any(process.pid == command.pid for process in sandbox.commands.list()): + raise AssertionError("background command was absent from process list") + trace(label, "process.send-stdin") + command.send_stdin(payload) + trace(label, "process.close-stdin") + command.close_stdin() + trace(label, "process.wait") + result = command.wait() + if result.exit_code != 0 or result.stdout != payload or result.stderr: + raise AssertionError(f"unexpected background command result: {result!r}") + + output: list[bytes] = [] + trace(label, "pty.create") + terminal = sandbox.pty.create(PtySize(cols=80, rows=24), timeout=20) + trace(label, "pty.resize") + sandbox.pty.resize(terminal.pid, PtySize(cols=100, rows=30)) + trace(label, "pty.send-stdin") + sandbox.pty.send_stdin( + terminal.pid, + f"printf '{label}-pty:'; stty size; exit\n".encode(), + ) + trace(label, "pty.wait") + terminal_result = terminal.wait(on_pty=output.append) + terminal_output = b"".join(output).decode("utf-8", errors="replace") + if terminal_result.exit_code != 0 or f"{label}-pty:" not in terminal_output: + raise AssertionError(f"unexpected PTY output: {terminal_output!r}") + if "30 100" not in terminal_output: + raise AssertionError(f"PTY resize was not observable: {terminal_output!r}") + trace(label, "data-plane.complete") + + +async def exercise_async_data_plane(sandbox: AsyncSandbox, label: str) -> None: + root = f"a3s-runtime-{label}" + original = f"{root}/nested/original.txt" + renamed = f"{root}/nested/renamed.txt" + content = f"{label}-filesystem" + + trace(label, "filesystem.remove-initial") + await sandbox.files.remove(root) + trace(label, "filesystem.make-dir") + if not await sandbox.files.make_dir(f"{root}/nested"): + raise AssertionError("fresh nested directory was reported as pre-existing") + trace(label, "filesystem.write") + written = await sandbox.files.write(original, content) + if written.path != f"/home/user/{original}": + raise AssertionError(f"unexpected written path: {written.path}") + trace(label, "filesystem.read") + if await sandbox.files.read(original) != content: + raise AssertionError("filesystem read did not return the written content") + trace(label, "filesystem.get-info") + info = await sandbox.files.get_info(original) + if info.name != "original.txt" or info.path != f"/home/user/{original}": + raise AssertionError(f"unexpected filesystem stat result: {info!r}") + trace(label, "filesystem.list") + entries = await sandbox.files.list(root, depth=2) + if not any(entry.path == f"/home/user/{original}" for entry in entries): + raise AssertionError("filesystem list omitted the written file") + trace(label, "filesystem.rename") + moved = await sandbox.files.rename(original, renamed) + if moved.path != f"/home/user/{renamed}": + raise AssertionError(f"unexpected renamed path: {moved.path}") + trace(label, "filesystem.exists-renamed") + if await sandbox.files.exists(original) or not await sandbox.files.exists(renamed): + raise AssertionError("filesystem rename did not move the file") + trace(label, "filesystem.remove-final") + await sandbox.files.remove(root) + trace(label, "filesystem.exists-final") + if await sandbox.files.exists(root): + raise AssertionError("filesystem remove left the directory behind") + + payload = f"{label}-stdin" + trace(label, "process.start-background") + command = await sandbox.commands.run( + "cat", background=True, stdin=True, timeout=20 + ) + trace(label, "process.list") + if not any( + process.pid == command.pid for process in await sandbox.commands.list() + ): + raise AssertionError("background command was absent from process list") + trace(label, "process.send-stdin") + await command.send_stdin(payload) + trace(label, "process.close-stdin") + await command.close_stdin() + trace(label, "process.wait") + result = await command.wait() + if result.exit_code != 0 or result.stdout != payload or result.stderr: + raise AssertionError(f"unexpected background command result: {result!r}") + + output: list[bytes] = [] + trace(label, "pty.create") + terminal = await sandbox.pty.create( + PtySize(cols=80, rows=24), on_data=output.append, timeout=20 + ) + trace(label, "pty.resize") + await sandbox.pty.resize(terminal.pid, PtySize(cols=100, rows=30)) + trace(label, "pty.send-stdin") + await sandbox.pty.send_stdin( + terminal.pid, + f"printf '{label}-pty:'; stty size; exit\n".encode(), + ) + trace(label, "pty.wait") + terminal_result = await terminal.wait() + terminal_output = b"".join(output).decode("utf-8", errors="replace") + if terminal_result.exit_code != 0 or f"{label}-pty:" not in terminal_output: + raise AssertionError(f"unexpected PTY output: {terminal_output!r}") + if "30 100" not in terminal_output: + raise AssertionError(f"PTY resize was not observable: {terminal_output!r}") + trace(label, "data-plane.complete") + + +def exercise_sync_interpreter(interpreter: CodeInterpreter, label: str) -> None: + trace(label, "interpreter.run") + execution = interpreter.run_code(f"print('{label}-code')\n6 * 7") + if execution.text != "42" or not any( + f"{label}-code" in line for line in execution.logs.stdout + ): + raise AssertionError(f"unexpected Code Interpreter result: {execution!r}") + + trace(label, "interpreter.context-create") + context = interpreter.create_code_context(language="python") + trace(label, "interpreter.context-list") + if not any(item.id == context.id for item in interpreter.list_code_contexts()): + raise AssertionError("created Code Interpreter context was not listed") + trace(label, "interpreter.context-run") + contextual = interpreter.run_code("value = 41\nvalue + 1", context=context) + if contextual.text != "42": + raise AssertionError(f"unexpected contextual execution: {contextual!r}") + trace(label, "interpreter.context-restart") + interpreter.restart_code_context(context.id) + trace(label, "interpreter.context-run-restarted") + restarted = interpreter.run_code("value", context=context) + if restarted.error is None or restarted.error.name != "NameError": + raise AssertionError("restarted context retained its previous variables") + trace(label, "interpreter.context-remove") + interpreter.remove_code_context(context.id) + trace(label, "interpreter.context-list-removed") + if any(item.id == context.id for item in interpreter.list_code_contexts()): + raise AssertionError("removed Code Interpreter context remained listed") + trace(label, "interpreter.complete") + + +async def exercise_async_interpreter( + interpreter: AsyncCodeInterpreter, label: str +) -> None: + trace(label, "interpreter.run") + execution = await interpreter.run_code(f"print('{label}-code')\n6 * 7") + if execution.text != "42" or not any( + f"{label}-code" in line for line in execution.logs.stdout + ): + raise AssertionError(f"unexpected Code Interpreter result: {execution!r}") + + trace(label, "interpreter.context-create") + context = await interpreter.create_code_context(language="python") + trace(label, "interpreter.context-list") + if not any( + item.id == context.id for item in await interpreter.list_code_contexts() + ): + raise AssertionError("created Code Interpreter context was not listed") + trace(label, "interpreter.context-run") + contextual = await interpreter.run_code("value = 41\nvalue + 1", context=context) + if contextual.text != "42": + raise AssertionError(f"unexpected contextual execution: {contextual!r}") + trace(label, "interpreter.context-restart") + await interpreter.restart_code_context(context.id) + trace(label, "interpreter.context-run-restarted") + restarted = await interpreter.run_code("value", context=context) + if restarted.error is None or restarted.error.name != "NameError": + raise AssertionError("restarted context retained its previous variables") + trace(label, "interpreter.context-remove") + await interpreter.remove_code_context(context.id) + trace(label, "interpreter.context-list-removed") + if any(item.id == context.id for item in await interpreter.list_code_contexts()): + raise AssertionError("removed Code Interpreter context remained listed") + trace(label, "interpreter.complete") + + +def run_sync(api_url: str, domain: str, template: str) -> None: + label = "python-sync" + options = connection(api_url, domain) + volume_options = volume_connection(api_url) + volume_name = f"{'a3s' if NATIVE_SDK else 'official'}-{label}-volume" + metadata = {"client": "python-sync", "suite": "production-official"} + sandbox: Sandbox | None = None + restored: Sandbox | None = None + interpreter: CodeInterpreter | None = None + volume: Volume | None = None + snapshot_id: str | None = None + try: + trace(label, "volume.create") + volume = Volume.create(volume_name, **options) + if volume.name != volume_name or not volume.volume_id or not volume.token: + raise AssertionError(f"unexpected created Volume: {volume!r}") + trace(label, "volume.connect") + connected_volume = Volume.connect(volume.volume_id, **options) + if connected_volume.name != volume_name: + raise AssertionError(f"unexpected connected Volume: {connected_volume!r}") + trace(label, "volume.list") + if not any(item.volume_id == volume.volume_id for item in Volume.list(**options)): + raise AssertionError("created Volume was absent from the owner-scoped list") + trace(label, "volume.make-dir") + volume.make_dir( + "/shared", + uid=1000, + gid=1000, + mode=0o777, + force=True, + **volume_options, + ) + api_content = f"{label}-api-to-sandbox" + trace(label, "volume.api-write") + volume.write_file( + "/shared/from-api.txt", + api_content, + uid=1000, + gid=1000, + mode=0o644, + **volume_options, + ) + + trace(label, "sandbox.create") + sandbox = Sandbox.create( + template, + timeout=60, + metadata=metadata, + envs={"OFFICIAL_CLIENT": "python-sync"}, + secure=True, + allow_internet_access=False, + volume_mounts={"/mnt/data": volume}, + **options, + ) + trace(label, "sandbox.connect") + connected = Sandbox.connect(sandbox.sandbox_id, timeout=45, **options) + if connected.sandbox_id != sandbox.sandbox_id: + raise AssertionError("connect returned a different sandbox ID") + trace(label, "sandbox.health") + if not sandbox.is_running(): + raise AssertionError("envd health reported the running sandbox as stopped") + trace(label, "sandbox.metrics") + assert_metrics(sandbox.get_metrics(), label) + trace(label, "sandbox.metrics-past-range") + if sandbox.get_metrics( + start=datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc), + end=datetime.datetime(1970, 1, 2, tzinfo=datetime.timezone.utc), + ): + raise AssertionError("past metrics range returned current samples") + trace(label, "process.foreground") + result = sandbox.commands.run( + "printf 'python-sync:%s' \"$OFFICIAL_CLIENT\"" + ) + if result.stdout != "python-sync:python-sync" or result.stderr: + raise AssertionError(f"unexpected sync command result: {result!r}") + trace(label, "process.foreground.complete") + trace(label, "volume.sandbox-read") + mounted = sandbox.commands.run("cat /mnt/data/shared/from-api.txt") + if mounted.stdout != api_content or mounted.stderr: + raise AssertionError(f"Sandbox did not read API Volume content: {mounted!r}") + trace(label, "volume.sandbox-stat") + ownership = sandbox.commands.run( + "stat -c '%u:%g' /mnt/data/shared/from-api.txt" + ) + if ownership.stdout.strip() != "1000:1000": + raise AssertionError(f"API Volume ownership was not mapped: {ownership!r}") + identity = sandbox.commands.run("printf '%s:%s' \"$(id -u)\" \"$(id -g)\"") + sandbox_uid, sandbox_gid = (int(value) for value in identity.stdout.split(":")) + sandbox_content = f"{label}-sandbox-to-api" + trace(label, "volume.sandbox-write") + sandbox.commands.run( + f"printf '%s' '{sandbox_content}' > /mnt/data/shared/from-sandbox.txt" + ) + trace(label, "volume.api-read") + if ( + volume.read_file("/shared/from-sandbox.txt", **volume_options) + != sandbox_content + ): + raise AssertionError("Volume API did not read Sandbox-written content") + sandbox_entry = volume.get_info( + "/shared/from-sandbox.txt", **volume_options + ) + if sandbox_entry.uid != sandbox_uid or sandbox_entry.gid != sandbox_gid: + raise AssertionError( + "Sandbox Volume ownership did not round trip through the API: " + f"{sandbox_entry!r} versus {sandbox_uid}:{sandbox_gid}" + ) + trace(label, "volume.destroy-in-use") + try: + Volume.destroy(volume.volume_id, **options) + except VolumeException as error: + if "in use" not in str(error).lower(): + raise AssertionError(f"unexpected in-use Volume error: {error}") from error + else: + raise AssertionError("mounted Volume was destroyed while Sandbox was running") + exercise_sync_data_plane(sandbox, label) + + trace(label, "sandbox.pause-process-start") + survivor = sandbox.commands.run("cat", background=True, stdin=True, timeout=20) + trace(label, "sandbox.pause") + if not sandbox.pause(keep_memory=True): + raise AssertionError("running sandbox was reported as already paused") + trace(label, "sandbox.pause-idempotent") + if sandbox.pause(keep_memory=True): + raise AssertionError("second pause did not report the paused state") + trace(label, "sandbox.list-paused") + paused = Sandbox.list( + query=SandboxQuery(metadata=metadata, state=[SandboxState.PAUSED]), + limit=20, + **options, + ) + assert_listed(paused.next_items(), sandbox.sandbox_id) + trace(label, "sandbox.resume-connect") + resumed = sandbox.connect(timeout=45) + if resumed.sandbox_id != sandbox.sandbox_id: + raise AssertionError("resume returned a different sandbox ID") + trace(label, "sandbox.pause-process-survived") + survivor.send_stdin(f"{label}-pause") + survivor.close_stdin() + survivor_result = survivor.wait() + if survivor_result.exit_code != 0 or survivor_result.stdout != f"{label}-pause": + raise AssertionError( + f"memory-preserving pause lost the running process: {survivor_result!r}" + ) + + trace(label, "sandbox.list") + paginator = Sandbox.list( + query=SandboxQuery(metadata=metadata, state=[SandboxState.RUNNING]), + limit=20, + **options, + ) + listed = assert_listed(paginator.next_items(), sandbox.sandbox_id) + assert_volume_mount(listed, volume_name, "/mnt/data") + + snapshot_content = f"{'a3s' if NATIVE_SDK else 'official'}-{label}-snapshot" + trace(label, "snapshot.write-state") + sandbox.files.write("a3s-snapshot-state.txt", snapshot_content) + snapshot_metadata = sandbox.commands.run( + "stat -c '%u:%g:%a' /home/user/a3s-snapshot-state.txt" + ).stdout.strip() + trace(label, "snapshot.create") + snapshot = sandbox.create_snapshot( + name=f"{'a3s' if NATIVE_SDK else 'official'}-{label}-state" + ) + snapshot_id = snapshot.snapshot_id + if not snapshot_id or snapshot.names != [snapshot_id]: + raise AssertionError(f"unexpected created Snapshot: {snapshot!r}") + trace(label, "snapshot.list") + snapshots = sandbox.list_snapshots(limit=20).next_items() + if not any(item.snapshot_id == snapshot_id for item in snapshots): + raise AssertionError("created Snapshot was absent from the source-scoped list") + trace(label, "snapshot.source-running") + if not sandbox.is_running(): + raise AssertionError("Snapshot did not restore the running source state") + + trace(label, "sandbox.set-timeout") + sandbox.set_timeout(30) + trace(label, "sandbox.kill") + if not sandbox.kill(): + raise AssertionError("kill did not terminate the production sandbox") + trace(label, "sandbox.health-killed") + if sandbox.is_running(): + raise AssertionError("envd health reported the killed sandbox as running") + + trace(label, "snapshot.restore-after-source-kill") + restored = Sandbox.create(snapshot_id, timeout=60, **options) + trace(label, "snapshot.read-restored-state") + if restored.files.read("a3s-snapshot-state.txt") != snapshot_content: + raise AssertionError("restored Sandbox lost the captured filesystem state") + restored_metadata = restored.commands.run( + "stat -c '%u:%g:%a' /home/user/a3s-snapshot-state.txt" + ).stdout.strip() + if restored_metadata != snapshot_metadata: + raise AssertionError( + "restored Snapshot changed file ownership or mode: " + f"{snapshot_metadata!r} -> {restored_metadata!r}" + ) + restored.commands.run( + "printf '%s' '-writable' >> /home/user/a3s-snapshot-state.txt" + ) + trace(label, "snapshot.delete-in-use") + try: + Sandbox.delete_snapshot(snapshot_id, **options) + except SandboxException as error: + if "409" not in str(error): + raise AssertionError( + f"unexpected in-use Snapshot error: {error}" + ) from error + else: + raise AssertionError("Snapshot was deleted while a restored Sandbox used it") + trace(label, "snapshot.restored-kill") + if not restored.kill(): + raise AssertionError("restored Sandbox did not terminate") + restored = None + trace(label, "snapshot.delete") + if not Sandbox.delete_snapshot(snapshot_id, **options): + raise AssertionError("detached Snapshot was not deleted") + trace(label, "snapshot.delete-missing") + if Sandbox.delete_snapshot(snapshot_id, **options): + raise AssertionError("missing Snapshot deletion reported success") + snapshot_id = None + + trace(label, "volume.destroy") + if not Volume.destroy(volume.volume_id, **options): + raise AssertionError("detached Volume was not destroyed") + volume = None + + missing_id = "missing-production-python-sync" + trace(label, "sandbox.kill-missing") + if Sandbox.kill(missing_id, **options): + raise AssertionError("kill reported success for a missing sandbox") + trace(label, "sandbox.connect-missing") + try: + Sandbox.connect(missing_id, **options) + except SandboxNotFoundException: + pass + else: + raise AssertionError("missing sandbox connect did not raise not-found") + + trace(label, "interpreter.create") + interpreter = CodeInterpreter.create( + timeout=60, + metadata={"client": "python-code-interpreter"}, + **options, + ) + trace(label, "interpreter.health") + if not interpreter.is_running(): + raise AssertionError("Code Interpreter envd health check failed") + exercise_sync_interpreter(interpreter, label) + trace(label, "interpreter.kill") + if not interpreter.kill(): + raise AssertionError("Code Interpreter lifecycle kill failed") + trace(label, "interpreter.health-killed") + if interpreter.is_running(): + raise AssertionError("Code Interpreter remained running after kill") + trace(label, "complete") + finally: + try: + if interpreter is not None: + Sandbox.kill(interpreter.sandbox_id, **options) + if restored is not None: + Sandbox.kill(restored.sandbox_id, **options) + if sandbox is not None: + Sandbox.kill(sandbox.sandbox_id, **options) + if snapshot_id is not None: + Sandbox.delete_snapshot(snapshot_id, **options) + finally: + if volume is not None: + Volume.destroy(volume.volume_id, **options) + + +async def run_async(api_url: str, domain: str, template: str) -> None: + label = "python-async" + options = connection(api_url, domain) + volume_options = volume_connection(api_url) + volume_name = f"{'a3s' if NATIVE_SDK else 'official'}-{label}-volume" + metadata = {"client": "python-async", "suite": "production-official"} + sandbox: AsyncSandbox | None = None + restored: AsyncSandbox | None = None + interpreter: AsyncCodeInterpreter | None = None + volume: AsyncVolume | None = None + snapshot_id: str | None = None + try: + trace(label, "volume.create") + volume = await AsyncVolume.create(volume_name, **options) + if volume.name != volume_name or not volume.volume_id or not volume.token: + raise AssertionError(f"unexpected created Volume: {volume!r}") + trace(label, "volume.connect") + connected_volume = await AsyncVolume.connect(volume.volume_id, **options) + if connected_volume.name != volume_name: + raise AssertionError(f"unexpected connected Volume: {connected_volume!r}") + trace(label, "volume.list") + if not any( + item.volume_id == volume.volume_id + for item in await AsyncVolume.list(**options) + ): + raise AssertionError("created Volume was absent from the owner-scoped list") + trace(label, "volume.make-dir") + await volume.make_dir( + "/shared", + uid=1000, + gid=1000, + mode=0o777, + force=True, + **volume_options, + ) + api_content = f"{label}-api-to-sandbox" + trace(label, "volume.api-write") + await volume.write_file( + "/shared/from-api.txt", + api_content, + uid=1000, + gid=1000, + mode=0o644, + **volume_options, + ) + + trace(label, "sandbox.create") + sandbox = await AsyncSandbox.create( + template, + timeout=60, + metadata=metadata, + envs={"OFFICIAL_CLIENT": "python-async"}, + secure=True, + allow_internet_access=False, + volume_mounts={"/mnt/data": volume}, + **options, + ) + trace(label, "sandbox.connect") + connected = await AsyncSandbox.connect( + sandbox.sandbox_id, timeout=45, **options + ) + if connected.sandbox_id != sandbox.sandbox_id: + raise AssertionError("connect returned a different sandbox ID") + trace(label, "sandbox.health") + if not await sandbox.is_running(): + raise AssertionError("envd health reported the running sandbox as stopped") + trace(label, "sandbox.metrics") + assert_metrics(await sandbox.get_metrics(), label) + trace(label, "sandbox.metrics-past-range") + if await sandbox.get_metrics( + start=datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc), + end=datetime.datetime(1970, 1, 2, tzinfo=datetime.timezone.utc), + ): + raise AssertionError("past metrics range returned current samples") + trace(label, "process.foreground") + result = await sandbox.commands.run( + "printf 'python-async:%s' \"$OFFICIAL_CLIENT\"" + ) + if result.stdout != "python-async:python-async" or result.stderr: + raise AssertionError(f"unexpected async command result: {result!r}") + trace(label, "process.foreground.complete") + trace(label, "volume.sandbox-read") + mounted = await sandbox.commands.run("cat /mnt/data/shared/from-api.txt") + if mounted.stdout != api_content or mounted.stderr: + raise AssertionError(f"Sandbox did not read API Volume content: {mounted!r}") + trace(label, "volume.sandbox-stat") + ownership = await sandbox.commands.run( + "stat -c '%u:%g' /mnt/data/shared/from-api.txt" + ) + if ownership.stdout.strip() != "1000:1000": + raise AssertionError(f"API Volume ownership was not mapped: {ownership!r}") + identity = await sandbox.commands.run( + "printf '%s:%s' \"$(id -u)\" \"$(id -g)\"" + ) + sandbox_uid, sandbox_gid = (int(value) for value in identity.stdout.split(":")) + sandbox_content = f"{label}-sandbox-to-api" + trace(label, "volume.sandbox-write") + await sandbox.commands.run( + f"printf '%s' '{sandbox_content}' > /mnt/data/shared/from-sandbox.txt" + ) + trace(label, "volume.api-read") + if ( + await volume.read_file("/shared/from-sandbox.txt", **volume_options) + != sandbox_content + ): + raise AssertionError("Volume API did not read Sandbox-written content") + sandbox_entry = await volume.get_info( + "/shared/from-sandbox.txt", **volume_options + ) + if sandbox_entry.uid != sandbox_uid or sandbox_entry.gid != sandbox_gid: + raise AssertionError( + "Sandbox Volume ownership did not round trip through the API: " + f"{sandbox_entry!r} versus {sandbox_uid}:{sandbox_gid}" + ) + trace(label, "volume.destroy-in-use") + try: + await AsyncVolume.destroy(volume.volume_id, **options) + except VolumeException as error: + if "in use" not in str(error).lower(): + raise AssertionError(f"unexpected in-use Volume error: {error}") from error + else: + raise AssertionError("mounted Volume was destroyed while Sandbox was running") + await exercise_async_data_plane(sandbox, label) + + trace(label, "sandbox.pause-process-start") + survivor = await sandbox.commands.run( + "cat", background=True, stdin=True, timeout=20 + ) + trace(label, "sandbox.pause") + if not await sandbox.pause(keep_memory=True): + raise AssertionError("running sandbox was reported as already paused") + trace(label, "sandbox.pause-idempotent") + if await sandbox.pause(keep_memory=True): + raise AssertionError("second pause did not report the paused state") + trace(label, "sandbox.list-paused") + paused = AsyncSandbox.list( + query=SandboxQuery(metadata=metadata, state=[SandboxState.PAUSED]), + limit=20, + **options, + ) + assert_listed(await paused.next_items(), sandbox.sandbox_id) + trace(label, "sandbox.resume-connect") + resumed = await sandbox.connect(timeout=45) + if resumed.sandbox_id != sandbox.sandbox_id: + raise AssertionError("resume returned a different sandbox ID") + trace(label, "sandbox.pause-process-survived") + await survivor.send_stdin(f"{label}-pause") + await survivor.close_stdin() + survivor_result = await survivor.wait() + if survivor_result.exit_code != 0 or survivor_result.stdout != f"{label}-pause": + raise AssertionError( + f"memory-preserving pause lost the running process: {survivor_result!r}" + ) + + trace(label, "sandbox.list") + paginator = AsyncSandbox.list( + query=SandboxQuery(metadata=metadata, state=[SandboxState.RUNNING]), + limit=20, + **options, + ) + listed = assert_listed(await paginator.next_items(), sandbox.sandbox_id) + assert_volume_mount(listed, volume_name, "/mnt/data") + + snapshot_content = f"{'a3s' if NATIVE_SDK else 'official'}-{label}-snapshot" + trace(label, "snapshot.write-state") + await sandbox.files.write("a3s-snapshot-state.txt", snapshot_content) + snapshot_metadata = ( + await sandbox.commands.run( + "stat -c '%u:%g:%a' /home/user/a3s-snapshot-state.txt" + ) + ).stdout.strip() + trace(label, "snapshot.create") + snapshot = await sandbox.create_snapshot( + name=f"{'a3s' if NATIVE_SDK else 'official'}-{label}-state" + ) + snapshot_id = snapshot.snapshot_id + if not snapshot_id or snapshot.names != [snapshot_id]: + raise AssertionError(f"unexpected created Snapshot: {snapshot!r}") + trace(label, "snapshot.list") + snapshots = await sandbox.list_snapshots(limit=20).next_items() + if not any(item.snapshot_id == snapshot_id for item in snapshots): + raise AssertionError("created Snapshot was absent from the source-scoped list") + trace(label, "snapshot.source-running") + if not await sandbox.is_running(): + raise AssertionError("Snapshot did not restore the running source state") + + trace(label, "sandbox.set-timeout") + await sandbox.set_timeout(30) + trace(label, "sandbox.kill") + if not await sandbox.kill(): + raise AssertionError("kill did not terminate the production sandbox") + trace(label, "sandbox.health-killed") + if await sandbox.is_running(): + raise AssertionError("envd health reported the killed sandbox as running") + + trace(label, "snapshot.restore-after-source-kill") + restored = await AsyncSandbox.create(snapshot_id, timeout=60, **options) + trace(label, "snapshot.read-restored-state") + if await restored.files.read("a3s-snapshot-state.txt") != snapshot_content: + raise AssertionError("restored Sandbox lost the captured filesystem state") + restored_metadata = ( + await restored.commands.run( + "stat -c '%u:%g:%a' /home/user/a3s-snapshot-state.txt" + ) + ).stdout.strip() + if restored_metadata != snapshot_metadata: + raise AssertionError( + "restored Snapshot changed file ownership or mode: " + f"{snapshot_metadata!r} -> {restored_metadata!r}" + ) + await restored.commands.run( + "printf '%s' '-writable' >> /home/user/a3s-snapshot-state.txt" + ) + trace(label, "snapshot.delete-in-use") + try: + await AsyncSandbox.delete_snapshot(snapshot_id, **options) + except SandboxException as error: + if "409" not in str(error): + raise AssertionError( + f"unexpected in-use Snapshot error: {error}" + ) from error + else: + raise AssertionError("Snapshot was deleted while a restored Sandbox used it") + trace(label, "snapshot.restored-kill") + if not await restored.kill(): + raise AssertionError("restored Sandbox did not terminate") + restored = None + trace(label, "snapshot.delete") + if not await AsyncSandbox.delete_snapshot(snapshot_id, **options): + raise AssertionError("detached Snapshot was not deleted") + trace(label, "snapshot.delete-missing") + if await AsyncSandbox.delete_snapshot(snapshot_id, **options): + raise AssertionError("missing Snapshot deletion reported success") + snapshot_id = None + + trace(label, "volume.destroy") + if not await AsyncVolume.destroy(volume.volume_id, **options): + raise AssertionError("detached Volume was not destroyed") + volume = None + + missing_id = "missing-production-python-async" + trace(label, "sandbox.kill-missing") + if await AsyncSandbox.kill(missing_id, **options): + raise AssertionError("kill reported success for a missing sandbox") + trace(label, "sandbox.connect-missing") + try: + await AsyncSandbox.connect(missing_id, **options) + except SandboxNotFoundException: + pass + else: + raise AssertionError("missing sandbox connect did not raise not-found") + + trace(label, "interpreter.create") + interpreter = await AsyncCodeInterpreter.create( + timeout=60, + metadata={"client": "python-async-code-interpreter"}, + **options, + ) + trace(label, "interpreter.health") + if not await interpreter.is_running(): + raise AssertionError("async Code Interpreter envd health check failed") + await exercise_async_interpreter(interpreter, label) + trace(label, "interpreter.kill") + if not await interpreter.kill(): + raise AssertionError("Code Interpreter lifecycle kill failed") + trace(label, "interpreter.health-killed") + if await interpreter.is_running(): + raise AssertionError("async Code Interpreter remained running after kill") + trace(label, "complete") + finally: + try: + if interpreter is not None: + await AsyncSandbox.kill(interpreter.sandbox_id, **options) + if restored is not None: + await AsyncSandbox.kill(restored.sandbox_id, **options) + if sandbox is not None: + await AsyncSandbox.kill(sandbox.sandbox_id, **options) + if snapshot_id is not None: + await AsyncSandbox.delete_snapshot(snapshot_id, **options) + finally: + if volume is not None: + await AsyncVolume.destroy(volume.volume_id, **options) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=["sync", "async"]) + parser.add_argument("api_url") + parser.add_argument("domain") + parser.add_argument("template") + args = parser.parse_args() + if args.mode == "sync": + run_sync(args.api_url, args.domain, args.template) + else: + asyncio.run(run_async(args.api_url, args.domain, args.template)) + + +if __name__ == "__main__": + main() diff --git a/compat/e2b/fixtures/official-clients/production_typescript_client.mjs b/compat/e2b/fixtures/official-clients/production_typescript_client.mjs new file mode 100644 index 00000000..4873f01c --- /dev/null +++ b/compat/e2b/fixtures/official-clients/production_typescript_client.mjs @@ -0,0 +1,421 @@ +#!/usr/bin/env node +/** Exercise the unchanged official TypeScript clients against production. */ + +import assert from 'node:assert/strict' + +const nativeSdk = process.env.A3S_BOX_NATIVE_SDK === '1' +const baseSdk = await import(nativeSdk ? '@a3s-lab/box' : 'e2b') +const codeInterpreterSdk = await import( + nativeSdk ? '@a3s-lab/box/code-interpreter' : '@e2b/code-interpreter' +) +const { Sandbox, SandboxError, SandboxNotFoundError, Volume, VolumeError } = + baseSdk +const { Sandbox: CodeInterpreter } = codeInterpreterSdk + +const [apiUrl, domain, template] = process.argv.slice(2) +const apiKey = nativeSdk + ? process.env.A3S_BOX_API_KEY + : process.env.E2B_API_KEY +if (!apiUrl || !domain || !template || !apiKey) { + throw new Error('API URL, domain, template, and API key are required') +} + +const connection = nativeSdk + ? baseSdk.A3SConnectionConfig.fromEnvironment( + process.env + ).typescriptOptions() + : { apiKey, apiUrl, domain } +const volumeConnection = nativeSdk + ? baseSdk.A3SConnectionConfig.fromEnvironment(process.env).volumeOptions() + : { apiUrl } +const metadata = { client: 'typescript', suite: 'production-official' } +const clientLabel = `${nativeSdk ? 'a3s' : 'official'}-typescript` +const volumeName = `${clientLabel}-volume` +const trace = (stage) => console.log(`${clientLabel}:${stage}`) +let sandbox +let restored +let interpreter +let volume +let snapshotId + +async function exerciseDataPlane(sandbox, label) { + const root = `a3s-runtime-${label}` + const original = `${root}/nested/original.txt` + const renamed = `${root}/nested/renamed.txt` + const content = `${label}-filesystem` + + trace('filesystem.remove-initial') + await sandbox.files.remove(root) + trace('filesystem.make-dir') + assert.equal(await sandbox.files.makeDir(`${root}/nested`), true) + trace('filesystem.write') + const written = await sandbox.files.write(original, content) + assert.equal(written.path, `/home/user/${original}`) + trace('filesystem.read') + assert.equal(await sandbox.files.read(original), content) + trace('filesystem.get-info') + const info = await sandbox.files.getInfo(original) + assert.equal(info.name, 'original.txt') + assert.equal(info.path, `/home/user/${original}`) + trace('filesystem.list') + const entries = await sandbox.files.list(root, { depth: 2 }) + assert.ok(entries.some((entry) => entry.path === `/home/user/${original}`)) + trace('filesystem.rename') + const moved = await sandbox.files.rename(original, renamed) + assert.equal(moved.path, `/home/user/${renamed}`) + trace('filesystem.exists-renamed') + assert.equal(await sandbox.files.exists(original), false) + assert.equal(await sandbox.files.exists(renamed), true) + trace('filesystem.remove-final') + await sandbox.files.remove(root) + trace('filesystem.exists-final') + assert.equal(await sandbox.files.exists(root), false) + + const payload = `${label}-stdin` + trace('process.start-background') + const command = await sandbox.commands.run('cat', { + background: true, + stdin: true, + timeoutMs: 20_000, + }) + trace('process.list') + const processes = await sandbox.commands.list() + assert.ok(processes.some((process) => process.pid === command.pid)) + trace('process.send-stdin') + await command.sendStdin(payload) + trace('process.close-stdin') + await command.closeStdin() + trace('process.wait') + const result = await command.wait() + assert.equal(result.exitCode, 0) + assert.equal(result.stdout, payload) + assert.equal(result.stderr, '') + + let terminalOutput = '' + const decoder = new TextDecoder() + trace('pty.create') + const terminal = await sandbox.pty.create({ + cols: 80, + rows: 24, + onData: (data) => { + terminalOutput += decoder.decode(data) + }, + timeoutMs: 20_000, + }) + trace('pty.resize') + await sandbox.pty.resize(terminal.pid, { cols: 100, rows: 30 }) + trace('pty.send-input') + await sandbox.pty.sendInput( + terminal.pid, + new TextEncoder().encode(`printf '${label}-pty:'; stty size; exit\n`) + ) + trace('pty.wait') + await terminal.wait() + assert.equal(terminal.exitCode, 0) + assert.ok(terminalOutput.includes(`${label}-pty:`)) + assert.ok(terminalOutput.includes('30 100')) + trace('data-plane.complete') +} + +async function exerciseInterpreter(interpreter, label) { + trace('interpreter.run') + const execution = await interpreter.runCode(`print('${label}-code')\n6 * 7`) + assert.equal(execution.text, '42') + assert.ok(execution.logs.stdout.some((line) => line.includes(`${label}-code`))) + + trace('interpreter.context-create') + const context = await interpreter.createCodeContext({ language: 'python' }) + trace('interpreter.context-list') + let contexts = await interpreter.listCodeContexts() + assert.ok(contexts.some((item) => item.id === context.id)) + trace('interpreter.context-run') + const contextual = await interpreter.runCode('value = 41\nvalue + 1', { + context, + }) + assert.equal(contextual.text, '42') + trace('interpreter.context-restart') + await interpreter.restartCodeContext(context.id) + trace('interpreter.context-run-restarted') + const restarted = await interpreter.runCode('value', { context }) + assert.equal(restarted.error?.name, 'NameError') + trace('interpreter.context-remove') + await interpreter.removeCodeContext(context.id) + trace('interpreter.context-list-removed') + contexts = await interpreter.listCodeContexts() + assert.equal(contexts.some((item) => item.id === context.id), false) + trace('interpreter.complete') +} + +try { + trace('volume.create') + volume = await Volume.create(volumeName, connection) + assert.equal(volume.name, volumeName) + assert.ok(volume.volumeId) + assert.ok(volume.token) + trace('volume.connect') + const connectedVolume = await Volume.connect(volume.volumeId, connection) + assert.equal(connectedVolume.name, volumeName) + trace('volume.list') + assert.ok( + (await Volume.list(connection)).some( + (item) => item.volumeId === volume.volumeId + ) + ) + trace('volume.make-dir') + await volume.makeDir('/shared', { + ...volumeConnection, + uid: 1000, + gid: 1000, + mode: 0o777, + force: true, + }) + const apiVolumeContent = 'typescript-api-to-sandbox' + trace('volume.api-write') + await volume.writeFile('/shared/from-api.txt', apiVolumeContent, { + ...volumeConnection, + uid: 1000, + gid: 1000, + mode: 0o644, + }) + + trace('sandbox.create') + sandbox = await Sandbox.create(template, { + ...connection, + timeoutMs: 60_000, + metadata, + envs: { OFFICIAL_CLIENT: 'typescript' }, + secure: true, + allowInternetAccess: false, + volumeMounts: { '/mnt/data': volume }, + }) + trace('sandbox.connect') + const connected = await Sandbox.connect(sandbox.sandboxId, { + ...connection, + timeoutMs: 45_000, + }) + assert.equal(connected.sandboxId, sandbox.sandboxId) + trace('sandbox.health') + assert.equal(await sandbox.isRunning(), true) + trace('sandbox.metrics') + const metrics = await sandbox.getMetrics() + assert.ok(metrics.length > 0) + for (const field of [ + 'timestamp', + 'cpuCount', + 'cpuUsedPct', + 'memUsed', + 'memTotal', + 'diskUsed', + 'diskTotal', + ]) { + assert.notEqual(metrics[0][field], undefined) + } + trace('sandbox.metrics-past-range') + assert.deepEqual( + await sandbox.getMetrics({ + start: new Date('1970-01-01T00:00:00Z'), + end: new Date('1970-01-02T00:00:00Z'), + }), + [] + ) + trace('process.foreground') + const command = await sandbox.commands.run( + 'printf \'typescript:%s\' "$OFFICIAL_CLIENT"' + ) + assert.equal(command.stdout, 'typescript:typescript') + assert.equal(command.stderr, '') + trace('process.foreground.complete') + trace('volume.sandbox-read') + const mounted = await sandbox.commands.run( + 'cat /mnt/data/shared/from-api.txt' + ) + assert.equal(mounted.stdout, apiVolumeContent) + assert.equal(mounted.stderr, '') + trace('volume.sandbox-stat') + const ownership = await sandbox.commands.run( + "stat -c '%u:%g' /mnt/data/shared/from-api.txt" + ) + assert.equal(ownership.stdout.trim(), '1000:1000') + const identity = await sandbox.commands.run( + 'printf \'%s:%s\' "$(id -u)" "$(id -g)"' + ) + const [sandboxUid, sandboxGid] = identity.stdout + .split(':') + .map((value) => Number.parseInt(value, 10)) + const sandboxVolumeContent = 'typescript-sandbox-to-api' + trace('volume.sandbox-write') + await sandbox.commands.run( + `printf '%s' '${sandboxVolumeContent}' > /mnt/data/shared/from-sandbox.txt` + ) + trace('volume.api-read') + assert.equal( + await volume.readFile('/shared/from-sandbox.txt', volumeConnection), + sandboxVolumeContent + ) + const sandboxEntry = await volume.getInfo( + '/shared/from-sandbox.txt', + volumeConnection + ) + assert.equal(sandboxEntry.uid, sandboxUid) + assert.equal(sandboxEntry.gid, sandboxGid) + trace('volume.destroy-in-use') + await assert.rejects( + Volume.destroy(volume.volumeId, connection), + (error) => + error instanceof VolumeError && /in use/i.test(error.message) + ) + await exerciseDataPlane(sandbox, 'typescript') + + trace('sandbox.pause-process-start') + const survivor = await sandbox.commands.run('cat', { + background: true, + stdin: true, + timeoutMs: 20_000, + }) + trace('sandbox.pause') + assert.equal(await sandbox.pause({ keepMemory: true }), true) + trace('sandbox.pause-idempotent') + assert.equal(await sandbox.pause({ keepMemory: true }), false) + trace('sandbox.list-paused') + const pausedPaginator = Sandbox.list({ + ...connection, + query: { metadata, state: ['paused'] }, + limit: 20, + }) + const paused = await pausedPaginator.nextItems() + assert.ok(paused.some((item) => item.sandboxId === sandbox.sandboxId)) + trace('sandbox.resume-connect') + const resumed = await sandbox.connect({ timeoutMs: 45_000 }) + assert.equal(resumed.sandboxId, sandbox.sandboxId) + trace('sandbox.pause-process-survived') + await survivor.sendStdin('typescript-pause') + await survivor.closeStdin() + const survivorResult = await survivor.wait() + assert.equal(survivorResult.exitCode, 0) + assert.equal(survivorResult.stdout, 'typescript-pause') + + trace('sandbox.list') + const paginator = Sandbox.list({ + ...connection, + query: { metadata, state: ['running'] }, + limit: 20, + }) + const listed = await paginator.nextItems() + const listedSandbox = listed.find( + (item) => item.sandboxId === sandbox.sandboxId + ) + assert.ok(listedSandbox) + assert.ok( + listedSandbox.volumeMounts.some( + (mount) => mount.name === volumeName && mount.path === '/mnt/data' + ) + ) + + const snapshotContent = `${clientLabel}-snapshot` + trace('snapshot.write-state') + await sandbox.files.write('a3s-snapshot-state.txt', snapshotContent) + const snapshotMetadata = ( + await sandbox.commands.run( + "stat -c '%u:%g:%a' /home/user/a3s-snapshot-state.txt" + ) + ).stdout.trim() + trace('snapshot.create') + const snapshot = await sandbox.createSnapshot({ + name: `${clientLabel}-state`, + }) + snapshotId = snapshot.snapshotId + assert.ok(snapshotId) + assert.deepEqual(snapshot.names, [snapshotId]) + trace('snapshot.list') + const snapshots = await sandbox.listSnapshots({ limit: 20 }).nextItems() + assert.ok(snapshots.some((item) => item.snapshotId === snapshotId)) + trace('snapshot.source-running') + assert.equal(await sandbox.isRunning(), true) + + trace('sandbox.set-timeout') + await sandbox.setTimeout(30_000) + trace('sandbox.kill') + assert.equal(await sandbox.kill(), true) + trace('sandbox.health-killed') + assert.equal(await sandbox.isRunning(), false) + + trace('snapshot.restore-after-source-kill') + restored = await Sandbox.create(snapshotId, { + ...connection, + timeoutMs: 60_000, + }) + trace('snapshot.read-restored-state') + assert.equal( + await restored.files.read('a3s-snapshot-state.txt'), + snapshotContent + ) + const restoredMetadata = ( + await restored.commands.run( + "stat -c '%u:%g:%a' /home/user/a3s-snapshot-state.txt" + ) + ).stdout.trim() + assert.equal(restoredMetadata, snapshotMetadata) + await restored.commands.run( + "printf '%s' '-writable' >> /home/user/a3s-snapshot-state.txt" + ) + trace('snapshot.delete-in-use') + await assert.rejects( + Sandbox.deleteSnapshot(snapshotId, connection), + (error) => error instanceof SandboxError && /^409:/.test(error.message) + ) + trace('snapshot.restored-kill') + assert.equal(await restored.kill(), true) + restored = undefined + trace('snapshot.delete') + assert.equal(await Sandbox.deleteSnapshot(snapshotId, connection), true) + trace('snapshot.delete-missing') + assert.equal(await Sandbox.deleteSnapshot(snapshotId, connection), false) + snapshotId = undefined + + trace('volume.destroy') + assert.equal(await Volume.destroy(volume.volumeId, connection), true) + volume = undefined + + const missingId = 'missing-production-typescript' + trace('sandbox.kill-missing') + assert.equal(await Sandbox.kill(missingId, connection), false) + trace('sandbox.connect-missing') + await assert.rejects( + Sandbox.connect(missingId, connection), + SandboxNotFoundError + ) + + trace('interpreter.create') + interpreter = await CodeInterpreter.create({ + ...connection, + timeoutMs: 60_000, + metadata: { client: 'typescript-code-interpreter' }, + }) + trace('interpreter.health') + assert.equal(await interpreter.isRunning(), true) + await exerciseInterpreter(interpreter, 'typescript') + trace('interpreter.kill') + assert.equal(await interpreter.kill(), true) + trace('interpreter.health-killed') + assert.equal(await interpreter.isRunning(), false) + trace('complete') +} finally { + try { + if (interpreter) { + await Sandbox.kill(interpreter.sandboxId, connection) + } + if (restored) { + await Sandbox.kill(restored.sandboxId, connection) + } + if (sandbox) { + await Sandbox.kill(sandbox.sandboxId, connection) + } + if (snapshotId) { + await Sandbox.deleteSnapshot(snapshotId, connection) + } + } finally { + if (volume) { + await Volume.destroy(volume.volumeId, connection) + } + } +} diff --git a/compat/e2b/fixtures/official-clients/python_client.py b/compat/e2b/fixtures/official-clients/python_client.py new file mode 100644 index 00000000..6828c28d --- /dev/null +++ b/compat/e2b/fixtures/official-clients/python_client.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Exercise pinned official Python lifecycle clients against the recorder.""" + +from __future__ import annotations + +import argparse +import asyncio +from typing import Any + +from e2b import ( + AsyncVolume, + AsyncSandbox, + Sandbox, + SandboxNotFoundException, + SandboxQuery, + SandboxState, + Volume, +) +from e2b_code_interpreter import AsyncSandbox as AsyncCodeInterpreter +from e2b_code_interpreter import Sandbox as CodeInterpreter + + +API_KEY = "e2b_a1b2c3" +SANDBOX_ID = "fixture-sandbox" +RESTORED_SANDBOX_ID = "fixture-restored" +INTERPRETER_SANDBOX_ID = "fixture-interpreter" +MISSING_SANDBOX_ID = "missing-sandbox" + + +def connection(api_url: str) -> dict[str, Any]: + return {"api_key": API_KEY, "api_url": api_url} + + +def create_options(api_url: str) -> dict[str, Any]: + return { + **connection(api_url), + "allow_internet_access": False, + "envs": {"BETA": "two", "ALPHA": "one"}, + "lifecycle": { + "on_timeout": {"action": "pause", "keep_memory": False}, + "auto_resume": False, + }, + "metadata": {"team": "alpha beta", "purpose": "fixture"}, + "secure": True, + "timeout": 321, + } + + +def run_sync(api_url: str) -> None: + volume = Volume.create("fixture-data", **connection(api_url)) + assert volume.volume_id + assert volume.token == "fixture-volume-token" + connected_volume = Volume.connect(volume.volume_id, **connection(api_url)) + assert connected_volume.name == "fixture-data" + assert any( + item.volume_id == volume.volume_id + for item in Volume.list(**connection(api_url)) + ) + directory = volume.make_dir( + "/nested", force=True, mode=0o755, api_url=api_url + ) + assert directory.path == "/nested" + written = volume.write_file( + "/nested/value.txt", "volume-value", mode=0o644, api_url=api_url + ) + assert written.size == len("volume-value") + assert volume.exists("/nested/value.txt", api_url=api_url) + updated = volume.update_metadata( + "/nested/value.txt", mode=0o600, api_url=api_url + ) + assert updated.mode == 0o600 + assert len(volume.list("/", depth=2, api_url=api_url)) == 2 + assert volume.read_file("/nested/value.txt", api_url=api_url) == "volume-value" + volume.remove("/nested", api_url=api_url) + + sandbox = Sandbox.create( + "fixture-template", + volume_mounts={"/mnt/data": volume}, + **create_options(api_url), + ) + assert sandbox.sandbox_id == SANDBOX_ID + + assert sandbox.pause(keep_memory=True) + assert not sandbox.pause(keep_memory=True) + + connected = Sandbox.connect(SANDBOX_ID, timeout=222, **connection(api_url)) + assert connected.sandbox_id == SANDBOX_ID + + paginator = Sandbox.list( + query=SandboxQuery( + metadata={"team": "alpha beta"}, + state=[SandboxState.RUNNING, SandboxState.PAUSED], + ), + limit=2, + next_token="cursor-0", + **connection(api_url), + ) + listed = paginator.next_items() + assert len(listed) == 1 + assert listed[0].volume_mounts[0]["name"] == "fixture-data" + assert listed[0].volume_mounts[0]["path"] == "/mnt/data" + + snapshot = sandbox.create_snapshot(name="fixture-state") + assert snapshot.snapshot_id + assert snapshot.names == [snapshot.snapshot_id] + snapshots = sandbox.list_snapshots(limit=1).next_items() + assert len(snapshots) == 1 + assert snapshots[0].snapshot_id == snapshot.snapshot_id + restored = Sandbox.create(snapshot.snapshot_id, **connection(api_url)) + assert restored.sandbox_id == RESTORED_SANDBOX_ID + assert restored.kill() + assert Sandbox.delete_snapshot(snapshot.snapshot_id, **connection(api_url)) + assert not Sandbox.delete_snapshot(snapshot.snapshot_id, **connection(api_url)) + + sandbox.set_timeout(123) + assert sandbox.kill() + assert not Sandbox.kill(MISSING_SANDBOX_ID, **connection(api_url)) + try: + Sandbox.connect(MISSING_SANDBOX_ID, **connection(api_url)) + except SandboxNotFoundException: + pass + else: + raise AssertionError("missing sandbox connect must raise SandboxNotFoundException") + + interpreter = CodeInterpreter.create(**connection(api_url)) + assert interpreter.sandbox_id == INTERPRETER_SANDBOX_ID + assert interpreter.kill() + assert Volume.destroy(volume.volume_id, **connection(api_url)) + + +async def run_async(api_url: str) -> None: + volume = await AsyncVolume.create("fixture-data", **connection(api_url)) + assert volume.volume_id + assert volume.token == "fixture-volume-token" + connected_volume = await AsyncVolume.connect( + volume.volume_id, **connection(api_url) + ) + assert connected_volume.name == "fixture-data" + assert any( + item.volume_id == volume.volume_id + for item in await AsyncVolume.list(**connection(api_url)) + ) + directory = await volume.make_dir( + "/nested", force=True, mode=0o755, api_url=api_url + ) + assert directory.path == "/nested" + written = await volume.write_file( + "/nested/value.txt", "volume-value", mode=0o644, api_url=api_url + ) + assert written.size == len("volume-value") + assert await volume.exists("/nested/value.txt", api_url=api_url) + updated = await volume.update_metadata( + "/nested/value.txt", mode=0o600, api_url=api_url + ) + assert updated.mode == 0o600 + assert len(await volume.list("/", depth=2, api_url=api_url)) == 2 + assert ( + await volume.read_file("/nested/value.txt", api_url=api_url) + == "volume-value" + ) + await volume.remove("/nested", api_url=api_url) + + sandbox = await AsyncSandbox.create( + "fixture-template", + volume_mounts={"/mnt/data": volume}, + **create_options(api_url), + ) + assert sandbox.sandbox_id == SANDBOX_ID + + assert await sandbox.pause(keep_memory=True) + assert not await sandbox.pause(keep_memory=True) + + connected = await AsyncSandbox.connect( + SANDBOX_ID, timeout=222, **connection(api_url) + ) + assert connected.sandbox_id == SANDBOX_ID + + paginator = AsyncSandbox.list( + query=SandboxQuery( + metadata={"team": "alpha beta"}, + state=[SandboxState.RUNNING, SandboxState.PAUSED], + ), + limit=2, + next_token="cursor-0", + **connection(api_url), + ) + listed = await paginator.next_items() + assert len(listed) == 1 + assert listed[0].volume_mounts[0]["name"] == "fixture-data" + assert listed[0].volume_mounts[0]["path"] == "/mnt/data" + + snapshot = await sandbox.create_snapshot(name="fixture-state") + assert snapshot.snapshot_id + assert snapshot.names == [snapshot.snapshot_id] + snapshots = await sandbox.list_snapshots(limit=1).next_items() + assert len(snapshots) == 1 + assert snapshots[0].snapshot_id == snapshot.snapshot_id + restored = await AsyncSandbox.create(snapshot.snapshot_id, **connection(api_url)) + assert restored.sandbox_id == RESTORED_SANDBOX_ID + assert await restored.kill() + assert await AsyncSandbox.delete_snapshot( + snapshot.snapshot_id, **connection(api_url) + ) + assert not await AsyncSandbox.delete_snapshot( + snapshot.snapshot_id, **connection(api_url) + ) + + await sandbox.set_timeout(123) + assert await sandbox.kill() + assert not await AsyncSandbox.kill(MISSING_SANDBOX_ID, **connection(api_url)) + try: + await AsyncSandbox.connect(MISSING_SANDBOX_ID, **connection(api_url)) + except SandboxNotFoundException: + pass + else: + raise AssertionError("missing sandbox connect must raise SandboxNotFoundException") + + interpreter = await AsyncCodeInterpreter.create(**connection(api_url)) + assert interpreter.sandbox_id == INTERPRETER_SANDBOX_ID + assert await interpreter.kill() + assert await AsyncVolume.destroy(volume.volume_id, **connection(api_url)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=["sync", "async"]) + parser.add_argument("api_url") + args = parser.parse_args() + if args.mode == "sync": + run_sync(args.api_url) + else: + asyncio.run(run_async(args.api_url)) + + +if __name__ == "__main__": + main() diff --git a/compat/e2b/fixtures/official-clients/run_fixtures.py b/compat/e2b/fixtures/official-clients/run_fixtures.py new file mode 100644 index 00000000..f129e6f1 --- /dev/null +++ b/compat/e2b/fixtures/official-clients/run_fixtures.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Download pinned clients and generate or verify lifecycle wire fixtures.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import http.client +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request +import venv +from pathlib import Path +from typing import Any + + +FIXTURE_DIR = Path(__file__).resolve().parent +COMPAT_ROOT = FIXTURE_DIR.parent.parent +SOURCE_LOCK = COMPAT_ROOT / "upstream.lock.json" +EXPECTED_REQUESTS = 28 +DOWNLOAD_ATTEMPTS = 3 +DOWNLOAD_TIMEOUT_SECONDS = 120 + + +def load_artifacts() -> dict[str, dict[str, Any]]: + lock = json.loads(SOURCE_LOCK.read_text(encoding="utf-8")) + return {artifact["id"]: artifact for artifact in lock["artifacts"]} + + +def download_artifact( + artifact: dict[str, Any], + destination: Path, + artifact_cache: Path | None, +) -> None: + cache_path = None + if artifact_cache: + cache_path = artifact_cache.resolve() / Path(artifact["url"]).name + if cache_path and cache_path.is_file(): + payload = cache_path.read_bytes() + else: + payload = download_url(artifact["url"]) + actual_sha256 = "sha256:" + hashlib.sha256(payload).hexdigest() + if actual_sha256 != artifact["sha256"]: + raise RuntimeError( + f"artifact {artifact['id']} SHA-256 mismatch: " + f"expected {artifact['sha256']}, got {actual_sha256}" + ) + integrity = artifact.get("integrity") + if integrity: + actual_integrity = "sha512-" + base64.b64encode( + hashlib.sha512(payload).digest() + ).decode() + if actual_integrity != integrity: + raise RuntimeError( + f"artifact {artifact['id']} npm integrity mismatch: " + f"expected {integrity}, got {actual_integrity}" + ) + if cache_path and not cache_path.exists(): + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_bytes(payload) + destination.write_bytes(payload) + + +def download_url(url: str) -> bytes: + last_error: Exception | None = None + for attempt in range(1, DOWNLOAD_ATTEMPTS + 1): + try: + with urllib.request.urlopen( + url, timeout=DOWNLOAD_TIMEOUT_SECONDS + ) as response: + return response.read() + except (OSError, http.client.HTTPException) as error: + last_error = error + if attempt < DOWNLOAD_ATTEMPTS: + time.sleep(attempt) + raise RuntimeError( + f"download failed after {DOWNLOAD_ATTEMPTS} attempts: {url}" + ) from last_error + + +def prepare_python( + temp: Path, + artifacts: dict[str, dict[str, Any]], + pip_bootstrap_wheel: Path | None, + artifact_cache: Path | None, +) -> Path: + environment = temp / "python" + python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + wheels = [] + for artifact_id in ["python-e2b-wheel", "python-code-interpreter-wheel"]: + wheel = temp / Path(artifacts[artifact_id]["url"]).name + download_artifact(artifacts[artifact_id], wheel, artifact_cache) + wheels.append(str(wheel)) + + env = os.environ.copy() + env.setdefault("PIP_INDEX_URL", "https://pypi.org/simple") + env.setdefault("PIP_DEFAULT_TIMEOUT", "60") + env.setdefault("PIP_RETRIES", "5") + uv = shutil.which("uv") + if uv: + subprocess.run( + [uv, "venv", "--python", sys.executable, str(environment)], + check=True, + env=env, + ) + subprocess.run( + [uv, "pip", "install", "--python", str(python), *wheels], + check=True, + env=env, + ) + elif pip_bootstrap_wheel: + bootstrap = pip_bootstrap_wheel.resolve() + if not bootstrap.is_file(): + raise FileNotFoundError(f"pip bootstrap wheel not found: {bootstrap}") + venv.EnvBuilder(with_pip=False).create(environment) + bootstrap_env = env.copy() + bootstrap_env["PYTHONPATH"] = str(bootstrap) + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + *wheels, + ], + check=True, + env=bootstrap_env, + ) + else: + venv.EnvBuilder(with_pip=True).create(environment) + subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--disable-pip-version-check", + *wheels, + ], + check=True, + env=env, + ) + return python + + +def prepare_typescript( + temp: Path, + artifacts: dict[str, dict[str, Any]], + artifact_cache: Path | None, +) -> Path: + environment = temp / "typescript" + environment.mkdir() + tarballs = [] + for artifact_id in [ + "typescript-e2b-tarball", + "typescript-code-interpreter-tarball", + ]: + tarball = temp / Path(artifacts[artifact_id]["url"]).name + download_artifact(artifacts[artifact_id], tarball, artifact_cache) + tarballs.append(str(tarball)) + subprocess.run( + [ + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefix", + str(environment), + *tarballs, + ], + check=True, + ) + client = environment / "typescript_client.mjs" + shutil.copyfile(FIXTURE_DIR / "typescript_client.mjs", client) + return client + + +def run_client( + mode: str, + label: str, + command: list[str], + temp: Path, + update: bool, +) -> None: + capture = temp / f"{label}.jsonl" + port_file = temp / f"{label}.port" + server = subprocess.Popen( + [ + sys.executable, + str(FIXTURE_DIR / "mock_server.py"), + "--capture", + str(capture), + "--client", + label, + "--port-file", + str(port_file), + ] + ) + try: + deadline = time.monotonic() + 10 + while not port_file.exists(): + if server.poll() is not None: + raise RuntimeError(f"fixture server exited before {label} started") + if time.monotonic() >= deadline: + raise TimeoutError(f"fixture server did not start for {label}") + time.sleep(0.02) + api_url = f"http://127.0.0.1:{port_file.read_text(encoding='utf-8')}" + subprocess.run([*command, api_url], check=True) + finally: + server.terminate() + server.wait(timeout=10) + + lines = capture.read_text(encoding="utf-8").splitlines() + if len(lines) != EXPECTED_REQUESTS: + raise RuntimeError( + f"{label} emitted {len(lines)} requests; expected {EXPECTED_REQUESTS}" + ) + expected = FIXTURE_DIR / "expected" / f"{label}.jsonl" + if update: + expected.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(capture, expected) + elif not expected.exists() or expected.read_bytes() != capture.read_bytes(): + actual = capture.read_text(encoding="utf-8") + wanted = expected.read_text(encoding="utf-8") if expected.exists() else "\n" + raise RuntimeError( + f"{mode} fixture drift for {label}\n--- expected\n{wanted}--- actual\n{actual}" + ) + + +def run_rust_client( + label: str, + command: list[str], + temp: Path, + server_bin: Path, +) -> None: + port_file = temp / f"{label}-rust.port" + server = subprocess.Popen( + [str(server_bin), "--port-file", str(port_file)], + ) + try: + deadline = time.monotonic() + 10 + while not port_file.exists(): + if server.poll() is not None: + raise RuntimeError(f"Rust fixture server exited before {label} started") + if time.monotonic() >= deadline: + raise TimeoutError(f"Rust fixture server did not start for {label}") + time.sleep(0.02) + api_url = f"http://127.0.0.1:{port_file.read_text(encoding='utf-8')}" + subprocess.run([*command, api_url], check=True) + finally: + server.terminate() + server.wait(timeout=10) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=["generate", "verify"], nargs="?", default="verify") + parser.add_argument( + "--pip-bootstrap-wheel", + type=Path, + help="use this pip wheel when uv and ensurepip are unavailable", + ) + parser.add_argument( + "--artifact-cache", + type=Path, + help="reuse verified SDK artifacts from this directory", + ) + parser.add_argument( + "--rust-server-bin", + type=Path, + help="also run every official client against this Rust fixture server", + ) + args = parser.parse_args() + artifacts = load_artifacts() + with tempfile.TemporaryDirectory(prefix="a3s-e2b-official-clients-") as directory: + temp = Path(directory) + python = prepare_python( + temp, + artifacts, + args.pip_bootstrap_wheel, + args.artifact_cache, + ) + typescript_client = prepare_typescript(temp, artifacts, args.artifact_cache) + update = args.mode == "generate" + run_client( + args.mode, + "python-sync", + [str(python), str(FIXTURE_DIR / "python_client.py"), "sync"], + temp, + update, + ) + run_client( + args.mode, + "python-async", + [str(python), str(FIXTURE_DIR / "python_client.py"), "async"], + temp, + update, + ) + run_client( + args.mode, + "typescript", + ["node", str(typescript_client)], + temp, + update, + ) + if args.rust_server_bin: + server_bin = args.rust_server_bin.resolve() + if not server_bin.is_file(): + raise FileNotFoundError(f"Rust fixture server not found: {server_bin}") + run_rust_client( + "python-sync", + [str(python), str(FIXTURE_DIR / "python_client.py"), "sync"], + temp, + server_bin, + ) + run_rust_client( + "python-async", + [str(python), str(FIXTURE_DIR / "python_client.py"), "async"], + temp, + server_bin, + ) + run_rust_client( + "typescript", + ["node", str(typescript_client)], + temp, + server_bin, + ) + + +if __name__ == "__main__": + main() diff --git a/compat/e2b/fixtures/official-clients/run_production.py b/compat/e2b/fixtures/official-clients/run_production.py new file mode 100644 index 00000000..3be054c8 --- /dev/null +++ b/compat/e2b/fixtures/official-clients/run_production.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Run pinned official clients against an already-running production service.""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from run_fixtures import ( + COMPAT_ROOT, + FIXTURE_DIR, + load_artifacts, + prepare_python, + prepare_typescript, +) + +SDK_ROOT = COMPAT_ROOT.parent.parent / "sdk" +E2B_CONNECTION_ENVIRONMENT = ( + "E2B_API_KEY", + "E2B_API_URL", + "E2B_DEBUG", + "E2B_DOMAIN", + "E2B_SANDBOX_URL", + "E2B_VALIDATE_API_KEY", + "E2B_VOLUME_API_URL", +) + + +def prepare_native_typescript(temp: Path, client: Path) -> None: + source = SDK_ROOT / "typescript" + if not source.is_dir(): + raise FileNotFoundError(f"TypeScript SDK source not found: {source}") + environment = client.parent + build_source = temp / "a3s-typescript-sdk" + shutil.copytree( + source, + build_source, + ignore=shutil.ignore_patterns("dist", "node_modules"), + ) + subprocess.run( + [ + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-save", + "--prefix", + str(environment), + "typescript@5.9.3", + ], + check=True, + ) + compiler = environment / "node_modules" / ".bin" / "tsc" + dependencies = build_source / "node_modules" + dependencies.symlink_to(environment / "node_modules", target_is_directory=True) + try: + subprocess.run( + [str(compiler), "-p", "tsconfig.json"], + cwd=build_source, + check=True, + ) + finally: + dependencies.unlink(missing_ok=True) + packed = subprocess.run( + ["npm", "pack", "--ignore-scripts", "--pack-destination", str(temp)], + cwd=build_source, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + tarball = temp / packed.splitlines()[-1] + subprocess.run( + [ + "npm", + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-save", + "--prefix", + str(environment), + str(tarball), + ], + check=True, + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--api-url", required=True) + parser.add_argument("--domain", required=True) + parser.add_argument("--template", required=True) + parser.add_argument("--pip-bootstrap-wheel", type=Path) + parser.add_argument("--artifact-cache", type=Path) + parser.add_argument( + "--native-sdks", + action="store_true", + help="repeat the matrix through the repository's A3S SDK packages", + ) + args = parser.parse_args() + + if not os.environ.get("E2B_API_KEY"): + raise RuntimeError("E2B_API_KEY is required") + + artifacts = load_artifacts() + with tempfile.TemporaryDirectory( + prefix="a3s-e2b-production-official-clients-" + ) as directory: + temp = Path(directory) + python = prepare_python( + temp, + artifacts, + args.pip_bootstrap_wheel, + args.artifact_cache, + ) + typescript_client = prepare_typescript(temp, artifacts, args.artifact_cache) + shutil.copyfile( + FIXTURE_DIR / "production_typescript_client.mjs", typescript_client + ) + + python_client = FIXTURE_DIR / "production_python_client.py" + common = [args.api_url, args.domain, args.template] + subprocess.run( + [str(python), str(python_client), "sync", *common], check=True + ) + subprocess.run( + [str(python), str(python_client), "async", *common], check=True + ) + subprocess.run(["node", str(typescript_client), *common], check=True) + + if args.native_sdks: + python_env = os.environ.copy() + api_key = python_env["E2B_API_KEY"] + sandbox_url = python_env.get("E2B_SANDBOX_URL") + for name in E2B_CONNECTION_ENVIRONMENT: + python_env.pop(name, None) + python_env.update( + { + "A3S_BOX_ENDPOINT": args.api_url, + "A3S_BOX_DOMAIN": args.domain, + "A3S_BOX_API_KEY": api_key, + } + ) + if sandbox_url: + python_env["A3S_BOX_SANDBOX_URL"] = sandbox_url + python_source = SDK_ROOT / "python" / "src" + if not python_source.is_dir(): + raise FileNotFoundError(f"Python SDK source not found: {python_source}") + python_env["PYTHONPATH"] = os.pathsep.join( + filter( + None, + [str(python_source), python_env.get("PYTHONPATH")], + ) + ) + python_env["A3S_BOX_NATIVE_SDK"] = "1" + subprocess.run( + [str(python), str(python_client), "sync", *common], + check=True, + env=python_env, + ) + subprocess.run( + [str(python), str(python_client), "async", *common], + check=True, + env=python_env, + ) + + prepare_native_typescript(temp, typescript_client) + typescript_env = python_env.copy() + typescript_env["A3S_BOX_NATIVE_SDK"] = "1" + subprocess.run( + ["node", str(typescript_client), *common], + check=True, + env=typescript_env, + ) + + print( + "Official production clients passed: Python sync, Python async, and " + "TypeScript lifecycle, envd health, Filesystem operations, foreground " + "and background commands, stdin, PTY resize, Volume control/content, " + "bidirectional Sandbox mounts, filesystem Snapshot capture/list, " + "source deletion, restore, active-use conflicts and deletion, and Code " + "Interpreter execution and contexts" + + (" through both official and A3S SDK packages" if args.native_sdks else "") + ) + + +if __name__ == "__main__": + main() diff --git a/compat/e2b/fixtures/official-clients/test_run_production.py b/compat/e2b/fixtures/official-clients/test_run_production.py new file mode 100644 index 00000000..334f80ef --- /dev/null +++ b/compat/e2b/fixtures/official-clients/test_run_production.py @@ -0,0 +1,85 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import run_production + + +class NativeEnvironmentTests(unittest.TestCase): + def test_removes_every_official_connection_override(self) -> None: + self.assertEqual( + set(run_production.E2B_CONNECTION_ENVIRONMENT), + { + "E2B_API_KEY", + "E2B_API_URL", + "E2B_DEBUG", + "E2B_DOMAIN", + "E2B_SANDBOX_URL", + "E2B_VALIDATE_API_KEY", + "E2B_VOLUME_API_URL", + }, + ) + + +class PrepareNativeTypescriptTests(unittest.TestCase): + def test_compiler_resolves_pinned_official_dependencies(self) -> None: + with tempfile.TemporaryDirectory() as directory: + temp = Path(directory) + sdk_root = temp / "sdk" + source = sdk_root / "typescript" + (source / "src").mkdir(parents=True) + (source / "src" / "index.ts").write_text( + "export { Sandbox } from 'e2b'\n", + encoding="utf-8", + ) + (source / "package.json").write_text( + '{"name":"@a3s-lab/box","version":"0.1.0"}\n', + encoding="utf-8", + ) + (source / "tsconfig.json").write_text("{}\n", encoding="utf-8") + + environment = temp / "typescript" + environment.mkdir() + client = environment / "production_typescript_client.mjs" + client.touch() + modules = environment / "node_modules" + (modules / ".bin").mkdir(parents=True) + compiler = modules / ".bin" / "tsc" + compiler.touch() + + calls = 0 + + def run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + nonlocal calls + calls += 1 + if calls == 2: + build_source = Path(str(kwargs["cwd"])) + dependencies = build_source / "node_modules" + self.assertTrue(dependencies.is_symlink()) + self.assertEqual(dependencies.resolve(), modules.resolve()) + if calls == 3: + tarball = temp / "a3s-lab-box-0.1.0.tgz" + tarball.touch() + return subprocess.CompletedProcess( + command, + 0, + stdout=f"{tarball.name}\n", + ) + return subprocess.CompletedProcess(command, 0) + + with ( + mock.patch.object(run_production, "SDK_ROOT", sdk_root), + mock.patch.object(run_production.subprocess, "run", side_effect=run), + ): + run_production.prepare_native_typescript(temp, client) + + self.assertEqual(calls, 4) + self.assertFalse((temp / "a3s-typescript-sdk" / "node_modules").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/compat/e2b/fixtures/official-clients/typescript_client.mjs b/compat/e2b/fixtures/official-clients/typescript_client.mjs new file mode 100644 index 00000000..df303c2c --- /dev/null +++ b/compat/e2b/fixtures/official-clients/typescript_client.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** Exercise pinned official TypeScript lifecycle clients against the recorder. */ + +import assert from 'node:assert/strict' +import { Sandbox, SandboxNotFoundError, Volume } from 'e2b' +import { Sandbox as CodeInterpreter } from '@e2b/code-interpreter' + +const apiUrl = process.argv[2] +if (!apiUrl) { + throw new Error('API URL argument is required') +} + +const connection = { + apiKey: 'e2b_a1b2c3', + apiUrl, +} +const volumeApi = { apiUrl } +const volume = await Volume.create('fixture-data', connection) +assert.equal(volume.token, 'fixture-volume-token') +const connectedVolume = await Volume.connect(volume.volumeId, connection) +assert.equal(connectedVolume.name, 'fixture-data') +assert.ok((await Volume.list(connection)).some((item) => item.volumeId === volume.volumeId)) +const directory = await volume.makeDir('/nested', { + ...volumeApi, + force: true, + mode: 0o755, +}) +assert.equal(directory.path, '/nested') +const written = await volume.writeFile('/nested/value.txt', 'volume-value', { + ...volumeApi, + mode: 0o644, +}) +assert.equal(written.size, 'volume-value'.length) +assert.equal(await volume.exists('/nested/value.txt', volumeApi), true) +const updated = await volume.updateMetadata( + '/nested/value.txt', + { mode: 0o600 }, + volumeApi +) +assert.equal(updated.mode, 0o600) +assert.equal((await volume.list('/', { ...volumeApi, depth: 2 })).length, 2) +assert.equal( + await volume.readFile('/nested/value.txt', volumeApi), + 'volume-value' +) +await volume.remove('/nested', volumeApi) + +const sandbox = await Sandbox.create('fixture-template', { + ...connection, + allowInternetAccess: false, + envs: { BETA: 'two', ALPHA: 'one' }, + lifecycle: { + onTimeout: { action: 'pause', keepMemory: false }, + autoResume: false, + }, + metadata: { team: 'alpha beta', purpose: 'fixture' }, + secure: true, + timeoutMs: 321_000, + volumeMounts: { '/mnt/data': volume }, +}) +assert.equal(sandbox.sandboxId, 'fixture-sandbox') + +assert.equal(await sandbox.pause({ keepMemory: true }), true) +assert.equal(await sandbox.pause({ keepMemory: true }), false) + +const connected = await Sandbox.connect('fixture-sandbox', { + ...connection, + timeoutMs: 222_000, +}) +assert.equal(connected.sandboxId, 'fixture-sandbox') + +const paginator = Sandbox.list({ + ...connection, + limit: 2, + nextToken: 'cursor-0', + query: { + metadata: { team: 'alpha beta' }, + state: ['running', 'paused'], + }, +}) +const listed = await paginator.nextItems() +assert.equal(listed.length, 1) +assert.equal(listed[0].volumeMounts[0].name, 'fixture-data') +assert.equal(listed[0].volumeMounts[0].path, '/mnt/data') + +const snapshot = await sandbox.createSnapshot({ name: 'fixture-state' }) +assert.ok(snapshot.snapshotId) +assert.deepEqual(snapshot.names, [snapshot.snapshotId]) +const snapshots = await sandbox.listSnapshots({ limit: 1 }).nextItems() +assert.equal(snapshots.length, 1) +assert.equal(snapshots[0].snapshotId, snapshot.snapshotId) +const restored = await Sandbox.create(snapshot.snapshotId, connection) +assert.equal(restored.sandboxId, 'fixture-restored') +assert.equal(await restored.kill(), true) +assert.equal(await Sandbox.deleteSnapshot(snapshot.snapshotId, connection), true) +assert.equal(await Sandbox.deleteSnapshot(snapshot.snapshotId, connection), false) + +await sandbox.setTimeout(123_000) +assert.equal(await sandbox.kill(), true) +assert.equal(await Sandbox.kill('missing-sandbox', connection), false) +await assert.rejects( + Sandbox.connect('missing-sandbox', connection), + SandboxNotFoundError +) + +const interpreter = await CodeInterpreter.create(connection) +assert.equal(interpreter.sandboxId, 'fixture-interpreter') +assert.equal(await interpreter.kill(), true) +assert.equal(await Volume.destroy(volume.volumeId, connection), true) diff --git a/compat/e2b/inventory/contracts.json b/compat/e2b/inventory/contracts.json new file mode 100644 index 00000000..9c370527 --- /dev/null +++ b/compat/e2b/inventory/contracts.json @@ -0,0 +1,12152 @@ +{ + "schema_version": 1, + "compatibility_id": "e2b-2026-07-14", + "openapi": [ + { + "name": "control-plane", + "openapi_version": "3.0.0", + "contract_version": "0.1.0", + "operations": [ + { + "method": "GET", + "path": "/sandboxes", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": "metadata", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/ListedSandbox" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/sandboxes", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/Sandbox" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/sandboxes/metrics", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": "sandbox_ids", + "location": "query", + "required": true, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/SandboxesWithMetrics" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "DELETE", + "path": "/sandboxes/{sandboxID}", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/sandboxes/{sandboxID}", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/SandboxDetail" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/sandboxes/{sandboxID}/connect", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/Sandbox" + ], + "error": false + }, + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/Sandbox" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/sandboxes/{sandboxID}/logs", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + }, + { + "name": "limit", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "start", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/SandboxLogs" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/sandboxes/{sandboxID}/metrics", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + }, + { + "name": "end", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "start", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/SandboxMetric" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "PUT", + "path": "/sandboxes/{sandboxID}/network", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "409", + "reference": "#/components/responses/409", + "content_types": [], + "schema_references": [ + "#/components/responses/409" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/sandboxes/{sandboxID}/pause", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "409", + "reference": "#/components/responses/409", + "content_types": [], + "schema_references": [ + "#/components/responses/409" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/sandboxes/{sandboxID}/refreshes", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/sandboxes/{sandboxID}/resume", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/Sandbox" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "409", + "reference": "#/components/responses/409", + "content_types": [], + "schema_references": [ + "#/components/responses/409" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/sandboxes/{sandboxID}/snapshots", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/SnapshotInfo" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/sandboxes/{sandboxID}/timeout", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/snapshots", + "operation_id": null, + "tags": [ + "snapshots" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/paginationLimit" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/paginationNextToken" + }, + { + "name": "sandboxID", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/SnapshotInfo" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/teams", + "operation_id": null, + "tags": [ + "auth" + ], + "parameters": [], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/Team" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/teams/{teamID}/metrics", + "operation_id": null, + "tags": [ + "auth" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/teamID" + }, + { + "name": "end", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "start", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TeamMetric" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "403", + "reference": "#/components/responses/403", + "content_types": [], + "schema_references": [ + "#/components/responses/403" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/teams/{teamID}/metrics/max", + "operation_id": null, + "tags": [ + "auth" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/teamID" + }, + { + "name": "end", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "metric", + "location": "query", + "required": true, + "reference": null + }, + { + "name": "start", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/MaxTeamMetric" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "403", + "reference": "#/components/responses/403", + "content_types": [], + "schema_references": [ + "#/components/responses/403" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/templates", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": "teamID", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/Template" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/templates", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "202", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateLegacy" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/templates/aliases/{alias}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": "alias", + "location": "path", + "required": true, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateAliasResponse" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "403", + "reference": "#/components/responses/403", + "content_types": [], + "schema_references": [ + "#/components/responses/403" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "DELETE", + "path": "/templates/tags", + "operation_id": null, + "tags": [ + "tags" + ], + "parameters": [], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/templates/tags", + "operation_id": null, + "tags": [ + "tags" + ], + "parameters": [], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/AssignedTemplateTags" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "DELETE", + "path": "/templates/{templateID}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/templates/{templateID}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/paginationLimit" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/paginationNextToken" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateWithBuilds" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "PATCH", + "path": "/templates/{templateID}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/templates/{templateID}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "202", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateLegacy" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/templates/{templateID}/builds/{buildID}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/buildID" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "202", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/templates/{templateID}/builds/{buildID}/logs", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/buildID" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + }, + { + "name": "cursor", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "direction", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "level", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "limit", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "source", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateBuildLogsResponse" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/templates/{templateID}/builds/{buildID}/status", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/buildID" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + }, + { + "name": "level", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "limit", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "logsOffset", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateBuildInfo" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/templates/{templateID}/files/{hash}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + }, + { + "name": "hash", + "location": "path", + "required": true, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateBuildFileUpload" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/templates/{templateID}/tags", + "operation_id": null, + "tags": [ + "tags" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateTag" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "403", + "reference": "#/components/responses/403", + "content_types": [], + "schema_references": [ + "#/components/responses/403" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/v2/sandboxes", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/paginationLimit" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/paginationNextToken" + }, + { + "name": "metadata", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "state", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/ListedSandbox" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/v2/sandboxes/{sandboxID}/logs", + "operation_id": null, + "tags": [ + "sandboxes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/sandboxID" + }, + { + "name": "cursor", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "direction", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "level", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "limit", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "search", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/SandboxLogsV2Response" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/v2/templates", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "202", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateLegacy" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "PATCH", + "path": "/v2/templates/{templateID}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateUpdateResponse" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/v2/templates/{templateID}/builds/{buildID}", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/buildID" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/templateID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "202", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/v3/templates", + "operation_id": null, + "tags": [ + "templates" + ], + "parameters": [], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "202", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/TemplateRequestResponseV3" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "403", + "reference": "#/components/responses/403", + "content_types": [], + "schema_references": [ + "#/components/responses/403" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/volumes", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/Volume" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/volumes", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/VolumeAndToken" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/400", + "content_types": [], + "schema_references": [ + "#/components/responses/400" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "DELETE", + "path": "/volumes/{volumeID}", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/volumes/{volumeID}", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/VolumeAndToken" + ], + "error": false + }, + { + "status": "401", + "reference": "#/components/responses/401", + "content_types": [], + "schema_references": [ + "#/components/responses/401" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + } + ], + "component_schemas": [ + "AWSRegistry", + "AdminBuildCancelResult", + "AdminSandboxKillResult", + "AssignTemplateTagsRequest", + "AssignedTemplateTags", + "BuildLogEntry", + "BuildStatusReason", + "CPUCount", + "ConnectSandbox", + "CreatedAccessToken", + "CreatedTeamAPIKey", + "DeleteTemplateTagsRequest", + "DiskMetrics", + "DiskSizeMB", + "EnvVars", + "EnvdVersion", + "Error", + "FromImageRegistry", + "GCPRegistry", + "GeneralRegistry", + "IdentifierMaskingDetails", + "ListedSandbox", + "LogLevel", + "LogsDirection", + "LogsSource", + "MachineInfo", + "MaxTeamMetric", + "Mcp", + "MemoryMB", + "NewAccessToken", + "NewSandbox", + "NewTeamAPIKey", + "NewVolume", + "Node", + "NodeDetail", + "NodeMetrics", + "NodeStatus", + "NodeStatusChange", + "ResumedSandbox", + "Sandbox", + "SandboxAutoResumeConfig", + "SandboxAutoResumeEnabled", + "SandboxDetail", + "SandboxLifecycle", + "SandboxLog", + "SandboxLogEntry", + "SandboxLogs", + "SandboxLogsV2Response", + "SandboxMetadata", + "SandboxMetric", + "SandboxNetworkConfig", + "SandboxNetworkRule", + "SandboxNetworkTransform", + "SandboxNetworkUpdateConfig", + "SandboxOnTimeout", + "SandboxPauseRequest", + "SandboxState", + "SandboxVolumeMount", + "SandboxesWithMetrics", + "SnapshotInfo", + "Team", + "TeamAPIKey", + "TeamMetric", + "TeamUser", + "Template", + "TemplateAliasResponse", + "TemplateBuild", + "TemplateBuildFileUpload", + "TemplateBuildInfo", + "TemplateBuildLogsResponse", + "TemplateBuildRequest", + "TemplateBuildRequestV2", + "TemplateBuildRequestV3", + "TemplateBuildStartV2", + "TemplateBuildStatus", + "TemplateLegacy", + "TemplateRequestResponseV3", + "TemplateStep", + "TemplateTag", + "TemplateUpdateRequest", + "TemplateUpdateResponse", + "TemplateWithBuilds", + "UpdateTeamAPIKey", + "Volume", + "VolumeAndToken", + "VolumeToken" + ], + "fields": [ + { + "pointer": "/components/schemas/AWSRegistry/properties/awsAccessKeyId", + "name": "awsAccessKeyId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AWSRegistry/properties/awsRegion", + "name": "awsRegion", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AWSRegistry/properties/awsSecretAccessKey", + "name": "awsSecretAccessKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AWSRegistry/properties/type", + "name": "type", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AdminBuildCancelResult/properties/cancelledCount", + "name": "cancelledCount", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AdminBuildCancelResult/properties/failedCount", + "name": "failedCount", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AdminSandboxKillResult/properties/failedCount", + "name": "failedCount", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AdminSandboxKillResult/properties/killedCount", + "name": "killedCount", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AssignTemplateTagsRequest/properties/tags", + "name": "tags", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AssignTemplateTagsRequest/properties/target", + "name": "target", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/AssignedTemplateTags/properties/buildID", + "name": "buildID", + "required": true, + "field_type": "string", + "format": "uuid", + "reference": null + }, + { + "pointer": "/components/schemas/AssignedTemplateTags/properties/tags", + "name": "tags", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/BuildLogEntry/properties/level", + "name": "level", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/LogLevel" + }, + { + "pointer": "/components/schemas/BuildLogEntry/properties/message", + "name": "message", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/BuildLogEntry/properties/step", + "name": "step", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/BuildLogEntry/properties/timestamp", + "name": "timestamp", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/BuildStatusReason/properties/logEntries", + "name": "logEntries", + "required": false, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/BuildLogEntry" + }, + { + "pointer": "/components/schemas/BuildStatusReason/properties/message", + "name": "message", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/BuildStatusReason/properties/step", + "name": "step", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/ConnectSandbox/properties/timeout", + "name": "timeout", + "required": true, + "field_type": "integer", + "format": "int32", + "reference": null + }, + { + "pointer": "/components/schemas/CreatedAccessToken/properties/createdAt", + "name": "createdAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/CreatedAccessToken/properties/id", + "name": "id", + "required": true, + "field_type": "string", + "format": "uuid", + "reference": null + }, + { + "pointer": "/components/schemas/CreatedAccessToken/properties/mask", + "name": "mask", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/IdentifierMaskingDetails" + }, + { + "pointer": "/components/schemas/CreatedAccessToken/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/CreatedAccessToken/properties/token", + "name": "token", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/CreatedTeamAPIKey/properties/createdAt", + "name": "createdAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/CreatedTeamAPIKey/properties/createdBy", + "name": "createdBy", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/TeamUser" + }, + { + "pointer": "/components/schemas/CreatedTeamAPIKey/properties/id", + "name": "id", + "required": true, + "field_type": "string", + "format": "uuid", + "reference": null + }, + { + "pointer": "/components/schemas/CreatedTeamAPIKey/properties/key", + "name": "key", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/CreatedTeamAPIKey/properties/lastUsed", + "name": "lastUsed", + "required": false, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/CreatedTeamAPIKey/properties/mask", + "name": "mask", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/IdentifierMaskingDetails" + }, + { + "pointer": "/components/schemas/CreatedTeamAPIKey/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/DeleteTemplateTagsRequest/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/DeleteTemplateTagsRequest/properties/tags", + "name": "tags", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/DiskMetrics/properties/device", + "name": "device", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/DiskMetrics/properties/filesystemType", + "name": "filesystemType", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/DiskMetrics/properties/mountPoint", + "name": "mountPoint", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/DiskMetrics/properties/totalBytes", + "name": "totalBytes", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/DiskMetrics/properties/usedBytes", + "name": "usedBytes", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/Error/properties/code", + "name": "code", + "required": true, + "field_type": "integer", + "format": "int32", + "reference": null + }, + { + "pointer": "/components/schemas/Error/properties/message", + "name": "message", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/GCPRegistry/properties/serviceAccountJson", + "name": "serviceAccountJson", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/GCPRegistry/properties/type", + "name": "type", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/GeneralRegistry/properties/password", + "name": "password", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/GeneralRegistry/properties/type", + "name": "type", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/GeneralRegistry/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/IdentifierMaskingDetails/properties/maskedValuePrefix", + "name": "maskedValuePrefix", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/IdentifierMaskingDetails/properties/maskedValueSuffix", + "name": "maskedValueSuffix", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/IdentifierMaskingDetails/properties/prefix", + "name": "prefix", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/IdentifierMaskingDetails/properties/valueLength", + "name": "valueLength", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/alias", + "name": "alias", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/clientID", + "name": "clientID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/cpuCount", + "name": "cpuCount", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/CPUCount" + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/diskSizeMB", + "name": "diskSizeMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/DiskSizeMB" + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/endAt", + "name": "endAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/envdVersion", + "name": "envdVersion", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/EnvdVersion" + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/memoryMB", + "name": "memoryMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MemoryMB" + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/metadata", + "name": "metadata", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxMetadata" + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/sandboxID", + "name": "sandboxID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/startedAt", + "name": "startedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/state", + "name": "state", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxState" + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/ListedSandbox/properties/volumeMounts", + "name": "volumeMounts", + "required": false, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/SandboxVolumeMount" + }, + { + "pointer": "/components/schemas/MachineInfo/properties/cpuArchitecture", + "name": "cpuArchitecture", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/MachineInfo/properties/cpuFamily", + "name": "cpuFamily", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/MachineInfo/properties/cpuModel", + "name": "cpuModel", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/MachineInfo/properties/cpuModelName", + "name": "cpuModelName", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/MaxTeamMetric/properties/timestamp", + "name": "timestamp", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/MaxTeamMetric/properties/timestampUnix", + "name": "timestampUnix", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/MaxTeamMetric/properties/value", + "name": "value", + "required": true, + "field_type": "number", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NewAccessToken/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NewSandbox/properties/allow_internet_access", + "name": "allow_internet_access", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NewSandbox/properties/autoPause", + "name": "autoPause", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NewSandbox/properties/autoPauseMemory", + "name": "autoPauseMemory", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NewSandbox/properties/autoResume", + "name": "autoResume", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxAutoResumeConfig" + }, + { + "pointer": "/components/schemas/NewSandbox/properties/envVars", + "name": "envVars", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/EnvVars" + }, + { + "pointer": "/components/schemas/NewSandbox/properties/mcp", + "name": "mcp", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/Mcp" + }, + { + "pointer": "/components/schemas/NewSandbox/properties/metadata", + "name": "metadata", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxMetadata" + }, + { + "pointer": "/components/schemas/NewSandbox/properties/network", + "name": "network", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxNetworkConfig" + }, + { + "pointer": "/components/schemas/NewSandbox/properties/secure", + "name": "secure", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NewSandbox/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NewSandbox/properties/timeout", + "name": "timeout", + "required": false, + "field_type": "integer", + "format": "int32", + "reference": null + }, + { + "pointer": "/components/schemas/NewSandbox/properties/volumeMounts", + "name": "volumeMounts", + "required": false, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/SandboxVolumeMount" + }, + { + "pointer": "/components/schemas/NewTeamAPIKey/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NewVolume/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/clusterID", + "name": "clusterID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/commit", + "name": "commit", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/createFails", + "name": "createFails", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/createSuccesses", + "name": "createSuccesses", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/id", + "name": "id", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/machineInfo", + "name": "machineInfo", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MachineInfo" + }, + { + "pointer": "/components/schemas/Node/properties/metrics", + "name": "metrics", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/NodeMetrics" + }, + { + "pointer": "/components/schemas/Node/properties/sandboxCount", + "name": "sandboxCount", + "required": true, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/sandboxStartingCount", + "name": "sandboxStartingCount", + "required": true, + "field_type": "integer", + "format": "int", + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/serviceInstanceID", + "name": "serviceInstanceID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Node/properties/status", + "name": "status", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/NodeStatus" + }, + { + "pointer": "/components/schemas/Node/properties/version", + "name": "version", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/cachedBuilds", + "name": "cachedBuilds", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/clusterID", + "name": "clusterID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/commit", + "name": "commit", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/createFails", + "name": "createFails", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/createSuccesses", + "name": "createSuccesses", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/id", + "name": "id", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/machineInfo", + "name": "machineInfo", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MachineInfo" + }, + { + "pointer": "/components/schemas/NodeDetail/properties/metrics", + "name": "metrics", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/NodeMetrics" + }, + { + "pointer": "/components/schemas/NodeDetail/properties/sandboxCount", + "name": "sandboxCount", + "required": true, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/serviceInstanceID", + "name": "serviceInstanceID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NodeDetail/properties/status", + "name": "status", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/NodeStatus" + }, + { + "pointer": "/components/schemas/NodeDetail/properties/version", + "name": "version", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/NodeMetrics/properties/allocatedCPU", + "name": "allocatedCPU", + "required": true, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/components/schemas/NodeMetrics/properties/allocatedMemoryBytes", + "name": "allocatedMemoryBytes", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/NodeMetrics/properties/cpuCount", + "name": "cpuCount", + "required": true, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/components/schemas/NodeMetrics/properties/cpuPercent", + "name": "cpuPercent", + "required": true, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/components/schemas/NodeMetrics/properties/disks", + "name": "disks", + "required": true, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/DiskMetrics" + }, + { + "pointer": "/components/schemas/NodeMetrics/properties/memoryTotalBytes", + "name": "memoryTotalBytes", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/NodeMetrics/properties/memoryUsedBytes", + "name": "memoryUsedBytes", + "required": true, + "field_type": "integer", + "format": "uint64", + "reference": null + }, + { + "pointer": "/components/schemas/NodeStatusChange/properties/clusterID", + "name": "clusterID", + "required": false, + "field_type": "string", + "format": "uuid", + "reference": null + }, + { + "pointer": "/components/schemas/NodeStatusChange/properties/status", + "name": "status", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/NodeStatus" + }, + { + "pointer": "/components/schemas/ResumedSandbox/properties/autoPause", + "name": "autoPause", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/ResumedSandbox/properties/timeout", + "name": "timeout", + "required": false, + "field_type": "integer", + "format": "int32", + "reference": null + }, + { + "pointer": "/components/schemas/Sandbox/properties/alias", + "name": "alias", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Sandbox/properties/clientID", + "name": "clientID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Sandbox/properties/domain", + "name": "domain", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Sandbox/properties/envdAccessToken", + "name": "envdAccessToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Sandbox/properties/envdVersion", + "name": "envdVersion", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/EnvdVersion" + }, + { + "pointer": "/components/schemas/Sandbox/properties/sandboxID", + "name": "sandboxID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Sandbox/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Sandbox/properties/trafficAccessToken", + "name": "trafficAccessToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxAutoResumeConfig/properties/enabled", + "name": "enabled", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxAutoResumeEnabled" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/alias", + "name": "alias", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/allowInternetAccess", + "name": "allowInternetAccess", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/clientID", + "name": "clientID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/cpuCount", + "name": "cpuCount", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/CPUCount" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/diskSizeMB", + "name": "diskSizeMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/DiskSizeMB" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/domain", + "name": "domain", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/endAt", + "name": "endAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/envdAccessToken", + "name": "envdAccessToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/envdVersion", + "name": "envdVersion", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/EnvdVersion" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/lifecycle", + "name": "lifecycle", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxLifecycle" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/memoryMB", + "name": "memoryMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MemoryMB" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/metadata", + "name": "metadata", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxMetadata" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/network", + "name": "network", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxNetworkConfig" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/sandboxID", + "name": "sandboxID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/startedAt", + "name": "startedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/state", + "name": "state", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxState" + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxDetail/properties/volumeMounts", + "name": "volumeMounts", + "required": false, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/SandboxVolumeMount" + }, + { + "pointer": "/components/schemas/SandboxLifecycle/properties/autoResume", + "name": "autoResume", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxLifecycle/properties/onTimeout", + "name": "onTimeout", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxOnTimeout" + }, + { + "pointer": "/components/schemas/SandboxLog/properties/line", + "name": "line", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxLog/properties/timestamp", + "name": "timestamp", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxLogEntry/properties/fields", + "name": "fields", + "required": true, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxLogEntry/properties/level", + "name": "level", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/LogLevel" + }, + { + "pointer": "/components/schemas/SandboxLogEntry/properties/message", + "name": "message", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxLogEntry/properties/timestamp", + "name": "timestamp", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxLogs/properties/logEntries", + "name": "logEntries", + "required": true, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/SandboxLogEntry" + }, + { + "pointer": "/components/schemas/SandboxLogs/properties/logs", + "name": "logs", + "required": true, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/SandboxLog" + }, + { + "pointer": "/components/schemas/SandboxLogsV2Response/properties/logs", + "name": "logs", + "required": true, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/SandboxLogEntry" + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/cpuCount", + "name": "cpuCount", + "required": true, + "field_type": "integer", + "format": "int32", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/cpuUsedPct", + "name": "cpuUsedPct", + "required": true, + "field_type": "number", + "format": "float", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/diskTotal", + "name": "diskTotal", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/diskUsed", + "name": "diskUsed", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/memCache", + "name": "memCache", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/memTotal", + "name": "memTotal", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/memUsed", + "name": "memUsed", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/timestamp", + "name": "timestamp", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxMetric/properties/timestampUnix", + "name": "timestampUnix", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkConfig/properties/allowOut", + "name": "allowOut", + "required": false, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkConfig/properties/allowPublicTraffic", + "name": "allowPublicTraffic", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkConfig/properties/denyOut", + "name": "denyOut", + "required": false, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkConfig/properties/maskRequestHost", + "name": "maskRequestHost", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkConfig/properties/rules", + "name": "rules", + "required": false, + "field_type": "object", + "format": null, + "reference": "#/components/schemas/SandboxNetworkRule" + }, + { + "pointer": "/components/schemas/SandboxNetworkRule/properties/transform", + "name": "transform", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxNetworkTransform" + }, + { + "pointer": "/components/schemas/SandboxNetworkTransform/properties/headers", + "name": "headers", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkUpdateConfig/properties/allowOut", + "name": "allowOut", + "required": false, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkUpdateConfig/properties/allow_internet_access", + "name": "allow_internet_access", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkUpdateConfig/properties/denyOut", + "name": "denyOut", + "required": false, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxNetworkUpdateConfig/properties/rules", + "name": "rules", + "required": false, + "field_type": "object", + "format": null, + "reference": "#/components/schemas/SandboxNetworkRule" + }, + { + "pointer": "/components/schemas/SandboxPauseRequest/properties/memory", + "name": "memory", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxVolumeMount/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxVolumeMount/properties/path", + "name": "path", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SandboxesWithMetrics/properties/sandboxes", + "name": "sandboxes", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/SandboxMetric" + }, + { + "pointer": "/components/schemas/SnapshotInfo/properties/names", + "name": "names", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/SnapshotInfo/properties/snapshotID", + "name": "snapshotID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Team/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Team/properties/isDefault", + "name": "isDefault", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Team/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Team/properties/teamID", + "name": "teamID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TeamAPIKey/properties/createdAt", + "name": "createdAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TeamAPIKey/properties/createdBy", + "name": "createdBy", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/TeamUser" + }, + { + "pointer": "/components/schemas/TeamAPIKey/properties/id", + "name": "id", + "required": true, + "field_type": "string", + "format": "uuid", + "reference": null + }, + { + "pointer": "/components/schemas/TeamAPIKey/properties/lastUsed", + "name": "lastUsed", + "required": false, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TeamAPIKey/properties/mask", + "name": "mask", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/IdentifierMaskingDetails" + }, + { + "pointer": "/components/schemas/TeamAPIKey/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TeamMetric/properties/concurrentSandboxes", + "name": "concurrentSandboxes", + "required": true, + "field_type": "integer", + "format": "int32", + "reference": null + }, + { + "pointer": "/components/schemas/TeamMetric/properties/sandboxStartRate", + "name": "sandboxStartRate", + "required": true, + "field_type": "number", + "format": "float", + "reference": null + }, + { + "pointer": "/components/schemas/TeamMetric/properties/timestamp", + "name": "timestamp", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TeamMetric/properties/timestampUnix", + "name": "timestampUnix", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/TeamUser/properties/email", + "name": "email", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TeamUser/properties/id", + "name": "id", + "required": true, + "field_type": "string", + "format": "uuid", + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/aliases", + "name": "aliases", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/buildCount", + "name": "buildCount", + "required": true, + "field_type": "integer", + "format": "int32", + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/buildID", + "name": "buildID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/buildStatus", + "name": "buildStatus", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/TemplateBuildStatus" + }, + { + "pointer": "/components/schemas/Template/properties/cpuCount", + "name": "cpuCount", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/CPUCount" + }, + { + "pointer": "/components/schemas/Template/properties/createdAt", + "name": "createdAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/createdBy", + "name": "createdBy", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/TeamUser" + }, + { + "pointer": "/components/schemas/Template/properties/diskSizeMB", + "name": "diskSizeMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/DiskSizeMB" + }, + { + "pointer": "/components/schemas/Template/properties/envdVersion", + "name": "envdVersion", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/EnvdVersion" + }, + { + "pointer": "/components/schemas/Template/properties/lastSpawnedAt", + "name": "lastSpawnedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/memoryMB", + "name": "memoryMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MemoryMB" + }, + { + "pointer": "/components/schemas/Template/properties/names", + "name": "names", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/public", + "name": "public", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/spawnCount", + "name": "spawnCount", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Template/properties/updatedAt", + "name": "updatedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateAliasResponse/properties/public", + "name": "public", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateAliasResponse/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/buildID", + "name": "buildID", + "required": true, + "field_type": "string", + "format": "uuid", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/cpuCount", + "name": "cpuCount", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/CPUCount" + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/createdAt", + "name": "createdAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/diskSizeMB", + "name": "diskSizeMB", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/DiskSizeMB" + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/envdVersion", + "name": "envdVersion", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/EnvdVersion" + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/finishedAt", + "name": "finishedAt", + "required": false, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/memoryMB", + "name": "memoryMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MemoryMB" + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/status", + "name": "status", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/TemplateBuildStatus" + }, + { + "pointer": "/components/schemas/TemplateBuild/properties/updatedAt", + "name": "updatedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildFileUpload/properties/present", + "name": "present", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildFileUpload/properties/url", + "name": "url", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildInfo/properties/buildID", + "name": "buildID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildInfo/properties/logEntries", + "name": "logEntries", + "required": true, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/BuildLogEntry" + }, + { + "pointer": "/components/schemas/TemplateBuildInfo/properties/logs", + "name": "logs", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildInfo/properties/reason", + "name": "reason", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/BuildStatusReason" + }, + { + "pointer": "/components/schemas/TemplateBuildInfo/properties/status", + "name": "status", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/TemplateBuildStatus" + }, + { + "pointer": "/components/schemas/TemplateBuildInfo/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildLogsResponse/properties/logs", + "name": "logs", + "required": true, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/BuildLogEntry" + }, + { + "pointer": "/components/schemas/TemplateBuildRequest/properties/alias", + "name": "alias", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequest/properties/cpuCount", + "name": "cpuCount", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/CPUCount" + }, + { + "pointer": "/components/schemas/TemplateBuildRequest/properties/dockerfile", + "name": "dockerfile", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequest/properties/memoryMB", + "name": "memoryMB", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MemoryMB" + }, + { + "pointer": "/components/schemas/TemplateBuildRequest/properties/readyCmd", + "name": "readyCmd", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequest/properties/startCmd", + "name": "startCmd", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequest/properties/teamID", + "name": "teamID", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV2/properties/alias", + "name": "alias", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV2/properties/cpuCount", + "name": "cpuCount", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/CPUCount" + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV2/properties/memoryMB", + "name": "memoryMB", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MemoryMB" + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV2/properties/teamID", + "name": "teamID", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV3/properties/alias", + "name": "alias", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV3/properties/cpuCount", + "name": "cpuCount", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/CPUCount" + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV3/properties/memoryMB", + "name": "memoryMB", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MemoryMB" + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV3/properties/name", + "name": "name", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV3/properties/tags", + "name": "tags", + "required": false, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildRequestV3/properties/teamID", + "name": "teamID", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildStartV2/properties/force", + "name": "force", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildStartV2/properties/fromImage", + "name": "fromImage", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildStartV2/properties/fromImageRegistry", + "name": "fromImageRegistry", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/FromImageRegistry" + }, + { + "pointer": "/components/schemas/TemplateBuildStartV2/properties/fromTemplate", + "name": "fromTemplate", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildStartV2/properties/readyCmd", + "name": "readyCmd", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildStartV2/properties/startCmd", + "name": "startCmd", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateBuildStartV2/properties/steps", + "name": "steps", + "required": false, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/TemplateStep" + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/aliases", + "name": "aliases", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/buildCount", + "name": "buildCount", + "required": true, + "field_type": "integer", + "format": "int32", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/buildID", + "name": "buildID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/cpuCount", + "name": "cpuCount", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/CPUCount" + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/createdAt", + "name": "createdAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/createdBy", + "name": "createdBy", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/TeamUser" + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/diskSizeMB", + "name": "diskSizeMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/DiskSizeMB" + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/envdVersion", + "name": "envdVersion", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/EnvdVersion" + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/lastSpawnedAt", + "name": "lastSpawnedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/memoryMB", + "name": "memoryMB", + "required": true, + "field_type": null, + "format": null, + "reference": "#/components/schemas/MemoryMB" + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/public", + "name": "public", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/spawnCount", + "name": "spawnCount", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateLegacy/properties/updatedAt", + "name": "updatedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateRequestResponseV3/properties/aliases", + "name": "aliases", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateRequestResponseV3/properties/buildID", + "name": "buildID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateRequestResponseV3/properties/names", + "name": "names", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateRequestResponseV3/properties/public", + "name": "public", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateRequestResponseV3/properties/tags", + "name": "tags", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateRequestResponseV3/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateStep/properties/args", + "name": "args", + "required": false, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateStep/properties/filesHash", + "name": "filesHash", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateStep/properties/force", + "name": "force", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateStep/properties/type", + "name": "type", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateTag/properties/buildID", + "name": "buildID", + "required": true, + "field_type": "string", + "format": "uuid", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateTag/properties/createdAt", + "name": "createdAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateTag/properties/tag", + "name": "tag", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateUpdateRequest/properties/public", + "name": "public", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateUpdateResponse/properties/names", + "name": "names", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/aliases", + "name": "aliases", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/builds", + "name": "builds", + "required": true, + "field_type": "array", + "format": null, + "reference": "#/components/schemas/TemplateBuild" + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/createdAt", + "name": "createdAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/lastSpawnedAt", + "name": "lastSpawnedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/names", + "name": "names", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/public", + "name": "public", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/spawnCount", + "name": "spawnCount", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/templateID", + "name": "templateID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/TemplateWithBuilds/properties/updatedAt", + "name": "updatedAt", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/UpdateTeamAPIKey/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Volume/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Volume/properties/volumeID", + "name": "volumeID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeAndToken/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeAndToken/properties/token", + "name": "token", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeAndToken/properties/volumeID", + "name": "volumeID", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeToken/properties/token", + "name": "token", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/paths/~1sandboxes~1{sandboxID}~1refreshes/post/requestBody/content/application~1json/schema/properties/duration", + "name": "duration", + "required": false, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/paths/~1sandboxes~1{sandboxID}~1snapshots/post/requestBody/content/application~1json/schema/properties/name", + "name": "name", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/paths/~1sandboxes~1{sandboxID}~1timeout/post/requestBody/content/application~1json/schema/properties/timeout", + "name": "timeout", + "required": true, + "field_type": "integer", + "format": "int32", + "reference": null + } + ], + "authentication_headers": [ + "Authorization", + "X-API-Key", + "X-Admin-Token", + "X-Supabase-Team", + "X-Supabase-Token", + "X-Team-ID" + ] + }, + { + "name": "envd", + "openapi_version": "3.0.0", + "contract_version": "0.1.3", + "operations": [ + { + "method": "GET", + "path": "/envs", + "operation_id": null, + "tags": [], + "parameters": [], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/EnvVars" + ], + "error": false + } + ] + }, + { + "method": "GET", + "path": "/files", + "operation_id": null, + "tags": [ + "files" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/FilePath" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/Signature" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/SignatureExpiration" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/User" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": "#/components/responses/DownloadSuccess", + "content_types": [], + "schema_references": [ + "#/components/responses/DownloadSuccess" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/InvalidPath", + "content_types": [], + "schema_references": [ + "#/components/responses/InvalidPath" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/InvalidUser", + "content_types": [], + "schema_references": [ + "#/components/responses/InvalidUser" + ], + "error": true + }, + { + "status": "404", + "reference": "#/components/responses/FileNotFound", + "content_types": [], + "schema_references": [ + "#/components/responses/FileNotFound" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/InternalServerError", + "content_types": [], + "schema_references": [ + "#/components/responses/InternalServerError" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/files", + "operation_id": null, + "tags": [ + "files" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/FilePath" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/Signature" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/SignatureExpiration" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/User" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": "#/components/responses/UploadSuccess", + "content_types": [], + "schema_references": [ + "#/components/responses/UploadSuccess" + ], + "error": false + }, + { + "status": "400", + "reference": "#/components/responses/InvalidPath", + "content_types": [], + "schema_references": [ + "#/components/responses/InvalidPath" + ], + "error": true + }, + { + "status": "401", + "reference": "#/components/responses/InvalidUser", + "content_types": [], + "schema_references": [ + "#/components/responses/InvalidUser" + ], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/InternalServerError", + "content_types": [], + "schema_references": [ + "#/components/responses/InternalServerError" + ], + "error": true + }, + { + "status": "507", + "reference": "#/components/responses/NotEnoughDiskSpace", + "content_types": [], + "schema_references": [ + "#/components/responses/NotEnoughDiskSpace" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/health", + "operation_id": null, + "tags": [], + "parameters": [], + "request_content_types": [], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + } + ] + }, + { + "method": "POST", + "path": "/init", + "operation_id": null, + "tags": [], + "parameters": [], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + } + ] + }, + { + "method": "GET", + "path": "/metrics", + "operation_id": null, + "tags": [], + "parameters": [], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/Metrics" + ], + "error": false + } + ] + } + ], + "component_schemas": [ + "EntryInfo", + "EnvVars", + "Error", + "Metrics" + ], + "fields": [ + { + "pointer": "/components/requestBodies/File/content/multipart~1form-data/schema/properties/file", + "name": "file", + "required": false, + "field_type": "string", + "format": "binary", + "reference": null + }, + { + "pointer": "/components/schemas/EntryInfo/properties/metadata", + "name": "metadata", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/EntryInfo/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/EntryInfo/properties/path", + "name": "path", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/EntryInfo/properties/type", + "name": "type", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Error/properties/code", + "name": "code", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Error/properties/message", + "name": "message", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Metrics/properties/cpu_count", + "name": "cpu_count", + "required": false, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Metrics/properties/cpu_used_pct", + "name": "cpu_used_pct", + "required": false, + "field_type": "number", + "format": "float", + "reference": null + }, + { + "pointer": "/components/schemas/Metrics/properties/disk_total", + "name": "disk_total", + "required": false, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Metrics/properties/disk_used", + "name": "disk_used", + "required": false, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Metrics/properties/mem_total", + "name": "mem_total", + "required": false, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Metrics/properties/mem_used", + "name": "mem_used", + "required": false, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Metrics/properties/ts", + "name": "ts", + "required": false, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/paths/~1init/post/requestBody/content/application~1json/schema/properties/accessToken", + "name": "accessToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/paths/~1init/post/requestBody/content/application~1json/schema/properties/defaultUser", + "name": "defaultUser", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/paths/~1init/post/requestBody/content/application~1json/schema/properties/defaultWorkdir", + "name": "defaultWorkdir", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/paths/~1init/post/requestBody/content/application~1json/schema/properties/envVars", + "name": "envVars", + "required": false, + "field_type": null, + "format": null, + "reference": "#/components/schemas/EnvVars" + }, + { + "pointer": "/paths/~1init/post/requestBody/content/application~1json/schema/properties/hyperloopIP", + "name": "hyperloopIP", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/paths/~1init/post/requestBody/content/application~1json/schema/properties/timestamp", + "name": "timestamp", + "required": false, + "field_type": "string", + "format": "date-time", + "reference": null + } + ], + "authentication_headers": [ + "X-Access-Token" + ] + }, + { + "name": "volume-content", + "openapi_version": "3.0.0", + "contract_version": "0.1.0", + "operations": [ + { + "method": "GET", + "path": "/volumecontent/{volumeID}/dir", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/path" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + }, + { + "name": "depth", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/VolumeDirectoryListing" + ], + "error": false + }, + { + "status": "400", + "reference": null, + "content_types": [], + "schema_references": [], + "error": true + }, + { + "status": "404", + "reference": null, + "content_types": [], + "schema_references": [], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "POST", + "path": "/volumecontent/{volumeID}/dir", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/path" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + }, + { + "name": "force", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "gid", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "mode", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "uid", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [], + "responses": [ + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/VolumeEntryStat" + ], + "error": false + }, + { + "status": "404", + "reference": null, + "content_types": [], + "schema_references": [], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/volumecontent/{volumeID}/file", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/path" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/octet-stream" + ], + "schema_references": [], + "error": false + }, + { + "status": "404", + "reference": null, + "content_types": [], + "schema_references": [], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "PUT", + "path": "/volumecontent/{volumeID}/file", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/path" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + }, + { + "name": "force", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "gid", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "mode", + "location": "query", + "required": false, + "reference": null + }, + { + "name": "uid", + "location": "query", + "required": false, + "reference": null + } + ], + "request_content_types": [ + "application/octet-stream" + ], + "responses": [ + { + "status": "201", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/VolumeEntryStat" + ], + "error": false + }, + { + "status": "404", + "reference": null, + "content_types": [], + "schema_references": [], + "error": true + }, + { + "status": "500", + "reference": "#/components/responses/500", + "content_types": [], + "schema_references": [ + "#/components/responses/500" + ], + "error": true + } + ] + }, + { + "method": "DELETE", + "path": "/volumecontent/{volumeID}/path", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/path" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "204", + "reference": null, + "content_types": [], + "schema_references": [], + "error": false + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + } + ] + }, + { + "method": "GET", + "path": "/volumecontent/{volumeID}/path", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/path" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + } + ], + "request_content_types": [], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/VolumeEntryStat" + ], + "error": false + }, + { + "status": "404", + "reference": "#/components/responses/404", + "content_types": [], + "schema_references": [ + "#/components/responses/404" + ], + "error": true + } + ] + }, + { + "method": "PATCH", + "path": "/volumecontent/{volumeID}/path", + "operation_id": null, + "tags": [ + "volumes" + ], + "parameters": [ + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/path" + }, + { + "name": null, + "location": null, + "required": false, + "reference": "#/components/parameters/volumeID" + } + ], + "request_content_types": [ + "application/json" + ], + "responses": [ + { + "status": "200", + "reference": null, + "content_types": [ + "application/json" + ], + "schema_references": [ + "#/components/schemas/VolumeEntryStat" + ], + "error": false + }, + { + "status": "400", + "reference": null, + "content_types": [], + "schema_references": [], + "error": true + }, + { + "status": "404", + "reference": null, + "content_types": [], + "schema_references": [], + "error": true + }, + { + "status": "500", + "reference": null, + "content_types": [], + "schema_references": [], + "error": true + } + ] + } + ], + "component_schemas": [ + "Error", + "VolumeDirectoryListing", + "VolumeEntryStat" + ], + "fields": [ + { + "pointer": "/components/schemas/Error/properties/code", + "name": "code", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/Error/properties/message", + "name": "message", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/atime", + "name": "atime", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/ctime", + "name": "ctime", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/gid", + "name": "gid", + "required": true, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/mode", + "name": "mode", + "required": true, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/mtime", + "name": "mtime", + "required": true, + "field_type": "string", + "format": "date-time", + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/name", + "name": "name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/path", + "name": "path", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/size", + "name": "size", + "required": true, + "field_type": "integer", + "format": "int64", + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/target", + "name": "target", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/type", + "name": "type", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/components/schemas/VolumeEntryStat/properties/uid", + "name": "uid", + "required": true, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/paths/~1volumecontent~1{volumeID}~1path/patch/requestBody/content/application~1json/schema/properties/gid", + "name": "gid", + "required": false, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/paths/~1volumecontent~1{volumeID}~1path/patch/requestBody/content/application~1json/schema/properties/mode", + "name": "mode", + "required": false, + "field_type": "integer", + "format": "uint32", + "reference": null + }, + { + "pointer": "/paths/~1volumecontent~1{volumeID}~1path/patch/requestBody/content/application~1json/schema/properties/uid", + "name": "uid", + "required": false, + "field_type": "integer", + "format": "uint32", + "reference": null + } + ], + "authentication_headers": [ + "Authorization" + ] + } + ], + "protobuf": [ + { + "path": "filesystem/filesystem.proto", + "package": "filesystem", + "descriptor_digest": "sha256:d32a8691bbca78d6059468c35468121ff204968384ebe3d87fafc205626023d4", + "services": [ + { + "name": "Filesystem", + "methods": [ + { + "name": "CreateWatcher", + "input_type": ".filesystem.CreateWatcherRequest", + "output_type": ".filesystem.CreateWatcherResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "GetWatcherEvents", + "input_type": ".filesystem.GetWatcherEventsRequest", + "output_type": ".filesystem.GetWatcherEventsResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "ListDir", + "input_type": ".filesystem.ListDirRequest", + "output_type": ".filesystem.ListDirResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "MakeDir", + "input_type": ".filesystem.MakeDirRequest", + "output_type": ".filesystem.MakeDirResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "Move", + "input_type": ".filesystem.MoveRequest", + "output_type": ".filesystem.MoveResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "Remove", + "input_type": ".filesystem.RemoveRequest", + "output_type": ".filesystem.RemoveResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "RemoveWatcher", + "input_type": ".filesystem.RemoveWatcherRequest", + "output_type": ".filesystem.RemoveWatcherResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "Stat", + "input_type": ".filesystem.StatRequest", + "output_type": ".filesystem.StatResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "WatchDir", + "input_type": ".filesystem.WatchDirRequest", + "output_type": ".filesystem.WatchDirResponse", + "client_streaming": false, + "server_streaming": true + } + ] + } + ], + "messages": [ + { + "name": "CreateWatcherRequest", + "fields": [ + { + "name": "path", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "recursive", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BOOL", + "type_name": null, + "oneof": null + }, + { + "name": "include_entry", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BOOL", + "type_name": null, + "oneof": null + }, + { + "name": "allow_network_mounts", + "number": 4, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BOOL", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "CreateWatcherResponse", + "fields": [ + { + "name": "watcher_id", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "EntryInfo", + "fields": [ + { + "name": "name", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "type", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_ENUM", + "type_name": ".filesystem.FileType", + "oneof": null + }, + { + "name": "path", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "size", + "number": 4, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_INT64", + "type_name": null, + "oneof": null + }, + { + "name": "mode", + "number": 5, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_UINT32", + "type_name": null, + "oneof": null + }, + { + "name": "permissions", + "number": 6, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "owner", + "number": 7, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "group", + "number": 8, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "modified_time", + "number": 9, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".google.protobuf.Timestamp", + "oneof": null + }, + { + "name": "symlink_target", + "number": 10, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": "_symlink_target" + }, + { + "name": "metadata", + "number": 11, + "label": "LABEL_REPEATED", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.EntryInfo.MetadataEntry", + "oneof": null + } + ] + }, + { + "name": "EntryInfo.MetadataEntry", + "fields": [ + { + "name": "key", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "value", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "FilesystemEvent", + "fields": [ + { + "name": "name", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "type", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_ENUM", + "type_name": ".filesystem.EventType", + "oneof": null + }, + { + "name": "entry", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.EntryInfo", + "oneof": "_entry" + } + ] + }, + { + "name": "GetWatcherEventsRequest", + "fields": [ + { + "name": "watcher_id", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "GetWatcherEventsResponse", + "fields": [ + { + "name": "events", + "number": 1, + "label": "LABEL_REPEATED", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.FilesystemEvent", + "oneof": null + } + ] + }, + { + "name": "ListDirRequest", + "fields": [ + { + "name": "path", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "depth", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_UINT32", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "ListDirResponse", + "fields": [ + { + "name": "entries", + "number": 1, + "label": "LABEL_REPEATED", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.EntryInfo", + "oneof": null + } + ] + }, + { + "name": "MakeDirRequest", + "fields": [ + { + "name": "path", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "MakeDirResponse", + "fields": [ + { + "name": "entry", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.EntryInfo", + "oneof": null + } + ] + }, + { + "name": "MoveRequest", + "fields": [ + { + "name": "source", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "destination", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "MoveResponse", + "fields": [ + { + "name": "entry", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.EntryInfo", + "oneof": null + } + ] + }, + { + "name": "RemoveRequest", + "fields": [ + { + "name": "path", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "RemoveResponse", + "fields": [] + }, + { + "name": "RemoveWatcherRequest", + "fields": [ + { + "name": "watcher_id", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "RemoveWatcherResponse", + "fields": [] + }, + { + "name": "StatRequest", + "fields": [ + { + "name": "path", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "StatResponse", + "fields": [ + { + "name": "entry", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.EntryInfo", + "oneof": null + } + ] + }, + { + "name": "WatchDirRequest", + "fields": [ + { + "name": "path", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "recursive", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BOOL", + "type_name": null, + "oneof": null + }, + { + "name": "include_entry", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BOOL", + "type_name": null, + "oneof": null + }, + { + "name": "allow_network_mounts", + "number": 4, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BOOL", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "WatchDirResponse", + "fields": [ + { + "name": "start", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.WatchDirResponse.StartEvent", + "oneof": "event" + }, + { + "name": "filesystem", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.FilesystemEvent", + "oneof": "event" + }, + { + "name": "keepalive", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".filesystem.WatchDirResponse.KeepAlive", + "oneof": "event" + } + ] + }, + { + "name": "WatchDirResponse.KeepAlive", + "fields": [] + }, + { + "name": "WatchDirResponse.StartEvent", + "fields": [] + } + ], + "enums": [ + { + "name": "EventType", + "values": [ + { + "name": "EVENT_TYPE_UNSPECIFIED", + "number": 0 + }, + { + "name": "EVENT_TYPE_CREATE", + "number": 1 + }, + { + "name": "EVENT_TYPE_WRITE", + "number": 2 + }, + { + "name": "EVENT_TYPE_REMOVE", + "number": 3 + }, + { + "name": "EVENT_TYPE_RENAME", + "number": 4 + }, + { + "name": "EVENT_TYPE_CHMOD", + "number": 5 + } + ] + }, + { + "name": "FileType", + "values": [ + { + "name": "FILE_TYPE_UNSPECIFIED", + "number": 0 + }, + { + "name": "FILE_TYPE_FILE", + "number": 1 + }, + { + "name": "FILE_TYPE_DIRECTORY", + "number": 2 + } + ] + } + ] + }, + { + "path": "process/process.proto", + "package": "process", + "descriptor_digest": "sha256:95d48ccdafd24a7bb80b9129ce71558f6c4863368fddf6f484ca5e856a26990d", + "services": [ + { + "name": "Process", + "methods": [ + { + "name": "CloseStdin", + "input_type": ".process.CloseStdinRequest", + "output_type": ".process.CloseStdinResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "Connect", + "input_type": ".process.ConnectRequest", + "output_type": ".process.ConnectResponse", + "client_streaming": false, + "server_streaming": true + }, + { + "name": "List", + "input_type": ".process.ListRequest", + "output_type": ".process.ListResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "SendInput", + "input_type": ".process.SendInputRequest", + "output_type": ".process.SendInputResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "SendSignal", + "input_type": ".process.SendSignalRequest", + "output_type": ".process.SendSignalResponse", + "client_streaming": false, + "server_streaming": false + }, + { + "name": "Start", + "input_type": ".process.StartRequest", + "output_type": ".process.StartResponse", + "client_streaming": false, + "server_streaming": true + }, + { + "name": "StreamInput", + "input_type": ".process.StreamInputRequest", + "output_type": ".process.StreamInputResponse", + "client_streaming": true, + "server_streaming": false + }, + { + "name": "Update", + "input_type": ".process.UpdateRequest", + "output_type": ".process.UpdateResponse", + "client_streaming": false, + "server_streaming": false + } + ] + } + ], + "messages": [ + { + "name": "CloseStdinRequest", + "fields": [ + { + "name": "process", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessSelector", + "oneof": null + } + ] + }, + { + "name": "CloseStdinResponse", + "fields": [] + }, + { + "name": "ConnectRequest", + "fields": [ + { + "name": "process", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessSelector", + "oneof": null + } + ] + }, + { + "name": "ConnectResponse", + "fields": [ + { + "name": "event", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessEvent", + "oneof": null + } + ] + }, + { + "name": "ListRequest", + "fields": [] + }, + { + "name": "ListResponse", + "fields": [ + { + "name": "processes", + "number": 1, + "label": "LABEL_REPEATED", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessInfo", + "oneof": null + } + ] + }, + { + "name": "PTY", + "fields": [ + { + "name": "size", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.PTY.Size", + "oneof": null + } + ] + }, + { + "name": "PTY.Size", + "fields": [ + { + "name": "cols", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_UINT32", + "type_name": null, + "oneof": null + }, + { + "name": "rows", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_UINT32", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "ProcessConfig", + "fields": [ + { + "name": "cmd", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "args", + "number": 2, + "label": "LABEL_REPEATED", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "envs", + "number": 3, + "label": "LABEL_REPEATED", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessConfig.EnvsEntry", + "oneof": null + }, + { + "name": "cwd", + "number": 4, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": "_cwd" + } + ] + }, + { + "name": "ProcessConfig.EnvsEntry", + "fields": [ + { + "name": "key", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "value", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "ProcessEvent", + "fields": [ + { + "name": "start", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessEvent.StartEvent", + "oneof": "event" + }, + { + "name": "data", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessEvent.DataEvent", + "oneof": "event" + }, + { + "name": "end", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessEvent.EndEvent", + "oneof": "event" + }, + { + "name": "keepalive", + "number": 4, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessEvent.KeepAlive", + "oneof": "event" + } + ] + }, + { + "name": "ProcessEvent.DataEvent", + "fields": [ + { + "name": "stdout", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BYTES", + "type_name": null, + "oneof": "output" + }, + { + "name": "stderr", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BYTES", + "type_name": null, + "oneof": "output" + }, + { + "name": "pty", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BYTES", + "type_name": null, + "oneof": "output" + } + ] + }, + { + "name": "ProcessEvent.EndEvent", + "fields": [ + { + "name": "exit_code", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_SINT32", + "type_name": null, + "oneof": null + }, + { + "name": "exited", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BOOL", + "type_name": null, + "oneof": null + }, + { + "name": "status", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": null + }, + { + "name": "error", + "number": 4, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": "_error" + } + ] + }, + { + "name": "ProcessEvent.KeepAlive", + "fields": [] + }, + { + "name": "ProcessEvent.StartEvent", + "fields": [ + { + "name": "pid", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_UINT32", + "type_name": null, + "oneof": null + } + ] + }, + { + "name": "ProcessInfo", + "fields": [ + { + "name": "config", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessConfig", + "oneof": null + }, + { + "name": "pid", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_UINT32", + "type_name": null, + "oneof": null + }, + { + "name": "tag", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": "_tag" + } + ] + }, + { + "name": "ProcessInput", + "fields": [ + { + "name": "stdin", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BYTES", + "type_name": null, + "oneof": "input" + }, + { + "name": "pty", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BYTES", + "type_name": null, + "oneof": "input" + } + ] + }, + { + "name": "ProcessSelector", + "fields": [ + { + "name": "pid", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_UINT32", + "type_name": null, + "oneof": "selector" + }, + { + "name": "tag", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": "selector" + } + ] + }, + { + "name": "SendInputRequest", + "fields": [ + { + "name": "process", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessSelector", + "oneof": null + }, + { + "name": "input", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessInput", + "oneof": null + } + ] + }, + { + "name": "SendInputResponse", + "fields": [] + }, + { + "name": "SendSignalRequest", + "fields": [ + { + "name": "process", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessSelector", + "oneof": null + }, + { + "name": "signal", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_ENUM", + "type_name": ".process.Signal", + "oneof": null + } + ] + }, + { + "name": "SendSignalResponse", + "fields": [] + }, + { + "name": "StartRequest", + "fields": [ + { + "name": "process", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessConfig", + "oneof": null + }, + { + "name": "pty", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.PTY", + "oneof": "_pty" + }, + { + "name": "tag", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_STRING", + "type_name": null, + "oneof": "_tag" + }, + { + "name": "stdin", + "number": 4, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_BOOL", + "type_name": null, + "oneof": "_stdin" + } + ] + }, + { + "name": "StartResponse", + "fields": [ + { + "name": "event", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessEvent", + "oneof": null + } + ] + }, + { + "name": "StreamInputRequest", + "fields": [ + { + "name": "start", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.StreamInputRequest.StartEvent", + "oneof": "event" + }, + { + "name": "data", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.StreamInputRequest.DataEvent", + "oneof": "event" + }, + { + "name": "keepalive", + "number": 3, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.StreamInputRequest.KeepAlive", + "oneof": "event" + } + ] + }, + { + "name": "StreamInputRequest.DataEvent", + "fields": [ + { + "name": "input", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessInput", + "oneof": null + } + ] + }, + { + "name": "StreamInputRequest.KeepAlive", + "fields": [] + }, + { + "name": "StreamInputRequest.StartEvent", + "fields": [ + { + "name": "process", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessSelector", + "oneof": null + } + ] + }, + { + "name": "StreamInputResponse", + "fields": [] + }, + { + "name": "UpdateRequest", + "fields": [ + { + "name": "process", + "number": 1, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.ProcessSelector", + "oneof": null + }, + { + "name": "pty", + "number": 2, + "label": "LABEL_OPTIONAL", + "field_type": "TYPE_MESSAGE", + "type_name": ".process.PTY", + "oneof": "_pty" + } + ] + }, + { + "name": "UpdateResponse", + "fields": [] + } + ], + "enums": [ + { + "name": "Signal", + "values": [ + { + "name": "SIGNAL_UNSPECIFIED", + "number": 0 + }, + { + "name": "SIGNAL_SIGKILL", + "number": 9 + }, + { + "name": "SIGNAL_SIGTERM", + "number": 15 + } + ] + } + ] + } + ], + "mcp": { + "schema_id": null, + "title": null, + "fields": [ + { + "pointer": "/properties/airtable", + "name": "airtable", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/airtable/properties/airtableApiKey", + "name": "airtableApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/airtable/properties/nodeenv", + "name": "nodeenv", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/aks", + "name": "aks", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/aks/properties/accessLevel", + "name": "accessLevel", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/aks/properties/additionalTools", + "name": "additionalTools", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/aks/properties/allowNamespaces", + "name": "allowNamespaces", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/aks/properties/azureDir", + "name": "azureDir", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/aks/properties/containerUser", + "name": "containerUser", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/aks/properties/kubeconfig", + "name": "kubeconfig", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/apiGateway", + "name": "apiGateway", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/apiGateway/properties/api1HeaderAuthorization", + "name": "api1HeaderAuthorization", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/apiGateway/properties/api1Name", + "name": "api1Name", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/apiGateway/properties/api1SwaggerUrl", + "name": "api1SwaggerUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/apify", + "name": "apify", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/apify/properties/apifyToken", + "name": "apifyToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/apify/properties/tools", + "name": "tools", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/arxiv", + "name": "arxiv", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/arxiv/properties/storagePath", + "name": "storagePath", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/astGrep", + "name": "astGrep", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/astGrep/properties/path", + "name": "path", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/astraDb", + "name": "astraDb", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/astraDb/properties/astraDbApplicationToken", + "name": "astraDbApplicationToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/astraDb/properties/endpoint", + "name": "endpoint", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/astroDocs", + "name": "astroDocs", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlan", + "name": "atlan", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlan/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlan/properties/baseUrl", + "name": "baseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlasDocs", + "name": "atlasDocs", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlasDocs/properties/apiUrl", + "name": "apiUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian", + "name": "atlassian", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian/properties/confluenceApiToken", + "name": "confluenceApiToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian/properties/confluencePersonalToken", + "name": "confluencePersonalToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian/properties/confluenceUrl", + "name": "confluenceUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian/properties/confluenceUsername", + "name": "confluenceUsername", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian/properties/jiraApiToken", + "name": "jiraApiToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian/properties/jiraPersonalToken", + "name": "jiraPersonalToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian/properties/jiraUrl", + "name": "jiraUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/atlassian/properties/jiraUsername", + "name": "jiraUsername", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/audienseInsights", + "name": "audienseInsights", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/audienseInsights/properties/audienseClientSecret", + "name": "audienseClientSecret", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/audienseInsights/properties/clientId", + "name": "clientId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/audienseInsights/properties/twitterBearerToken", + "name": "twitterBearerToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/awsCdk", + "name": "awsCdk", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/awsCore", + "name": "awsCore", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/awsDiagram", + "name": "awsDiagram", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/awsDocumentation", + "name": "awsDocumentation", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/awsKbRetrievalServer", + "name": "awsKbRetrievalServer", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/awsKbRetrievalServer/properties/accessKeyId", + "name": "accessKeyId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/awsKbRetrievalServer/properties/awsSecretAccessKey", + "name": "awsSecretAccessKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/awsTerraform", + "name": "awsTerraform", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/azure", + "name": "azure", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/beagleSecurity", + "name": "beagleSecurity", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/beagleSecurity/properties/beagleSecurityApiToken", + "name": "beagleSecurityApiToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/bitrefill", + "name": "bitrefill", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/bitrefill/properties/apiId", + "name": "apiId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/bitrefill/properties/apiSecret", + "name": "apiSecret", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/box", + "name": "box", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/box/properties/clientId", + "name": "clientId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/box/properties/clientSecret", + "name": "clientSecret", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/brave", + "name": "brave", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/brave/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/browserbase", + "name": "browserbase", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/browserbase/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/browserbase/properties/geminiApiKey", + "name": "geminiApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/browserbase/properties/projectId", + "name": "projectId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/buildkite", + "name": "buildkite", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/buildkite/properties/apiToken", + "name": "apiToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/camunda", + "name": "camunda", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/camunda/properties/camundahost", + "name": "camundahost", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cdataConnectcloud", + "name": "cdataConnectcloud", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cdataConnectcloud/properties/cdataPat", + "name": "cdataPat", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cdataConnectcloud/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/charmhealth", + "name": "charmhealth", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/charmhealth/properties/charmhealthApiKey", + "name": "charmhealthApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/charmhealth/properties/charmhealthBaseUrl", + "name": "charmhealthBaseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/charmhealth/properties/charmhealthClientId", + "name": "charmhealthClientId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/charmhealth/properties/charmhealthClientSecret", + "name": "charmhealthClientSecret", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/charmhealth/properties/charmhealthRedirectUri", + "name": "charmhealthRedirectUri", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/charmhealth/properties/charmhealthRefreshToken", + "name": "charmhealthRefreshToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/charmhealth/properties/charmhealthTokenUrl", + "name": "charmhealthTokenUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/chroma", + "name": "chroma", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/chroma/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/circleci", + "name": "circleci", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/circleci/properties/token", + "name": "token", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/circleci/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse", + "name": "clickhouse", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse/properties/connectTimeout", + "name": "connectTimeout", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse/properties/host", + "name": "host", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse/properties/password", + "name": "password", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse/properties/port", + "name": "port", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse/properties/secure", + "name": "secure", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse/properties/sendReceiveTimeout", + "name": "sendReceiveTimeout", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse/properties/user", + "name": "user", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/clickhouse/properties/verify", + "name": "verify", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/close", + "name": "close", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/close/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cloudRun", + "name": "cloudRun", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cloudRun/properties/credentialsPath", + "name": "credentialsPath", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cloudflareDocs", + "name": "cloudflareDocs", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb", + "name": "cockroachdb", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/caPath", + "name": "caPath", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/crdbPwd", + "name": "crdbPwd", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/database", + "name": "database", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/host", + "name": "host", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/port", + "name": "port", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/sslCertfile", + "name": "sslCertfile", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/sslKeyfile", + "name": "sslKeyfile", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/sslMode", + "name": "sslMode", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cockroachdb/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/codeInterpreter", + "name": "codeInterpreter", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/context7", + "name": "context7", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/couchbase", + "name": "couchbase", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/couchbase/properties/cbBucketName", + "name": "cbBucketName", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/couchbase/properties/cbConnectionString", + "name": "cbConnectionString", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/couchbase/properties/cbMcpReadOnlyQueryMode", + "name": "cbMcpReadOnlyQueryMode", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/couchbase/properties/cbPassword", + "name": "cbPassword", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/couchbase/properties/cbUsername", + "name": "cbUsername", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cylera", + "name": "cylera", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cylera/properties/cyleraBaseUrl", + "name": "cyleraBaseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cylera/properties/cyleraPassword", + "name": "cyleraPassword", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cylera/properties/cyleraUsername", + "name": "cyleraUsername", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cyreslabAiShodan", + "name": "cyreslabAiShodan", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/cyreslabAiShodan/properties/shodanApiKey", + "name": "shodanApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dappier", + "name": "dappier", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dappier/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dappierRemote", + "name": "dappierRemote", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dappierRemote/properties/dappierRemoteApiKey", + "name": "dappierRemoteApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dart", + "name": "dart", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dart/properties/host", + "name": "host", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dart/properties/token", + "name": "token", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/databaseServer", + "name": "databaseServer", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/databaseServer/properties/databaseUrl", + "name": "databaseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/databutton", + "name": "databutton", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/deepwiki", + "name": "deepwiki", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/descope", + "name": "descope", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/descope/properties/managementKey", + "name": "managementKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/descope/properties/projectId", + "name": "projectId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/desktopCommander", + "name": "desktopCommander", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/desktopCommander/properties/paths", + "name": "paths", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/properties/devhubCms", + "name": "devhubCms", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/devhubCms/properties/devhubApiKey", + "name": "devhubApiKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/devhubCms/properties/devhubApiSecret", + "name": "devhubApiSecret", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/devhubCms/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/discord", + "name": "discord", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/discord/properties/discordToken", + "name": "discordToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dockerhub", + "name": "dockerhub", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dockerhub/properties/hubPatToken", + "name": "hubPatToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dockerhub/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dodoPayments", + "name": "dodoPayments", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dodoPayments/properties/dodoPaymentsApiKey", + "name": "dodoPaymentsApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dreamfactory", + "name": "dreamfactory", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dreamfactory/properties/dreamfactoryapikey", + "name": "dreamfactoryapikey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dreamfactory/properties/dreamfactoryurl", + "name": "dreamfactoryurl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/duckduckgo", + "name": "duckduckgo", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dynatrace", + "name": "dynatrace", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dynatrace/properties/oauthClientId", + "name": "oauthClientId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dynatrace/properties/oauthClientSecret", + "name": "oauthClientSecret", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/dynatrace/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/e2b", + "name": "e2b", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/e2b/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/edubase", + "name": "edubase", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/edubase/properties/apiKey", + "name": "apiKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/edubase/properties/app", + "name": "app", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/edubase/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/effect", + "name": "effect", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/elasticsearch", + "name": "elasticsearch", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/elasticsearch/properties/esApiKey", + "name": "esApiKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/elasticsearch/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/elevenlabs", + "name": "elevenlabs", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/elevenlabs/properties/apiKey", + "name": "apiKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/elevenlabs/properties/data", + "name": "data", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/everart", + "name": "everart", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/everart/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/exa", + "name": "exa", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/exa/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/explorium", + "name": "explorium", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/explorium/properties/apiAccessToken", + "name": "apiAccessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/fetch", + "name": "fetch", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/fibery", + "name": "fibery", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/fibery/properties/apiToken", + "name": "apiToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/fibery/properties/host", + "name": "host", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/filesystem", + "name": "filesystem", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/filesystem/properties/paths", + "name": "paths", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/properties/findADomain", + "name": "findADomain", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl", + "name": "firecrawl", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl/properties/creditCriticalThreshold", + "name": "creditCriticalThreshold", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl/properties/creditWarningThreshold", + "name": "creditWarningThreshold", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl/properties/retryBackoffFactor", + "name": "retryBackoffFactor", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl/properties/retryDelay", + "name": "retryDelay", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl/properties/retryMax", + "name": "retryMax", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl/properties/retryMaxDelay", + "name": "retryMaxDelay", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firecrawl/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firewalla", + "name": "firewalla", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firewalla/properties/boxId", + "name": "boxId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firewalla/properties/firewallaMspToken", + "name": "firewallaMspToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/firewalla/properties/mspId", + "name": "mspId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/flexprice", + "name": "flexprice", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/flexprice/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/flexprice/properties/baseUrl", + "name": "baseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/git", + "name": "git", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/git/properties/paths", + "name": "paths", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/properties/github", + "name": "github", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/github/properties/personalAccessToken", + "name": "personalAccessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/githubChat", + "name": "githubChat", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/githubChat/properties/githubApiKey", + "name": "githubApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/githubOfficial", + "name": "githubOfficial", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/githubOfficial/properties/githubPersonalAccessToken", + "name": "githubPersonalAccessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gitlab", + "name": "gitlab", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gitlab/properties/personalAccessToken", + "name": "personalAccessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gitlab/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gitmcp", + "name": "gitmcp", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/glif", + "name": "glif", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/glif/properties/apiToken", + "name": "apiToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/glif/properties/ids", + "name": "ids", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/glif/properties/ignoredSaved", + "name": "ignoredSaved", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gmail", + "name": "gmail", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gmail/properties/emailAddress", + "name": "emailAddress", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gmail/properties/emailPassword", + "name": "emailPassword", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/googleMaps", + "name": "googleMaps", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/googleMaps/properties/googleMapsApiKey", + "name": "googleMapsApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/googleMapsComprehensive", + "name": "googleMapsComprehensive", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/googleMapsComprehensive/properties/googleMapsApiKey", + "name": "googleMapsApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/grafana", + "name": "grafana", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/grafana/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/grafana/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gyazo", + "name": "gyazo", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/gyazo/properties/accessToken", + "name": "accessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hackernews", + "name": "hackernews", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hackle", + "name": "hackle", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hackle/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/handwritingOcr", + "name": "handwritingOcr", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/handwritingOcr/properties/apiToken", + "name": "apiToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hdx", + "name": "hdx", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hdx/properties/appIdentifier", + "name": "appIdentifier", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/heroku", + "name": "heroku", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/heroku/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hostinger", + "name": "hostinger", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hostinger/properties/apitoken", + "name": "apitoken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hoverfly", + "name": "hoverfly", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hoverfly/properties/data", + "name": "data", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hubspot", + "name": "hubspot", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hubspot/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/huggingFace", + "name": "huggingFace", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hummingbot", + "name": "hummingbot", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hummingbot/properties/apiUrl", + "name": "apiUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hummingbot/properties/hummingbotApiPassword", + "name": "hummingbotApiPassword", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hummingbot/properties/hummingbotApiUsername", + "name": "hummingbotApiUsername", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/husqvarnaAutomower", + "name": "husqvarnaAutomower", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/husqvarnaAutomower/properties/clientId", + "name": "clientId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/husqvarnaAutomower/properties/husqvarnaClientSecret", + "name": "husqvarnaClientSecret", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hyperbrowser", + "name": "hyperbrowser", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hyperbrowser/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hyperspell", + "name": "hyperspell", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hyperspell/properties/collection", + "name": "collection", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hyperspell/properties/token", + "name": "token", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/hyperspell/properties/useResources", + "name": "useResources", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/properties/iaptic", + "name": "iaptic", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/iaptic/properties/apiKey", + "name": "apiKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/iaptic/properties/appName", + "name": "appName", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/inspektorGadget", + "name": "inspektorGadget", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/inspektorGadget/properties/gadgetImages", + "name": "gadgetImages", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/inspektorGadget/properties/kubeconfig", + "name": "kubeconfig", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/javadocs", + "name": "javadocs", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/jetbrains", + "name": "jetbrains", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/jetbrains/properties/port", + "name": "port", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kafkaSchemaReg", + "name": "kafkaSchemaReg", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kafkaSchemaReg/properties/registryUrl", + "name": "registryUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kafkaSchemaReg/properties/schemaRegistryPassword", + "name": "schemaRegistryPassword", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kafkaSchemaReg/properties/schemaRegistryUser", + "name": "schemaRegistryUser", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kafkaSchemaReg/properties/slimMode", + "name": "slimMode", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kafkaSchemaReg/properties/viewonly", + "name": "viewonly", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kagisearch", + "name": "kagisearch", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kagisearch/properties/engine", + "name": "engine", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kagisearch/properties/kagiApiKey", + "name": "kagiApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/keboola", + "name": "keboola", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/keboola/properties/kbcStorageToken", + "name": "kbcStorageToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/keboola/properties/kbcWorkspaceSchema", + "name": "kbcWorkspaceSchema", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kong", + "name": "kong", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kong/properties/konnectAccessToken", + "name": "konnectAccessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kong/properties/region", + "name": "region", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kubectl", + "name": "kubectl", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kubectl/properties/kubeconfig", + "name": "kubeconfig", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kubernetes", + "name": "kubernetes", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/kubernetes/properties/configPath", + "name": "configPath", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/lara", + "name": "lara", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/lara/properties/accessKeySecret", + "name": "accessKeySecret", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/lara/properties/keyId", + "name": "keyId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/line", + "name": "line", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/line/properties/channelAccessToken", + "name": "channelAccessToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/line/properties/userId", + "name": "userId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/linkedin", + "name": "linkedin", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/linkedin/properties/linkedinCookie", + "name": "linkedinCookie", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/linkedin/properties/userAgent", + "name": "userAgent", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/llmtxt", + "name": "llmtxt", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/maestro", + "name": "maestro", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/maestro/properties/apiKeyApiKey", + "name": "apiKeyApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/manifold", + "name": "manifold", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mapbox", + "name": "mapbox", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mapbox/properties/accessToken", + "name": "accessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mapboxDevkit", + "name": "mapboxDevkit", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mapboxDevkit/properties/mapboxAccessToken", + "name": "mapboxAccessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/markdownify", + "name": "markdownify", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/markdownify/properties/paths", + "name": "paths", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/properties/markitdown", + "name": "markitdown", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/markitdown/properties/paths", + "name": "paths", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mavenTools", + "name": "mavenTools", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/memory", + "name": "memory", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mercadoLibre", + "name": "mercadoLibre", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mercadoLibre/properties/mercadoLibreApiKey", + "name": "mercadoLibreApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mercadoPago", + "name": "mercadoPago", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mercadoPago/properties/mercadoPagoApiKey", + "name": "mercadoPagoApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/metabase", + "name": "metabase", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/metabase/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/metabase/properties/metabaseurl", + "name": "metabaseurl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/metabase/properties/metabaseusername", + "name": "metabaseusername", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/metabase/properties/password", + "name": "password", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/minecraftWiki", + "name": "minecraftWiki", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mongodb", + "name": "mongodb", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/mongodb/properties/mdbMcpConnectionString", + "name": "mdbMcpConnectionString", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/multiversxMx", + "name": "multiversxMx", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/multiversxMx/properties/network", + "name": "network", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/multiversxMx/properties/wallet", + "name": "wallet", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/nasdaqDataLink", + "name": "nasdaqDataLink", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/nasdaqDataLink/properties/nasdaqDataLinkApiKey", + "name": "nasdaqDataLinkApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/needle", + "name": "needle", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/needle/properties/needleApiKey", + "name": "needleApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi", + "name": "neo4jCloudAuraApi", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi/properties/clientId", + "name": "clientId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi/properties/neo4jAuraClientSecret", + "name": "neo4jAuraClientSecret", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi/properties/serverAllowOrigins", + "name": "serverAllowOrigins", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi/properties/serverAllowedHosts", + "name": "serverAllowedHosts", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi/properties/serverHost", + "name": "serverHost", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi/properties/serverPath", + "name": "serverPath", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi/properties/serverPort", + "name": "serverPort", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCloudAuraApi/properties/transport", + "name": "transport", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher", + "name": "neo4jCypher", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/database", + "name": "database", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/namespace", + "name": "namespace", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/neo4jPassword", + "name": "neo4jPassword", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/readOnly", + "name": "readOnly", + "required": false, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/readTimeout", + "name": "readTimeout", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/responseTokenLimit", + "name": "responseTokenLimit", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/serverAllowOrigins", + "name": "serverAllowOrigins", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/serverAllowedHosts", + "name": "serverAllowedHosts", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/serverHost", + "name": "serverHost", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/serverPath", + "name": "serverPath", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/serverPort", + "name": "serverPort", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/transport", + "name": "transport", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jCypher/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jDataModeling", + "name": "neo4jDataModeling", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jDataModeling/properties/serverAllowOrigins", + "name": "serverAllowOrigins", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jDataModeling/properties/serverAllowedHosts", + "name": "serverAllowedHosts", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jDataModeling/properties/serverHost", + "name": "serverHost", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jDataModeling/properties/serverPath", + "name": "serverPath", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jDataModeling/properties/serverPort", + "name": "serverPort", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jDataModeling/properties/transport", + "name": "transport", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory", + "name": "neo4jMemory", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/database", + "name": "database", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/neo4jPassword", + "name": "neo4jPassword", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/serverAllowOrigins", + "name": "serverAllowOrigins", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/serverAllowedHosts", + "name": "serverAllowedHosts", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/serverHost", + "name": "serverHost", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/serverPath", + "name": "serverPath", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/serverPort", + "name": "serverPort", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/transport", + "name": "transport", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neo4jMemory/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neon", + "name": "neon", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/neon/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/nodeCodeSandbox", + "name": "nodeCodeSandbox", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/notion", + "name": "notion", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/notion/properties/internalIntegrationToken", + "name": "internalIntegrationToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/novita", + "name": "novita", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/npmSentinel", + "name": "npmSentinel", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/obsidian", + "name": "obsidian", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/obsidian/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oktaMcpFctr", + "name": "oktaMcpFctr", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oktaMcpFctr/properties/clientOrgurl", + "name": "clientOrgurl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oktaMcpFctr/properties/concurrentLimit", + "name": "concurrentLimit", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oktaMcpFctr/properties/logLevel", + "name": "logLevel", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oktaMcpFctr/properties/oktaApiToken", + "name": "oktaApiToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/omi", + "name": "omi", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/omi/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace", + "name": "onlyofficeDocspace", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/baseUrl", + "name": "baseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/docspaceApiKey", + "name": "docspaceApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/docspaceAuthToken", + "name": "docspaceAuthToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/docspacePassword", + "name": "docspacePassword", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/docspaceUsername", + "name": "docspaceUsername", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/dynamic", + "name": "dynamic", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/origin", + "name": "origin", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/toolsets", + "name": "toolsets", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/onlyofficeDocspace/properties/userAgent", + "name": "userAgent", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openapi", + "name": "openapi", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openapi/properties/mode", + "name": "mode", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openapiSchema", + "name": "openapiSchema", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openapiSchema/properties/SchemaPath", + "name": "SchemaPath", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openbnbAirbnb", + "name": "openbnbAirbnb", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openmesh", + "name": "openmesh", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openweather", + "name": "openweather", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openweather/properties/owmApiKey", + "name": "owmApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openzeppelinCairo", + "name": "openzeppelinCairo", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openzeppelinSolidity", + "name": "openzeppelinSolidity", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openzeppelinStellar", + "name": "openzeppelinStellar", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/openzeppelinStylus", + "name": "openzeppelinStylus", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/opik", + "name": "opik", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/opik/properties/apiBaseUrl", + "name": "apiBaseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/opik/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/opik/properties/workspaceName", + "name": "workspaceName", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/opine", + "name": "opine", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/opine/properties/opineApiKey", + "name": "opineApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oracle", + "name": "oracle", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oracle/properties/oracleConnectionString", + "name": "oracleConnectionString", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oracle/properties/oracleUser", + "name": "oracleUser", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oracle/properties/password", + "name": "password", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/ospMarketingTools", + "name": "ospMarketingTools", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oxylabs", + "name": "oxylabs", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oxylabs/properties/password", + "name": "password", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/oxylabs/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/paperSearch", + "name": "paperSearch", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/perplexityAsk", + "name": "perplexityAsk", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/perplexityAsk/properties/perplexityApiKey", + "name": "perplexityApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pia", + "name": "pia", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pia/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pinecone", + "name": "pinecone", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pinecone/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pinecone/properties/assistantHost", + "name": "assistantHost", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/playwright", + "name": "playwright", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/playwright/properties/data", + "name": "data", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pluggedinMcpProxy", + "name": "pluggedinMcpProxy", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pluggedinMcpProxy/properties/pluggedinApiBaseUrl", + "name": "pluggedinApiBaseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pluggedinMcpProxy/properties/pluggedinApiKey", + "name": "pluggedinApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/polarSignals", + "name": "polarSignals", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/polarSignals/properties/polarSignalsApiKey", + "name": "polarSignalsApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pomodash", + "name": "pomodash", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pomodash/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/postgres", + "name": "postgres", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/postgres/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/postman", + "name": "postman", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/postman/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/prefEditor", + "name": "prefEditor", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/prometheus", + "name": "prometheus", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/prometheus/properties/prometheusUrl", + "name": "prometheusUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/puppeteer", + "name": "puppeteer", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/pythonRefactoring", + "name": "pythonRefactoring", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/quantconnect", + "name": "quantconnect", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/quantconnect/properties/agentname", + "name": "agentname", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/quantconnect/properties/quantconnectapitoken", + "name": "quantconnectapitoken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/quantconnect/properties/quantconnectuserid", + "name": "quantconnectuserid", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/ramparts", + "name": "ramparts", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/razorpay", + "name": "razorpay", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/razorpay/properties/keyId", + "name": "keyId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/razorpay/properties/keySecret", + "name": "keySecret", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/reddit", + "name": "reddit", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/reddit/properties/redditClientId", + "name": "redditClientId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/reddit/properties/redditClientSecret", + "name": "redditClientSecret", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/reddit/properties/redditPassword", + "name": "redditPassword", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/reddit/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis", + "name": "redis", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/caCerts", + "name": "caCerts", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/caPath", + "name": "caPath", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/certReqs", + "name": "certReqs", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/clusterMode", + "name": "clusterMode", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/host", + "name": "host", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/port", + "name": "port", + "required": true, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/pwd", + "name": "pwd", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/ssl", + "name": "ssl", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/sslCertfile", + "name": "sslCertfile", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/sslKeyfile", + "name": "sslKeyfile", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redis/properties/username", + "name": "username", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redisCloud", + "name": "redisCloud", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redisCloud/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/redisCloud/properties/secretKey", + "name": "secretKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/ref", + "name": "ref", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/ref/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/remote", + "name": "remote", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/render", + "name": "render", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/render/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/resend", + "name": "resend", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/resend/properties/apiKey", + "name": "apiKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/resend/properties/replyTo", + "name": "replyTo", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/resend/properties/sender", + "name": "sender", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/risken", + "name": "risken", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/risken/properties/accessToken", + "name": "accessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/risken/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/root", + "name": "root", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/root/properties/apiAccessToken", + "name": "apiAccessToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/ros2", + "name": "ros2", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/rube", + "name": "rube", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/rube/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/rustMcpFilesystem", + "name": "rustMcpFilesystem", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/rustMcpFilesystem/properties/allowWrite", + "name": "allowWrite", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/properties/rustMcpFilesystem/properties/allowedDirectories", + "name": "allowedDirectories", + "required": true, + "field_type": "array", + "format": null, + "reference": null + }, + { + "pointer": "/properties/rustMcpFilesystem/properties/enableRoots", + "name": "enableRoots", + "required": true, + "field_type": "boolean", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi", + "name": "schemacrawlerAi", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/generalInfoLevel", + "name": "generalInfoLevel", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/generalLogLevel", + "name": "generalLogLevel", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/schcrwlrDatabasePassword", + "name": "schcrwlrDatabasePassword", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/schcrwlrDatabaseUser", + "name": "schcrwlrDatabaseUser", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/serverConnectionDatabase", + "name": "serverConnectionDatabase", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/serverConnectionHost", + "name": "serverConnectionHost", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/serverConnectionPort", + "name": "serverConnectionPort", + "required": false, + "field_type": "integer", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/serverConnectionServer", + "name": "serverConnectionServer", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/urlConnectionJdbcUrl", + "name": "urlConnectionJdbcUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schemacrawlerAi/properties/volumeHostShare", + "name": "volumeHostShare", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/schoginiMcpImageBorder", + "name": "schoginiMcpImageBorder", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/scrapegraph", + "name": "scrapegraph", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/scrapegraph/properties/sgaiApiKey", + "name": "sgaiApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/scrapezy", + "name": "scrapezy", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/scrapezy/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/securenoteLink", + "name": "securenoteLink", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/semgrep", + "name": "semgrep", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sentry", + "name": "sentry", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sentry/properties/authToken", + "name": "authToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sequa", + "name": "sequa", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sequa/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sequa/properties/mcpServerUrl", + "name": "mcpServerUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sequentialthinking", + "name": "sequentialthinking", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/shortIo", + "name": "shortIo", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/shortIo/properties/shortIoApiKey", + "name": "shortIoApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/simplechecklist", + "name": "simplechecklist", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/singlestore", + "name": "singlestore", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/singlestore/properties/mcpApiKey", + "name": "mcpApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/slack", + "name": "slack", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/slack/properties/botToken", + "name": "botToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/slack/properties/channelIds", + "name": "channelIds", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/slack/properties/teamId", + "name": "teamId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear", + "name": "smartbear", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/apiHubApiKey", + "name": "apiHubApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/bugsnagApiKey", + "name": "bugsnagApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/bugsnagAuthToken", + "name": "bugsnagAuthToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/bugsnagEndpoint", + "name": "bugsnagEndpoint", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/pactBrokerBaseUrl", + "name": "pactBrokerBaseUrl", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/pactBrokerPassword", + "name": "pactBrokerPassword", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/pactBrokerToken", + "name": "pactBrokerToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/pactBrokerUsername", + "name": "pactBrokerUsername", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/smartbear/properties/reflectApiToken", + "name": "reflectApiToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sonarqube", + "name": "sonarqube", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sonarqube/properties/org", + "name": "org", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sonarqube/properties/token", + "name": "token", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sonarqube/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/sqlite", + "name": "sqlite", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/stackgen", + "name": "stackgen", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/stackgen/properties/token", + "name": "token", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/stackgen/properties/url", + "name": "url", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/stackhawk", + "name": "stackhawk", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/stackhawk/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/stripe", + "name": "stripe", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/stripe/properties/secretKey", + "name": "secretKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/supadata", + "name": "supadata", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/supadata/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/suzieq", + "name": "suzieq", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/suzieq/properties/apiEndpoint", + "name": "apiEndpoint", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/suzieq/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/taskOrchestrator", + "name": "taskOrchestrator", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/tavily", + "name": "tavily", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/tavily/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/teamwork", + "name": "teamwork", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/teamwork/properties/twMcpBearerToken", + "name": "twMcpBearerToken", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/telnyx", + "name": "telnyx", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/telnyx/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/tembo", + "name": "tembo", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/tembo/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/terraform", + "name": "terraform", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/textToGraphql", + "name": "textToGraphql", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/textToGraphql/properties/graphqlApiKey", + "name": "graphqlApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/textToGraphql/properties/graphqlAuthType", + "name": "graphqlAuthType", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/textToGraphql/properties/graphqlEndpoint", + "name": "graphqlEndpoint", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/textToGraphql/properties/modelName", + "name": "modelName", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/textToGraphql/properties/modelTemperature", + "name": "modelTemperature", + "required": true, + "field_type": "number", + "format": null, + "reference": null + }, + { + "pointer": "/properties/textToGraphql/properties/openaiApiKey", + "name": "openaiApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/tigris", + "name": "tigris", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/tigris/properties/awsAccessKeyId", + "name": "awsAccessKeyId", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/tigris/properties/awsEndpointUrlS3", + "name": "awsEndpointUrlS3", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/tigris/properties/awsSecretAccessKey", + "name": "awsSecretAccessKey", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/time", + "name": "time", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/triplewhale", + "name": "triplewhale", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/triplewhale/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/unrealEngine", + "name": "unrealEngine", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/unrealEngine/properties/logLevel", + "name": "logLevel", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/unrealEngine/properties/ueHost", + "name": "ueHost", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/unrealEngine/properties/ueRcHttpPort", + "name": "ueRcHttpPort", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/unrealEngine/properties/ueRcWsPort", + "name": "ueRcWsPort", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/veyrax", + "name": "veyrax", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/veyrax/properties/apiKey", + "name": "apiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/vizro", + "name": "vizro", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/vulnNist", + "name": "vulnNist", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/wayfound", + "name": "wayfound", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/wayfound/properties/mcpApiKey", + "name": "mcpApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/webflow", + "name": "webflow", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/webflow/properties/token", + "name": "token", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/wikipedia", + "name": "wikipedia", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/wolframAlpha", + "name": "wolframAlpha", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/wolframAlpha/properties/wolframApiKey", + "name": "wolframApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/youtubeTranscript", + "name": "youtubeTranscript", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/zerodhaKite", + "name": "zerodhaKite", + "required": false, + "field_type": "object", + "format": null, + "reference": null + }, + { + "pointer": "/properties/zerodhaKite/properties/kiteAccessToken", + "name": "kiteAccessToken", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/zerodhaKite/properties/kiteApiKey", + "name": "kiteApiKey", + "required": true, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/zerodhaKite/properties/kiteApiSecret", + "name": "kiteApiSecret", + "required": false, + "field_type": "string", + "format": null, + "reference": null + }, + { + "pointer": "/properties/zerodhaKite/properties/kiteRedirectUrl", + "name": "kiteRedirectUrl", + "required": false, + "field_type": "string", + "format": null, + "reference": null + } + ] + } +} diff --git a/compat/e2b/inventory/public-exports.json b/compat/e2b/inventory/public-exports.json new file mode 100644 index 00000000..2819c542 --- /dev/null +++ b/compat/e2b/inventory/public-exports.json @@ -0,0 +1,543 @@ +{ + "schema_version": 1, + "compatibility_id": "e2b-2026-07-14", + "packages": { + "python-code-interpreter": { + "language": "python", + "package": "e2b-code-interpreter", + "version": "2.8.1", + "symbols": [ + "ALL_TRAFFIC", + "ApiClient", + "ApiParams", + "AsyncCommandHandle", + "AsyncSandbox", + "AsyncSandboxPaginator", + "AsyncSnapshotPaginator", + "AsyncTemplate", + "AsyncVolume", + "AsyncWatchHandle", + "AuthenticationException", + "BuildException", + "BuildInfo", + "BuildStatusReason", + "CommandExitException", + "CommandHandle", + "CommandResult", + "ConnectionConfig", + "Context", + "CopyItem", + "EntryInfo", + "Execution", + "ExecutionError", + "FileNotFoundException", + "FileType", + "FileUploadException", + "FilesystemEvent", + "FilesystemEventType", + "Git", + "GitAuthException", + "GitBranches", + "GitFileStatus", + "GitHubMcpServer", + "GitHubMcpServerConfig", + "GitResetMode", + "GitStatus", + "GitUpstreamException", + "InvalidArgumentException", + "LogEntry", + "LogEntryEnd", + "LogEntryLevel", + "LogEntryStart", + "Logs", + "MIMEType", + "McpServer", + "NotEnoughSpaceException", + "NotFoundException", + "OutputHandler", + "OutputMessage", + "ProcessInfo", + "ProxyTypes", + "PtyOutput", + "PtySize", + "RateLimitException", + "ReadyCmd", + "Result", + "RunCodeLanguage", + "Sandbox", + "SandboxException", + "SandboxInfo", + "SandboxInfoLifecycle", + "SandboxLifecycle", + "SandboxMetrics", + "SandboxNetworkInfo", + "SandboxNetworkOpts", + "SandboxNetworkRule", + "SandboxNetworkRuleInfo", + "SandboxNetworkRules", + "SandboxNetworkSelector", + "SandboxNetworkSelectorContext", + "SandboxNetworkTransform", + "SandboxNetworkUpdate", + "SandboxNotFoundException", + "SandboxOnTimeout", + "SandboxPaginator", + "SandboxQuery", + "SandboxState", + "SnapshotInfo", + "SnapshotPaginator", + "Stderr", + "Stdout", + "Template", + "TemplateBase", + "TemplateBuildStatus", + "TemplateBuildStatusResponse", + "TemplateClass", + "TemplateException", + "TemplateTag", + "TemplateTagInfo", + "TimeoutException", + "Username", + "Volume", + "VolumeAndToken", + "VolumeApiParams", + "VolumeConnectionConfig", + "VolumeEntryStat", + "VolumeException", + "VolumeFileType", + "VolumeInfo", + "WatchHandle", + "WriteInfo", + "client", + "default_build_logger", + "get_signature", + "wait_for_file", + "wait_for_port", + "wait_for_process", + "wait_for_timeout", + "wait_for_url" + ], + "type_only_symbols": [], + "reexports": [ + "e2b" + ], + "has_default_export": false + }, + "python-e2b": { + "language": "python", + "package": "e2b", + "version": "2.32.0", + "symbols": [ + "ALL_TRAFFIC", + "ApiClient", + "ApiParams", + "AsyncCommandHandle", + "AsyncSandbox", + "AsyncSandboxPaginator", + "AsyncSnapshotPaginator", + "AsyncTemplate", + "AsyncVolume", + "AsyncWatchHandle", + "AuthenticationException", + "BuildException", + "BuildInfo", + "BuildStatusReason", + "CommandExitException", + "CommandHandle", + "CommandResult", + "ConnectionConfig", + "CopyItem", + "EntryInfo", + "FileNotFoundException", + "FileType", + "FileUploadException", + "FilesystemEvent", + "FilesystemEventType", + "Git", + "GitAuthException", + "GitBranches", + "GitFileStatus", + "GitHubMcpServer", + "GitHubMcpServerConfig", + "GitResetMode", + "GitStatus", + "GitUpstreamException", + "InvalidArgumentException", + "LogEntry", + "LogEntryEnd", + "LogEntryLevel", + "LogEntryStart", + "McpServer", + "NotEnoughSpaceException", + "NotFoundException", + "OutputHandler", + "ProcessInfo", + "ProxyTypes", + "PtyOutput", + "PtySize", + "RateLimitException", + "ReadyCmd", + "Sandbox", + "SandboxException", + "SandboxInfo", + "SandboxInfoLifecycle", + "SandboxLifecycle", + "SandboxMetrics", + "SandboxNetworkInfo", + "SandboxNetworkOpts", + "SandboxNetworkRule", + "SandboxNetworkRuleInfo", + "SandboxNetworkRules", + "SandboxNetworkSelector", + "SandboxNetworkSelectorContext", + "SandboxNetworkTransform", + "SandboxNetworkUpdate", + "SandboxNotFoundException", + "SandboxOnTimeout", + "SandboxPaginator", + "SandboxQuery", + "SandboxState", + "SnapshotInfo", + "SnapshotPaginator", + "Stderr", + "Stdout", + "Template", + "TemplateBase", + "TemplateBuildStatus", + "TemplateBuildStatusResponse", + "TemplateClass", + "TemplateException", + "TemplateTag", + "TemplateTagInfo", + "TimeoutException", + "Username", + "Volume", + "VolumeAndToken", + "VolumeApiParams", + "VolumeConnectionConfig", + "VolumeEntryStat", + "VolumeException", + "VolumeFileType", + "VolumeInfo", + "WatchHandle", + "WriteInfo", + "client", + "default_build_logger", + "get_signature", + "wait_for_file", + "wait_for_port", + "wait_for_process", + "wait_for_timeout", + "wait_for_url" + ], + "type_only_symbols": [], + "reexports": [], + "has_default_export": false + }, + "typescript-code-interpreter": { + "language": "typescript", + "package": "@e2b/code-interpreter", + "version": "2.6.1", + "symbols": [ + "ALL_TRAFFIC", + "ApiClient", + "AuthenticationError", + "BuildError", + "CommandExitError", + "ConnectionConfig", + "FileNotFoundError", + "FileType", + "FileUploadError", + "FilesystemEventType", + "Git", + "GitAuthError", + "GitUpstreamError", + "InvalidArgumentError", + "LogEntry", + "LogEntryEnd", + "LogEntryStart", + "NotEnoughSpaceError", + "NotFoundError", + "RateLimitError", + "ReadyCmd", + "Sandbox", + "SandboxError", + "SandboxNotFoundError", + "Template", + "TemplateBase", + "TemplateError", + "TimeoutError", + "Volume", + "VolumeError", + "VolumeFileType", + "defaultBuildLogger", + "getSignature", + "waitForFile", + "waitForPort", + "waitForProcess", + "waitForTimeout", + "waitForURL" + ], + "type_only_symbols": [ + "BarChart", + "BarData", + "BoxAndWhiskerChart", + "BoxAndWhiskerData", + "BuildInfo", + "BuildOptions", + "BuildStatusReason", + "Chart", + "ChartType", + "ChartTypes", + "CommandConnectOpts", + "CommandHandle", + "CommandRequestOpts", + "CommandResult", + "CommandStartOpts", + "Commands", + "ConnectionConfigOpts", + "ConnectionOpts", + "Context", + "CopyItem", + "CreateCodeContextOpts", + "CreateSnapshotOpts", + "EntryInfo", + "Execution", + "ExecutionError", + "Filesystem", + "FilesystemEvent", + "FilesystemReadOpts", + "FilesystemWriteOpts", + "GetBuildStatusOptions", + "GitAddOpts", + "GitBranches", + "GitCloneOpts", + "GitCommitOpts", + "GitConfigOpts", + "GitConfigScope", + "GitDangerouslyAuthenticateOpts", + "GitDeleteBranchOpts", + "GitFileStatus", + "GitInitOpts", + "GitPullOpts", + "GitPushOpts", + "GitRemoteAddOpts", + "GitRequestOpts", + "GitStatus", + "LineChart", + "LogEntryLevel", + "Logger", + "Logs", + "MIMEType", + "McpServer", + "McpServerName", + "OutputMessage", + "PieChart", + "PieData", + "PointData", + "ProcessInfo", + "Pty", + "PtyOutput", + "RawData", + "Result", + "RunCodeLanguage", + "RunCodeOpts", + "SandboxApiOpts", + "SandboxConnectOpts", + "SandboxInfo", + "SandboxInfoLifecycle", + "SandboxLifecycle", + "SandboxListOpts", + "SandboxMetrics", + "SandboxMetricsOpts", + "SandboxNetworkInfo", + "SandboxNetworkOpts", + "SandboxNetworkRule", + "SandboxNetworkRuleInfo", + "SandboxNetworkRules", + "SandboxNetworkSelector", + "SandboxNetworkSelectorContext", + "SandboxNetworkTransform", + "SandboxNetworkUpdate", + "SandboxOnTimeout", + "SandboxOpts", + "SandboxPaginator", + "SandboxPauseOpts", + "SandboxState", + "ScaleType", + "ScatterChart", + "SnapshotInfo", + "SnapshotListOpts", + "SnapshotPaginator", + "Stderr", + "Stdout", + "SuperChart", + "TemplateBuildStatus", + "TemplateBuildStatusResponse", + "TemplateBuilder", + "TemplateClass", + "TemplateTag", + "TemplateTagInfo", + "Username", + "VolumeAndToken", + "VolumeApiOpts", + "VolumeConnectionConfig", + "VolumeEntryStat", + "VolumeInfo", + "VolumeMetadataOptions", + "VolumeMetadataOpts", + "VolumeReadOpts", + "VolumeWriteOptions", + "VolumeWriteOpts", + "WatchHandle", + "WriteInfo", + "components", + "paths" + ], + "reexports": [ + "e2b" + ], + "has_default_export": true + }, + "typescript-e2b": { + "language": "typescript", + "package": "e2b", + "version": "2.33.0", + "symbols": [ + "ALL_TRAFFIC", + "ApiClient", + "AuthenticationError", + "BuildError", + "CommandExitError", + "ConnectionConfig", + "FileNotFoundError", + "FileType", + "FileUploadError", + "FilesystemEventType", + "Git", + "GitAuthError", + "GitUpstreamError", + "InvalidArgumentError", + "LogEntry", + "LogEntryEnd", + "LogEntryStart", + "NotEnoughSpaceError", + "NotFoundError", + "RateLimitError", + "ReadyCmd", + "Sandbox", + "SandboxError", + "SandboxNotFoundError", + "Template", + "TemplateBase", + "TemplateError", + "TimeoutError", + "Volume", + "VolumeError", + "VolumeFileType", + "defaultBuildLogger", + "getSignature", + "waitForFile", + "waitForPort", + "waitForProcess", + "waitForTimeout", + "waitForURL" + ], + "type_only_symbols": [ + "BuildInfo", + "BuildOptions", + "BuildStatusReason", + "CommandConnectOpts", + "CommandHandle", + "CommandRequestOpts", + "CommandResult", + "CommandStartOpts", + "Commands", + "ConnectionConfigOpts", + "ConnectionOpts", + "CopyItem", + "CreateSnapshotOpts", + "EntryInfo", + "Filesystem", + "FilesystemEvent", + "FilesystemReadOpts", + "FilesystemWriteOpts", + "GetBuildStatusOptions", + "GitAddOpts", + "GitBranches", + "GitCloneOpts", + "GitCommitOpts", + "GitConfigOpts", + "GitConfigScope", + "GitDangerouslyAuthenticateOpts", + "GitDeleteBranchOpts", + "GitFileStatus", + "GitInitOpts", + "GitPullOpts", + "GitPushOpts", + "GitRemoteAddOpts", + "GitRequestOpts", + "GitStatus", + "LogEntryLevel", + "Logger", + "McpServer", + "McpServerName", + "ProcessInfo", + "Pty", + "PtyOutput", + "SandboxApiOpts", + "SandboxConnectOpts", + "SandboxInfo", + "SandboxInfoLifecycle", + "SandboxLifecycle", + "SandboxListOpts", + "SandboxMetrics", + "SandboxMetricsOpts", + "SandboxNetworkInfo", + "SandboxNetworkOpts", + "SandboxNetworkRule", + "SandboxNetworkRuleInfo", + "SandboxNetworkRules", + "SandboxNetworkSelector", + "SandboxNetworkSelectorContext", + "SandboxNetworkTransform", + "SandboxNetworkUpdate", + "SandboxOnTimeout", + "SandboxOpts", + "SandboxPaginator", + "SandboxPauseOpts", + "SandboxState", + "SnapshotInfo", + "SnapshotListOpts", + "SnapshotPaginator", + "Stderr", + "Stdout", + "TemplateBuildStatus", + "TemplateBuildStatusResponse", + "TemplateBuilder", + "TemplateClass", + "TemplateTag", + "TemplateTagInfo", + "Username", + "VolumeAndToken", + "VolumeApiOpts", + "VolumeConnectionConfig", + "VolumeEntryStat", + "VolumeInfo", + "VolumeMetadataOptions", + "VolumeMetadataOpts", + "VolumeReadOpts", + "VolumeWriteOptions", + "VolumeWriteOpts", + "WatchHandle", + "WriteInfo", + "components", + "paths" + ], + "reexports": [ + "./template" + ], + "has_default_export": true + } + } +} diff --git a/compat/e2b/manifests/v1.json b/compat/e2b/manifests/v1.json new file mode 100644 index 00000000..334ceddc --- /dev/null +++ b/compat/e2b/manifests/v1.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "compatibility_id": "e2b-2026-07-14", + "status": "contract-fixture", + "full_compatibility": false, + "e2b_git_commit": "423a1b73025ce871d9b9bfe338396c6b316be845", + "code_interpreter_git_commit": "5aeca43fe3fae2df260b1fb17c71fed5b5dac852", + "python_e2b_version": "2.32.0", + "typescript_e2b_version": "2.33.0", + "python_code_interpreter_version": "2.8.1", + "typescript_code_interpreter_version": "2.6.1", + "control_openapi_digest": "sha256:cea884caee4391153e2056fb1fe9acf691e4887cd990c3a0419a639a8e8a0aac", + "envd_openapi_digest": "sha256:0e0b41036eb8a99de8e37d16d30b9fbad2fb32024bfb500d4b6681d253240caf", + "volume_content_openapi_digest": "sha256:73e7829e9e5c06acd878d0929d20622e217d22f900a79c3d024b5f834d892933", + "process_descriptor_digest": "sha256:95d48ccdafd24a7bb80b9129ce71558f6c4863368fddf6f484ca5e856a26990d", + "filesystem_descriptor_digest": "sha256:d32a8691bbca78d6059468c35468121ff204968384ebe3d87fafc205626023d4", + "mcp_schema_digest": "sha256:82457dc19eb9c7ae29ed66034ac9d763e92f5fa48f9398910d0e1dbf8b06e70e", + "contract_inventory_digest": "sha256:0e7d4fb24ef4cf2508400533c58ee02c10ea5dfbe53439b7b61d49045de2e173", + "public_export_inventory_digest": "sha256:82eb129a6f9d888b7d0eda2aa0d767d258974d0f07f68d122acee45d2fdb1e2b", + "client_artifact_digests": { + "python-code-interpreter-wheel": "sha256:8449ff1abb507e4c28134c9cbc4e59f76c09fadceab879c8c4031399b31a126a", + "python-e2b-wheel": "sha256:0f1971f8e287aa717ad3e44c2cbe26753da97acf34da24225b07675a07c57300", + "typescript-code-interpreter-tarball": "sha256:df9350312e46f6f6c4f62004da528fd15176078b3027b4507f7358a6eab0fe57", + "typescript-e2b-tarball": "sha256:53581eae5f11efb4b1020b64c05d7e8fc46fe3e14b5172322b5feb35c262e579" + }, + "a3s_compat_version": "3.0.10-preview.1" +} diff --git a/compat/e2b/spec/code-interpreter/LICENSE b/compat/e2b/spec/code-interpreter/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/compat/e2b/spec/code-interpreter/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/compat/e2b/spec/code-interpreter/public-exports/python-init.py b/compat/e2b/spec/code-interpreter/public-exports/python-init.py new file mode 100644 index 00000000..5202d5de --- /dev/null +++ b/compat/e2b/spec/code-interpreter/public-exports/python-init.py @@ -0,0 +1,14 @@ +from e2b import * +from .code_interpreter_sync import Sandbox +from .code_interpreter_async import AsyncSandbox +from .models import ( + Context, + Execution, + ExecutionError, + Result, + MIMEType, + Logs, + OutputHandler, + OutputMessage, + RunCodeLanguage, +) diff --git a/compat/e2b/spec/code-interpreter/public-exports/typescript-index.ts b/compat/e2b/spec/code-interpreter/public-exports/typescript-index.ts new file mode 100644 index 00000000..d4598121 --- /dev/null +++ b/compat/e2b/spec/code-interpreter/public-exports/typescript-index.ts @@ -0,0 +1,37 @@ +export * from 'e2b' + +export { Sandbox } from './sandbox' +export type { + Context, + RunCodeLanguage, + RunCodeOpts, + CreateCodeContextOpts, +} from './sandbox' +export type { + Logs, + ExecutionError, + Result, + Execution, + MIMEType, + RawData, + OutputMessage, +} from './messaging' +export type { + ScaleType, + ChartType, + ChartTypes, + Chart, + BarChart, + BarData, + LineChart, + ScatterChart, + BoxAndWhiskerChart, + BoxAndWhiskerData, + PieChart, + PieData, + SuperChart, + PointData, +} from './charts' +import { Sandbox } from './sandbox' + +export default Sandbox diff --git a/compat/e2b/spec/e2b/LICENSE b/compat/e2b/spec/e2b/LICENSE new file mode 100644 index 00000000..ec47fef1 --- /dev/null +++ b/compat/e2b/spec/e2b/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 2023 FoundryLabs, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT 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/compat/e2b/spec/e2b/envd/envd.yaml b/compat/e2b/spec/e2b/envd/envd.yaml new file mode 100644 index 00000000..6b776a72 --- /dev/null +++ b/compat/e2b/spec/e2b/envd/envd.yaml @@ -0,0 +1,312 @@ +openapi: 3.0.0 +info: + title: envd + version: 0.1.3 + description: API for managing files' content and controlling envd + +tags: + - name: files + +paths: + /health: + get: + summary: Check the health of the service + responses: + '204': + description: The service is healthy + + /metrics: + get: + summary: Get the stats of the service + security: + - AccessTokenAuth: [] + - {} + responses: + '200': + description: The resource usage metrics of the service + content: + application/json: + schema: + $ref: '#/components/schemas/Metrics' + + /init: + post: + summary: Set initial vars, ensure the time and metadata is synced with the host + security: + - AccessTokenAuth: [] + - {} + requestBody: + content: + application/json: + schema: + type: object + properties: + hyperloopIP: + type: string + description: IP address of the hyperloop server to connect to + envVars: + $ref: '#/components/schemas/EnvVars' + accessToken: + type: string + description: Access token for secure access to envd service + timestamp: + type: string + format: date-time + description: The current timestamp in RFC3339 format + defaultUser: + type: string + description: The default user to use for operations + defaultWorkdir: + type: string + description: The default working directory to use for operations + responses: + '204': + description: Env vars set, the time and metadata is synced with the host + + /envs: + get: + summary: Get the environment variables + security: + - AccessTokenAuth: [] + - {} + responses: + '200': + description: Environment variables + content: + application/json: + schema: + $ref: '#/components/schemas/EnvVars' + + /files: + get: + summary: Download a file + tags: [files] + security: + - AccessTokenAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/FilePath' + - $ref: '#/components/parameters/User' + - $ref: '#/components/parameters/Signature' + - $ref: '#/components/parameters/SignatureExpiration' + responses: + '200': + $ref: '#/components/responses/DownloadSuccess' + '401': + $ref: '#/components/responses/InvalidUser' + '400': + $ref: '#/components/responses/InvalidPath' + '404': + $ref: '#/components/responses/FileNotFound' + '500': + $ref: '#/components/responses/InternalServerError' + post: + summary: Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + description: | + Any request header of the form `X-Metadata-: ` is persisted + as a user-defined extended attribute on the uploaded file. The + `X-Metadata-` prefix is stripped and the remaining header name is + lowercased to form the metadata key; the resulting map is returned on + `EntryInfo` lookups (e.g. `Stat`, `ListDir`). + + Each upload replaces the file's metadata with the keys provided in + that request: keys previously stored but absent from the new request + are removed, and an upload that sends no `X-Metadata-*` header clears + all existing metadata. + + Both keys and values must be printable US-ASCII (bytes `0x20`-`0x7E`) + and are rejected with HTTP 400 otherwise. Each key is capped at 246 + bytes (the Linux VFS xattr-name limit minus the namespace prefix), and + the combined size of all metadata on a file (keys plus values, with the + namespace prefix counted per key) is capped at 4096 bytes to stay within + the filesystem's per-inode xattr budget. Multiple files in a single + multipart upload receive the same metadata. If the same + `X-Metadata-` header is sent more than once, only the first + value is used. + tags: [files] + security: + - AccessTokenAuth: [] + - {} + parameters: + - $ref: '#/components/parameters/FilePath' + - $ref: '#/components/parameters/User' + - $ref: '#/components/parameters/Signature' + - $ref: '#/components/parameters/SignatureExpiration' + requestBody: + $ref: '#/components/requestBodies/File' + responses: + '200': + $ref: '#/components/responses/UploadSuccess' + '400': + $ref: '#/components/responses/InvalidPath' + '401': + $ref: '#/components/responses/InvalidUser' + '500': + $ref: '#/components/responses/InternalServerError' + '507': + $ref: '#/components/responses/NotEnoughDiskSpace' + +components: + securitySchemes: + AccessTokenAuth: + type: apiKey + scheme: header + name: X-Access-Token + + parameters: + FilePath: + name: path + in: query + required: false + description: Path to the file, URL encoded. Can be relative to user's home directory. + schema: + type: string + User: + name: username + in: query + required: false + description: User used for setting the owner, or resolving relative paths. + schema: + type: string + Signature: + name: signature + in: query + required: false + description: Signature used for file access permission verification. + schema: + type: string + SignatureExpiration: + name: signature_expiration + in: query + required: false + description: Signature expiration used for defining the expiration time of the signature. + schema: + type: integer + + requestBodies: + File: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + + responses: + UploadSuccess: + description: The file was uploaded successfully. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EntryInfo' + + DownloadSuccess: + description: Entire file downloaded successfully. + content: + application/octet-stream: + schema: + type: string + format: binary + description: The file content + InvalidPath: + description: Invalid path + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + FileNotFound: + description: File not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InvalidUser: + description: Invalid user + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotEnoughDiskSpace: + description: Not enough disk space + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Error: + required: + - message + - code + properties: + message: + type: string + description: Error message + code: + type: integer + description: Error code + EntryInfo: + required: + - path + - name + - type + properties: + path: + type: string + description: Path to the file + name: + type: string + description: Name of the file + type: + type: string + description: Type of the file + enum: + - file + metadata: + type: object + description: User-defined metadata stored as extended attributes on the file. + additionalProperties: + type: string + EnvVars: + type: object + description: Environment variables to set + additionalProperties: + type: string + Metrics: + type: object + description: Resource usage metrics + properties: + ts: + type: integer + format: int64 + description: Unix timestamp in UTC for current sandbox time + cpu_count: + type: integer + description: Number of CPU cores + cpu_used_pct: + type: number + format: float + description: CPU usage percentage + mem_total: + type: integer + description: Total virtual memory in bytes + mem_used: + type: integer + description: Used virtual memory in bytes + disk_used: + type: integer + description: Used disk space in bytes + disk_total: + type: integer + description: Total disk space in bytes diff --git a/compat/e2b/spec/e2b/envd/filesystem/filesystem.proto b/compat/e2b/spec/e2b/envd/filesystem/filesystem.proto new file mode 100644 index 00000000..e97b687d --- /dev/null +++ b/compat/e2b/spec/e2b/envd/filesystem/filesystem.proto @@ -0,0 +1,152 @@ +syntax = "proto3"; + +package filesystem; + +import "google/protobuf/timestamp.proto"; + +service Filesystem { + rpc Stat(StatRequest) returns (StatResponse); + rpc MakeDir(MakeDirRequest) returns (MakeDirResponse); + rpc Move(MoveRequest) returns (MoveResponse); + rpc ListDir(ListDirRequest) returns (ListDirResponse); + rpc Remove(RemoveRequest) returns (RemoveResponse); + + rpc WatchDir(WatchDirRequest) returns (stream WatchDirResponse); + + // Non-streaming versions of WatchDir + rpc CreateWatcher(CreateWatcherRequest) returns (CreateWatcherResponse); + rpc GetWatcherEvents(GetWatcherEventsRequest) returns (GetWatcherEventsResponse); + rpc RemoveWatcher(RemoveWatcherRequest) returns (RemoveWatcherResponse); +} + +message MoveRequest { + string source = 1; + string destination = 2; +} + +message MoveResponse { + EntryInfo entry = 1; +} + +message MakeDirRequest { + string path = 1; +} + +message MakeDirResponse { + EntryInfo entry = 1; +} + +message RemoveRequest { + string path = 1; +} + +message RemoveResponse {} + +message StatRequest { + string path = 1; +} + +message StatResponse { + EntryInfo entry = 1; +} + +message EntryInfo { + string name = 1; + FileType type = 2; + string path = 3; + int64 size = 4; + uint32 mode = 5; + string permissions = 6; + string owner = 7; + string group = 8; + google.protobuf.Timestamp modified_time = 9; + // If the entry is a symlink, this field contains the target of the symlink. + optional string symlink_target = 10; + // User-defined metadata stored as extended attributes (xattrs) on the file. + // Keys live under the `user.e2b.` xattr namespace; the prefix is stripped here. + // Plain `user.*` xattrs written by other tooling are not reflected. + map metadata = 11; +} + +enum FileType { + FILE_TYPE_UNSPECIFIED = 0; + FILE_TYPE_FILE = 1; + FILE_TYPE_DIRECTORY = 2; +} + +message ListDirRequest { + string path = 1; + uint32 depth = 2; +} + +message ListDirResponse { + repeated EntryInfo entries = 1; +} + +message WatchDirRequest { + string path = 1; + bool recursive = 2; + // If true, each FilesystemEvent includes the EntryInfo of the affected entry, when available. + bool include_entry = 3; + // If true, allows watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE). + // Events on network mounts may be unreliable or not delivered at all. + bool allow_network_mounts = 4; +} + +message FilesystemEvent { + string name = 1; + EventType type = 2; + // Info of the entry that triggered the event. Only populated when include_entry + // was requested and the entry could be stat-ed (e.g. not set for remove/rename-away + // events, where the entry no longer exists at this path). + optional EntryInfo entry = 3; +} + +message WatchDirResponse { + oneof event { + StartEvent start = 1; + FilesystemEvent filesystem = 2; + KeepAlive keepalive = 3; + } + + message StartEvent {} + + message KeepAlive {} +} + +message CreateWatcherRequest { + string path = 1; + bool recursive = 2; + // If true, each FilesystemEvent includes the EntryInfo of the affected entry, when available. + bool include_entry = 3; + // If true, allows watching paths on network filesystem mounts (NFS, CIFS, SMB, FUSE). + // Events on network mounts may be unreliable or not delivered at all. + bool allow_network_mounts = 4; +} + +message CreateWatcherResponse { + string watcher_id = 1; +} + +message GetWatcherEventsRequest { + string watcher_id = 1; +} + +message GetWatcherEventsResponse { + repeated FilesystemEvent events = 1; +} + +message RemoveWatcherRequest { + string watcher_id = 1; +} + +message RemoveWatcherResponse {} + +enum EventType { + EVENT_TYPE_UNSPECIFIED = 0; + EVENT_TYPE_CREATE = 1; + EVENT_TYPE_WRITE = 2; + EVENT_TYPE_REMOVE = 3; + EVENT_TYPE_RENAME = 4; + EVENT_TYPE_CHMOD = 5; +} diff --git a/compat/e2b/spec/e2b/envd/process/process.proto b/compat/e2b/spec/e2b/envd/process/process.proto new file mode 100644 index 00000000..4d394da6 --- /dev/null +++ b/compat/e2b/spec/e2b/envd/process/process.proto @@ -0,0 +1,169 @@ +syntax = "proto3"; + +package process; + +service Process { + rpc List(ListRequest) returns (ListResponse); + + rpc Connect(ConnectRequest) returns (stream ConnectResponse); + rpc Start(StartRequest) returns (stream StartResponse); + + rpc Update(UpdateRequest) returns (UpdateResponse); + + // Client input stream ensures ordering of messages + rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse); + rpc SendInput(SendInputRequest) returns (SendInputResponse); + rpc SendSignal(SendSignalRequest) returns (SendSignalResponse); + + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse); +} + +message PTY { + Size size = 1; + + message Size { + uint32 cols = 1; + uint32 rows = 2; + } +} + +message ProcessConfig { + string cmd = 1; + repeated string args = 2; + + map envs = 3; + optional string cwd = 4; +} + +message ListRequest {} + +message ProcessInfo { + ProcessConfig config = 1; + uint32 pid = 2; + optional string tag = 3; +} + +message ListResponse { + repeated ProcessInfo processes = 1; +} + +message StartRequest { + ProcessConfig process = 1; + optional PTY pty = 2; + optional string tag = 3; + optional bool stdin = 4; +} + +message UpdateRequest { + ProcessSelector process = 1; + + optional PTY pty = 2; +} + +message UpdateResponse {} + +message ProcessEvent { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + EndEvent end = 3; + KeepAlive keepalive = 4; + } + + message StartEvent { + uint32 pid = 1; + } + + message DataEvent { + oneof output { + bytes stdout = 1; + bytes stderr = 2; + bytes pty = 3; + } + } + + message EndEvent { + sint32 exit_code = 1; + bool exited = 2; + string status = 3; + optional string error = 4; + } + + message KeepAlive {} +} + +message StartResponse { + ProcessEvent event = 1; +} + +message ConnectResponse { + ProcessEvent event = 1; +} + +message SendInputRequest { + ProcessSelector process = 1; + + ProcessInput input = 2; +} + +message SendInputResponse {} + +message ProcessInput { + oneof input { + bytes stdin = 1; + bytes pty = 2; + } +} + +message StreamInputRequest { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + KeepAlive keepalive = 3; + } + + message StartEvent { + ProcessSelector process = 1; + } + + message DataEvent { + ProcessInput input = 2; + } + + message KeepAlive {} +} + +message StreamInputResponse {} + +enum Signal { + SIGNAL_UNSPECIFIED = 0; + SIGNAL_SIGTERM = 15; + SIGNAL_SIGKILL = 9; +} + +message SendSignalRequest { + ProcessSelector process = 1; + + Signal signal = 2; +} + +message SendSignalResponse {} + +message CloseStdinRequest { + ProcessSelector process = 1; +} + +message CloseStdinResponse {} + +message ConnectRequest { + ProcessSelector process = 1; +} + +message ProcessSelector { + oneof selector { + uint32 pid = 1; + string tag = 2; + } +} diff --git a/compat/e2b/spec/e2b/mcp-server.json b/compat/e2b/spec/e2b/mcp-server.json new file mode 100644 index 00000000..6c994b63 --- /dev/null +++ b/compat/e2b/spec/e2b/mcp-server.json @@ -0,0 +1,3746 @@ +{ + "additionalProperties": false, + "properties": { + "airtable": { + "title": "Airtable MCP Server", + "description": "Provides AI assistants with direct access to Airtable bases, allowing them to read schemas, query records, and interact with your Airtable data. Supports listing bases, retrieving table structures, and searching through records to help automate workflows and answer questions about your organized data.", + "required": [ + "airtableApiKey", + "nodeenv" + ], + "additionalProperties": false, + "properties": { + "airtableApiKey": { + "type": "string" + }, + "nodeenv": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/airtable-mcp-server/overview" + }, + "aks": { + "title": "Azure Kubernetes Service (AKS)", + "description": "Azure Kubernetes Service (AKS) official MCP server.", + "required": [ + "azureDir", + "kubeconfig", + "accessLevel" + ], + "additionalProperties": false, + "properties": { + "accessLevel": { + "description": "Access level for the MCP server, One of [ readonly, readwrite, admin ]", + "type": "string" + }, + "additionalTools": { + "description": "Comma-separated list of additional tools, One of [ helm, cilium ]", + "type": "string" + }, + "allowNamespaces": { + "description": "Comma-separated list of namespaces to allow access to. If not specified, all namespaces are allowed.", + "type": "string" + }, + "azureDir": { + "description": "Path to the Azure configuration directory (e.g. /home/azureuser/.azure). Used for Azure CLI authentication, you should be logged in (e.g. run `az login`) on the host before starting the MCP server.", + "type": "string" + }, + "containerUser": { + "description": "Username or UID of the container user (format \u003cname|uid\u003e[:\u003cgroup|gid\u003e] e.g. 10000), ensuring correct permissions to access the Azure and kubeconfig files. Leave empty to use default user in the container.", + "type": "string" + }, + "kubeconfig": { + "description": "Path to the kubeconfig file for the AKS cluster (e.g. /home/azureuser/.kube/config). Used to connect to the AKS cluster.", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aks/overview" + }, + "apiGateway": { + "title": "Api-gateway", + "description": "A universal MCP (Model Context Protocol) server to integrate any API with Claude Desktop using only Docker configurations.", + "required": [ + "api1HeaderAuthorization", + "api1Name", + "api1SwaggerUrl" + ], + "additionalProperties": false, + "properties": { + "api1HeaderAuthorization": { + "type": "string" + }, + "api1Name": { + "type": "string" + }, + "api1SwaggerUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-api-gateway/overview" + }, + "apify": { + "title": "Apify MCP Server", + "description": "Apify is the world's largest marketplace of tools for web scraping, data extraction, and web automation. You can extract structured data from social media, e-commerce, search engines, maps, travel sites, or any other website.", + "required": [ + "apifyToken", + "tools" + ], + "additionalProperties": false, + "properties": { + "apifyToken": { + "type": "string" + }, + "tools": { + "description": "Comma-separated list of tools to enable. Can be either a tool category, a specific tool, or an Apify Actor. For example: \"actors,docs,apify/rag-web-browser\". For more details visit https://mcp.apify.com.", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/apify-mcp-server/overview" + }, + "arxiv": { + "title": "ArXiv MCP Server", + "description": "The ArXiv MCP Server provides a comprehensive bridge between AI assistants and arXiv's research repository through the Model Context Protocol (MCP). Features: • Search arXiv papers with advanced filtering • Download and store papers locally as markdown • Read and analyze paper content • Deep research analysis prompts • Local paper management and storage • Enhanced tool descriptions optimized for local AI models • Docker MCP Gateway compatible with detailed context Perfect for researchers, academics, and AI assistants conducting literature reviews and research analysis. **Recent Update**: Enhanced tool descriptions specifically designed to resolve local AI model confusion and improve Docker MCP Gateway compatibility.", + "required": [ + "storagePath" + ], + "additionalProperties": false, + "properties": { + "storagePath": { + "description": "Directory path where downloaded papers will be stored", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/arxiv-mcp-server/overview" + }, + "astGrep": { + "title": "ast-grep", + "description": "ast-grep is a fast and polyglot tool for code structural search, lint, rewriting at large scale.", + "required": [ + "path" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/ast-grep/overview" + }, + "astraDb": { + "title": "Astra DB", + "description": "An MCP server for Astra DB workloads.", + "required": [ + "astraDbApplicationToken", + "endpoint" + ], + "additionalProperties": false, + "properties": { + "astraDbApplicationToken": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/astra-db/overview" + }, + "astroDocs": { + "title": "Astro Docs", + "description": "Access the latest Astro web framework documentation, guides, and API references.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/astro-docs/overview" + }, + "atlan": { + "title": "Atlan MCP Server", + "description": "MCP server for interacting with Atlan services including asset search, updates, and lineage traversal for comprehensive data governance and discovery.", + "required": [ + "apiKey", + "baseUrl" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "baseUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/atlan/overview" + }, + "atlasDocs": { + "title": "Atlas Docs", + "description": "Provide LLMs hosted, clean markdown documentation of libraries and frameworks.", + "required": [ + "apiUrl" + ], + "additionalProperties": false, + "properties": { + "apiUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/atlas-docs/overview" + }, + "atlassian": { + "title": "Atlassian", + "description": "Tools for Atlassian products (Confluence and Jira). This integration supports both Atlassian Cloud and Jira Server/Data Center deployments.", + "required": [ + "jiraUrl", + "confluenceUrl" + ], + "additionalProperties": false, + "properties": { + "confluenceApiToken": { + "type": "string" + }, + "confluencePersonalToken": { + "type": "string" + }, + "confluenceUrl": { + "type": "string" + }, + "confluenceUsername": { + "type": "string" + }, + "jiraApiToken": { + "type": "string" + }, + "jiraPersonalToken": { + "type": "string" + }, + "jiraUrl": { + "type": "string" + }, + "jiraUsername": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/atlassian/overview" + }, + "audienseInsights": { + "title": "Audiense Insights", + "description": "Audiense Insights MCP Server is a server based on the Model Context Protocol (MCP) that allows Claude and other MCP-compatible clients to interact with your Audiense Insights account.", + "required": [ + "clientId" + ], + "additionalProperties": false, + "properties": { + "audienseClientSecret": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "twitterBearerToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/audiense-insights/overview" + }, + "awsCdk": { + "title": "AWS CDK", + "description": "AWS Cloud Development Kit (CDK) best practices, infrastructure as code patterns, and security compliance with CDK Nag.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-cdk-mcp-server/overview" + }, + "awsCore": { + "title": "AWS Core MCP Server", + "description": "Starting point for using the awslabs MCP servers.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-core-mcp-server/overview" + }, + "awsDiagram": { + "title": "AWS Diagram", + "description": "Seamlessly create diagrams using the Python diagrams package DSL. This server allows you to generate AWS diagrams, sequence diagrams, flow diagrams, and class diagrams using Python code.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-diagram/overview" + }, + "awsDocumentation": { + "title": "AWS Documentation", + "description": "Tools to access AWS documentation, search for content, and get recommendations.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-documentation/overview" + }, + "awsKbRetrievalServer": { + "title": "AWS KB Retrieval (Archived)", + "description": "An MCP server implementation for retrieving information from the AWS Knowledge Base using the Bedrock Agent Runtime.", + "required": [ + "accessKeyId" + ], + "additionalProperties": false, + "properties": { + "accessKeyId": { + "type": "string" + }, + "awsSecretAccessKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-kb-retrieval-server/overview" + }, + "awsTerraform": { + "title": "AWS Terraform", + "description": "Terraform on AWS best practices, infrastructure as code patterns, and security compliance with Checkov.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/aws-terraform/overview" + }, + "azure": { + "title": "Azure", + "description": "The Azure MCP Server, bringing the power of Azure to your agents.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/azure/overview" + }, + "beagleSecurity": { + "title": "Beagle security MCP server", + "description": "Connects with the Beagle Security backend using a user token to manage applications, run automated security tests, track vulnerabilities across environments, and gain intelligence from Application and API vulnerability data.", + "required": [ + "beagleSecurityApiToken" + ], + "additionalProperties": false, + "properties": { + "beagleSecurityApiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/beagle-security/overview" + }, + "bitrefill": { + "title": "Bitrefill", + "description": "A Model Context Protocol Server connector for Bitrefill public API, to enable AI agents to search and shop on Bitrefill.", + "required": [ + "apiId" + ], + "additionalProperties": false, + "properties": { + "apiId": { + "type": "string" + }, + "apiSecret": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/bitrefill/overview" + }, + "box": { + "title": "Box", + "description": "An MCP server capable of interacting with the Box API.", + "required": [ + "clientId" + ], + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/box/overview" + }, + "brave": { + "title": "Brave Search", + "description": "Search the Web for pages, images, news, videos, and more using the Brave Search API.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/brave/overview" + }, + "browserbase": { + "title": "Browserbase", + "description": "Allow LLMs to control a browser with Browserbase and Stagehand for AI-powered web automation, intelligent data extraction, and screenshot capture.", + "required": [ + "apiKey", + "geminiApiKey", + "projectId" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "geminiApiKey": { + "type": "string" + }, + "projectId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/browserbase/overview" + }, + "buildkite": { + "title": "Buildkite", + "description": "Buildkite MCP lets agents interact with Buildkite Builds, Jobs, Logs, Packages and Test Suites.", + "required": [ + "apiToken" + ], + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/buildkite/overview" + }, + "camunda": { + "title": "Camunda BPM process engine MCP Server", + "description": "Tools to interact with the Camunda 7 Community Edition Engine using the Model Context Protocol (MCP). Whether you're automating workflows, querying process instances, or integrating with external systems, Camunda MCP Server is your agentic solution for seamless interaction with Camunda.", + "required": [ + "camundahost" + ], + "additionalProperties": false, + "properties": { + "camundahost": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/camunda/overview" + }, + "cdataConnectcloud": { + "title": "CData Connect Cloud", + "description": "This fully functional MCP Server allows you to connect to any data source in Connect Cloud from Claude Desktop.", + "required": [ + "username" + ], + "additionalProperties": false, + "properties": { + "cdataPat": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cdata-connectcloud/overview" + }, + "charmhealth": { + "title": "CharmHealth MCP Server", + "description": "An MCP server for CharmHealth EHR that allows LLMs and MCP clients to interact with patient records, encounters, and practice information.", + "required": [ + "charmhealthApiKey", + "charmhealthBaseUrl", + "charmhealthClientId", + "charmhealthClientSecret", + "charmhealthRedirectUri", + "charmhealthRefreshToken", + "charmhealthTokenUrl" + ], + "additionalProperties": false, + "properties": { + "charmhealthApiKey": { + "type": "string" + }, + "charmhealthBaseUrl": { + "type": "string" + }, + "charmhealthClientId": { + "type": "string" + }, + "charmhealthClientSecret": { + "type": "string" + }, + "charmhealthRedirectUri": { + "type": "string" + }, + "charmhealthRefreshToken": { + "type": "string" + }, + "charmhealthTokenUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/charmhealth-mcp-server/overview" + }, + "chroma": { + "title": "Chroma", + "description": "A Model Context Protocol (MCP) server implementation that provides database capabilities for Chroma.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/chroma/overview" + }, + "circleci": { + "title": "CircleCI", + "description": "A specialized server implementation for the Model Context Protocol (MCP) designed to integrate with CircleCI's development workflow. This project serves as a bridge between CircleCI's infrastructure and the Model Context Protocol, enabling enhanced AI-powered development experiences.", + "required": [ + "token", + "url" + ], + "additionalProperties": false, + "properties": { + "token": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/circleci/overview" + }, + "clickhouse": { + "title": "Official ClickHouse MCP Server", + "description": "Official ClickHouse MCP Server.", + "required": [ + "connectTimeout", + "host", + "password", + "port", + "secure", + "sendReceiveTimeout", + "user", + "verify" + ], + "additionalProperties": false, + "properties": { + "connectTimeout": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "string" + }, + "secure": { + "type": "string" + }, + "sendReceiveTimeout": { + "type": "string" + }, + "user": { + "type": "string" + }, + "verify": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/clickhouse/overview" + }, + "close": { + "title": "Close", + "description": "Streamline sales processes with integrated calling, email, SMS, and automated workflows for small and scaling businesses.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/close/overview" + }, + "cloudRun": { + "title": "Cloud Run MCP", + "description": "MCP server to deploy apps to Cloud Run.", + "required": [ + "credentialsPath" + ], + "additionalProperties": false, + "properties": { + "credentialsPath": { + "description": "path to application-default credentials (eg $HOME/.config/gcloud/application_default_credentials.json )", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cloud-run-mcp/overview" + }, + "cloudflareDocs": { + "title": "Cloudflare Docs", + "description": "Access the latest documentation on Cloudflare products such as Workers, Pages, R2, D1, KV.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cloudflare-docs/overview" + }, + "cockroachdb": { + "title": "CockroachDB", + "description": "Enable AI agents to manage, monitor, and query CockroachDB using natural language. Perform complex database operations, cluster management, and query execution seamlessly through AI-driven workflows. Integrate effortlessly with MCP clients for scalable and high-performance data operations.", + "required": [ + "caPath", + "crdbPwd", + "database", + "host", + "port", + "sslCertfile", + "sslKeyfile", + "sslMode", + "username" + ], + "additionalProperties": false, + "properties": { + "caPath": { + "type": "string" + }, + "crdbPwd": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "sslCertfile": { + "type": "string" + }, + "sslKeyfile": { + "type": "string" + }, + "sslMode": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cockroachdb/overview" + }, + "codeInterpreter": { + "title": "Python Interpreter", + "description": "A Python-based execution tool that mimics a Jupyter notebook environment. It accepts code snippets, executes them, and maintains state across sessions — preserving variables, imports, and past results. Ideal for iterative development, debugging, or code execution.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-code-interpreter/overview" + }, + "context7": { + "title": "Context7", + "description": "Context7 MCP Server -- Up-to-date code documentation for LLMs and AI code editors.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/context7/overview" + }, + "couchbase": { + "title": "Couchbase", + "description": "Couchbase is a distributed document database with a powerful search engine and in-built operational and analytical capabilities.", + "required": [ + "cbBucketName", + "cbConnectionString", + "cbMcpReadOnlyQueryMode", + "cbPassword", + "cbUsername" + ], + "additionalProperties": false, + "properties": { + "cbBucketName": { + "description": "Bucket in the Couchbase cluster to use for the MCP server.", + "type": "string" + }, + "cbConnectionString": { + "description": "Connection string for the Couchbase cluster.", + "type": "string" + }, + "cbMcpReadOnlyQueryMode": { + "description": "Setting to \"true\" (default) enables read-only query mode while running SQL++ queries.", + "type": "string" + }, + "cbPassword": { + "type": "string" + }, + "cbUsername": { + "description": "Username for the Couchbase cluster with access to the bucket.", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/couchbase/overview" + }, + "cylera": { + "title": "The official MCP Server for Cylera.", + "description": "Brings context about device inventory, threats, risks and utilization powered by the Cylera Partner API into an LLM.", + "required": [ + "cyleraBaseUrl", + "cyleraPassword", + "cyleraUsername" + ], + "additionalProperties": false, + "properties": { + "cyleraBaseUrl": { + "type": "string" + }, + "cyleraPassword": { + "type": "string" + }, + "cyleraUsername": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cylera-mcp-server/overview" + }, + "cyreslabAiShodan": { + "title": "Shodan", + "description": "A Model Context Protocol server that provides access to Shodan API functionality.", + "required": [ + "shodanApiKey" + ], + "additionalProperties": false, + "properties": { + "shodanApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/cyreslab-ai-shodan/overview" + }, + "dappier": { + "title": "Dappier", + "description": "Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dappier/overview" + }, + "dappierRemote": { + "title": "Dappier Remote MCP Server", + "description": "Enable fast, free real-time web search and access premium data from trusted media brands—news, financial markets, sports, entertainment, weather, and more. Build powerful AI agents with Dappier.", + "required": [ + "dappierRemoteApiKey" + ], + "additionalProperties": false, + "properties": { + "dappierRemoteApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dappier-remote/overview" + }, + "dart": { + "title": "Dart AI", + "description": "Dart AI Model Context Protocol (MCP) server.", + "required": [ + "host", + "token" + ], + "additionalProperties": false, + "properties": { + "host": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dart/overview" + }, + "databaseServer": { + "title": "MCP Database Server", + "description": "Comprehensive database server supporting PostgreSQL, MySQL, and SQLite with natural language SQL query capabilities. Enables AI agents to interact with databases through both direct SQL and natural language queries.", + "required": [ + "databaseUrl" + ], + "additionalProperties": false, + "properties": { + "databaseUrl": { + "description": "Connection string for your database. Examples: SQLite: sqlite+aiosqlite:///data/mydb.db, PostgreSQL: postgresql+asyncpg://user:password@localhost:5432/mydb, MySQL: mysql+aiomysql://user:password@localhost:3306/mydb", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/database-server/overview" + }, + "databutton": { + "title": "Databutton", + "description": "Databutton MCP Server.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/databutton/overview" + }, + "deepwiki": { + "title": "DeepWiki", + "description": "Tools for fetching and asking questions about GitHub repositories.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/deepwiki/overview" + }, + "descope": { + "title": "Descope", + "description": "The Descope Model Context Protocol (MCP) server provides an interface to interact with Descope's Management APIs, enabling the search and retrieval of project-related information.", + "required": [ + "projectId" + ], + "additionalProperties": false, + "properties": { + "managementKey": { + "type": "string" + }, + "projectId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/descope/overview" + }, + "desktopCommander": { + "title": "Desktop Commander", + "description": "Search, update, manage files and run terminal commands with AI.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "description": "List of directories that Desktop Commander can access", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/desktop-commander/overview" + }, + "devhubCms": { + "title": "DevHub CMS", + "description": "DevHub CMS LLM integration through the Model Context Protocol.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "devhubApiKey": { + "type": "string" + }, + "devhubApiSecret": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/devhub-cms/overview" + }, + "discord": { + "title": "Discord", + "description": "Interact with the Discord platform.", + "required": [ + "discordToken" + ], + "additionalProperties": false, + "properties": { + "discordToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-discord/overview" + }, + "dockerhub": { + "title": "Docker Hub", + "description": "Docker Hub official MCP server.", + "required": [ + "hubPatToken", + "username" + ], + "additionalProperties": false, + "properties": { + "hubPatToken": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dockerhub/overview" + }, + "dodoPayments": { + "title": "Dodo Payments", + "description": "Tools for cross-border payments, taxes, and compliance.", + "required": [ + "dodoPaymentsApiKey" + ], + "additionalProperties": false, + "properties": { + "dodoPaymentsApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dodo-payments/overview" + }, + "dreamfactory": { + "title": "DreamFactory MCP Server", + "description": "DreamFactory is a REST API generation platform with support for hundreds of data sources, including Microsoft SQL Server, MySQL, PostgreSQL, and MongoDB. The DreamFactory MCP Server makes it easy for users to securely interact with their data sources via an MCP client.", + "required": [ + "dreamfactoryapikey", + "dreamfactoryurl" + ], + "additionalProperties": false, + "properties": { + "dreamfactoryapikey": { + "type": "string" + }, + "dreamfactoryurl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dreamfactory-mcp/overview" + }, + "duckduckgo": { + "title": "DuckDuckGo", + "description": "A Model Context Protocol (MCP) server that provides web search capabilities through DuckDuckGo, with additional features for content fetching and parsing.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/duckduckgo/overview" + }, + "dynatrace": { + "title": "Dynatrace MCP Server", + "description": "This MCP Server allows interaction with the Dynatrace observability platform, brining real-time observability data directly into your development workflow.", + "required": [ + "oauthClientId", + "oauthClientSecret", + "url" + ], + "additionalProperties": false, + "properties": { + "oauthClientId": { + "type": "string" + }, + "oauthClientSecret": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/dynatrace-mcp-server/overview" + }, + "e2b": { + "title": "E2B", + "description": "Giving Claude ability to run code with E2B via MCP (Model Context Protocol).", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/e2b/overview" + }, + "edubase": { + "title": "EduBase", + "description": "The EduBase MCP server enables Claude and other LLMs to interact with EduBase's comprehensive e-learning platform through the Model Context Protocol (MCP).", + "required": [ + "app", + "url" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "app": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/edubase/overview" + }, + "effect": { + "title": "Effect MCP", + "description": "Tools and resources for writing Effect code in Typescript.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/effect-mcp/overview" + }, + "elasticsearch": { + "title": "Elasticsearch", + "description": "Interact with your Elasticsearch indices through natural language conversations.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "esApiKey": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/elasticsearch/overview" + }, + "elevenlabs": { + "title": "Elevenlabs MCP", + "description": "Official ElevenLabs Model Context Protocol (MCP) server that enables interaction with powerful Text to Speech and audio processing APIs.", + "required": [ + "data" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "data": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/elevenlabs/overview" + }, + "everart": { + "title": "EverArt (Archived)", + "description": "Image generation server using EverArt's API.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/everart/overview" + }, + "exa": { + "title": "Exa", + "description": "Exa MCP for web search and web crawling!.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/exa/overview" + }, + "explorium": { + "title": "Explorium B2B Data", + "description": "Discover companies, contacts, and business insights—powered by dozens of trusted external data sources.", + "required": [ + "apiAccessToken" + ], + "additionalProperties": false, + "properties": { + "apiAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/explorium/overview" + }, + "fetch": { + "title": "Fetch (Reference)", + "description": "Fetches a URL from the internet and extracts its contents as markdown.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/fetch/overview" + }, + "fibery": { + "title": "Fibery", + "description": "Interact with your Fibery workspace.", + "required": [ + "apiToken", + "host" + ], + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string" + }, + "host": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/fibery/overview" + }, + "filesystem": { + "title": "Filesystem (Reference)", + "description": "Local filesystem access with configurable allowed paths.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/filesystem/overview" + }, + "findADomain": { + "title": "Find-A-Domain", + "description": "Tools for finding domain names.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/find-a-domain/overview" + }, + "firecrawl": { + "title": "Firecrawl", + "description": "🔥 Official Firecrawl MCP Server - Adds powerful web scraping and search to Cursor, Claude and any other LLM clients.", + "required": [ + "apiKey", + "creditCriticalThreshold", + "creditWarningThreshold", + "retryBackoffFactor", + "retryDelay", + "retryMax", + "retryMaxDelay", + "url" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "creditCriticalThreshold": { + "type": "integer" + }, + "creditWarningThreshold": { + "type": "integer" + }, + "retryBackoffFactor": { + "type": "integer" + }, + "retryDelay": { + "type": "integer" + }, + "retryMax": { + "type": "integer" + }, + "retryMaxDelay": { + "type": "integer" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/firecrawl/overview" + }, + "firewalla": { + "title": "Firewalla MCP Server", + "description": "Real-time network monitoring, security analysis, and firewall management through 28 specialized tools. Access security alerts, network flows, device status, and firewall rules directly from your Firewalla device.", + "required": [ + "boxId", + "firewallaMspToken", + "mspId" + ], + "additionalProperties": false, + "properties": { + "boxId": { + "description": "Your Firewalla Box Global ID", + "type": "string" + }, + "firewallaMspToken": { + "type": "string" + }, + "mspId": { + "description": "Your Firewalla MSP domain (e.g., yourdomain.firewalla.net)", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/firewalla-mcp-server/overview" + }, + "flexprice": { + "title": "FlexPrice", + "description": "Official flexprice MCP Server.", + "required": [ + "apiKey", + "baseUrl" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "baseUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/flexprice/overview" + }, + "git": { + "title": "Git (Reference)", + "description": "Git repository interaction and automation.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/git/overview" + }, + "github": { + "title": "GitHub (Archived)", + "description": "Tools for interacting with the GitHub API, enabling file operations, repository management, search functionality, and more.", + "required": [ + "personalAccessToken" + ], + "additionalProperties": false, + "properties": { + "personalAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/github/overview" + }, + "githubChat": { + "title": "GitHub Chat", + "description": "A Model Context Protocol (MCP) for analyzing and querying GitHub repositories using the GitHub Chat API.", + "required": [ + "githubApiKey" + ], + "additionalProperties": false, + "properties": { + "githubApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/github-chat/overview" + }, + "githubOfficial": { + "title": "GitHub Official", + "description": "Official GitHub MCP Server, by GitHub. Provides seamless integration with GitHub APIs, enabling advanced automation and interaction capabilities for developers and tools.", + "required": [ + "githubPersonalAccessToken" + ], + "additionalProperties": false, + "properties": { + "githubPersonalAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/github-official/overview" + }, + "gitlab": { + "title": "GitLab (Archived)", + "description": "MCP Server for the GitLab API, enabling project management, file operations, and more.", + "required": [ + "personalAccessToken", + "url" + ], + "additionalProperties": false, + "properties": { + "personalAccessToken": { + "type": "string" + }, + "url": { + "description": "api url - optional for self-hosted instances", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/gitlab/overview" + }, + "gitmcp": { + "title": "GitMCP", + "description": "Tools for interacting with Git repositories.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/gitmcp/overview" + }, + "glif": { + "title": "glif.app", + "description": "Easily run glif.app AI workflows inside your LLM: image generators, memes, selfies, and more. Glif supports all major multimedia AI models inside one app.", + "required": [ + "apiToken", + "ids", + "ignoredSaved" + ], + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string" + }, + "ids": { + "type": "string" + }, + "ignoredSaved": { + "type": "boolean" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/glif/overview" + }, + "gmail": { + "title": "Gmail MCP Server", + "description": "A Model Context Protocol server for Gmail operations using IMAP/SMTP with app password authentication. Supports listing messages, searching emails, and sending messages. To create your app password, visit your Google Account settings under Security \u003e App Passwords. Or visit the link https://myaccount.google.com/apppasswords.", + "required": [ + "emailAddress" + ], + "additionalProperties": false, + "properties": { + "emailAddress": { + "description": "Your Gmail email address", + "type": "string" + }, + "emailPassword": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/gmail-mcp/overview" + }, + "googleMaps": { + "title": "Google Maps (Archived)", + "description": "Tools for interacting with the Google Maps API.", + "required": [ + "googleMapsApiKey" + ], + "additionalProperties": false, + "properties": { + "googleMapsApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/google-maps/overview" + }, + "googleMapsComprehensive": { + "title": "Google Maps Comprehensive MCP", + "description": "Complete Google Maps integration with 8 tools including geocoding, places search, directions, elevation data, and more using Google's latest APIs.", + "required": [ + "googleMapsApiKey" + ], + "additionalProperties": false, + "properties": { + "googleMapsApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/google-maps-comprehensive/overview" + }, + "grafana": { + "title": "Grafana", + "description": "MCP server for Grafana.", + "required": [ + "apiKey", + "url" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/grafana/overview" + }, + "gyazo": { + "title": "Gyazo", + "description": "Official Model Context Protocol server for Gyazo.", + "required": [ + "accessToken" + ], + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/gyazo/overview" + }, + "hackernews": { + "title": "Hackernews mcp", + "description": "A Model Context Protocol (MCP) server that provides access to Hacker News stories, comments, and user data, with support for search and content retrieval.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-hackernews/overview" + }, + "hackle": { + "title": "Hackle", + "description": "Model Context Protocol server for Hackle.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hackle/overview" + }, + "handwritingOcr": { + "title": "Handwriting OCR", + "description": "Model Context Protocol (MCP) Server for Handwriting OCR.", + "required": [ + "apiToken" + ], + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/handwriting-ocr/overview" + }, + "hdx": { + "title": "Humanitarian Data Exchange MCP Server", + "description": "HDX MCP Server provides access to humanitarian data through the Humanitarian Data Exchange (HDX) API - https://data.humdata.org/hapi. This server offers 33 specialized tools for retrieving humanitarian information including affected populations (refugees, IDPs, returnees), baseline demographics, food security indicators, conflict data, funding information, and operational presence across hundreds of countries and territories. See repository for instructions on getting a free HDX_APP_INDENTIFIER for access.", + "required": [ + "appIdentifier" + ], + "additionalProperties": false, + "properties": { + "appIdentifier": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hdx/overview" + }, + "heroku": { + "title": "Heroku", + "description": "Heroku Platform MCP Server using the Heroku CLI.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/heroku/overview" + }, + "hostinger": { + "title": "Hostinger API MCP Server", + "description": "Interact with Hostinger services over the Hostinger API.", + "required": [ + "apitoken" + ], + "additionalProperties": false, + "properties": { + "apitoken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hostinger-mcp-server/overview" + }, + "hoverfly": { + "title": "Hoverfly MCP Server", + "description": "A Model Context Protocol (MCP) server that exposes Hoverfly as a programmable tool for AI assistants like Cursor, Claude, GitHub Copilot, and others supporting MCP. It enables dynamic mocking of third-party APIs to unblock development, automate testing, and simulate unavailable services during integration.", + "required": [ + "data" + ], + "additionalProperties": false, + "properties": { + "data": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hoverfly-mcp-server/overview" + }, + "hubspot": { + "title": "HubSpot", + "description": "Unite marketing, sales, and customer service with AI-powered automation, lead management, and comprehensive analytics.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hubspot/overview" + }, + "huggingFace": { + "title": "Hugging Face", + "description": "Tools for interacting with Hugging Face models, datasets, research papers, and more.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hugging-face/overview" + }, + "hummingbot": { + "title": "Hummingbot MCP: Trading Agent", + "description": "Hummingbot MCP is an open-source toolset that lets you control and monitor your Hummingbot trading bots through AI-powered commands and automation.", + "required": [ + "apiUrl" + ], + "additionalProperties": false, + "properties": { + "apiUrl": { + "type": "string" + }, + "hummingbotApiPassword": { + "type": "string" + }, + "hummingbotApiUsername": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hummingbot-mcp/overview" + }, + "husqvarnaAutomower": { + "title": "Husqvarna Automower", + "description": "MCP Server for huqsvarna automower.", + "required": [ + "clientId", + "husqvarnaClientSecret" + ], + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "husqvarnaClientSecret": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/husqvarna-automower/overview" + }, + "hyperbrowser": { + "title": "Hyperbrowser", + "description": "A MCP server implementation for hyperbrowser.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hyperbrowser/overview" + }, + "hyperspell": { + "title": "Hyperspell", + "description": "Hyperspell MCP Server.", + "required": [ + "collection", + "token", + "useResources" + ], + "additionalProperties": false, + "properties": { + "collection": { + "type": "string" + }, + "token": { + "type": "string" + }, + "useResources": { + "type": "boolean" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/hyperspell/overview" + }, + "iaptic": { + "title": "Iaptic", + "description": "Model Context Protocol server for interacting with iaptic.", + "required": [ + "appName" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "appName": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/iaptic/overview" + }, + "inspektorGadget": { + "title": "Inspektor Gadget", + "description": "AI interface to troubleshoot and observe Kubernetes/Container workloads.", + "required": [ + "kubeconfig" + ], + "additionalProperties": false, + "properties": { + "gadgetImages": { + "description": "Comma-separated list of gadget images (trace_dns, trace_tcp, etc) to use, allowing control over which gadgets are available as MCP tools", + "type": "string" + }, + "kubeconfig": { + "description": "Path to the kubeconfig file for accessing Kubernetes clusters", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/inspektor-gadget/overview" + }, + "javadocs": { + "title": "Javadocs", + "description": "Access to Java, Kotlin, and Scala library documentation.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/javadocs/overview" + }, + "jetbrains": { + "title": "JetBrains", + "description": "A model context protocol server to work with JetBrains IDEs: IntelliJ, PyCharm, WebStorm, etc. Also, works with Android Studio.", + "required": [ + "port" + ], + "additionalProperties": false, + "properties": { + "port": { + "type": "integer" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/jetbrains/overview" + }, + "kafkaSchemaReg": { + "title": "Kafka Schema Registry MCP", + "description": "Comprehensive MCP server for Kafka Schema Registry operations. Features multi-registry support, schema contexts, migration tools, OAuth authentication, and 57+ tools for complete schema management. Supports SLIM_MODE for optimal performance.", + "required": [ + "registryUrl" + ], + "additionalProperties": false, + "properties": { + "registryUrl": { + "description": "Schema Registry URL", + "type": "string" + }, + "schemaRegistryPassword": { + "type": "string" + }, + "schemaRegistryUser": { + "type": "string" + }, + "slimMode": { + "description": "Enable SLIM_MODE for better performance", + "type": "string" + }, + "viewonly": { + "description": "Enable read-only mode", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kafka-schema-reg-mcp/overview" + }, + "kagisearch": { + "title": "Kagi search", + "description": "The Official Model Context Protocol (MCP) server for Kagi search \u0026 other tools.", + "required": [ + "engine", + "kagiApiKey" + ], + "additionalProperties": false, + "properties": { + "engine": { + "type": "string" + }, + "kagiApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kagisearch/overview" + }, + "keboola": { + "title": "Keboola MCP Server", + "description": "Keboola MCP Server is an open-source bridge between your Keboola project and modern AI tools.", + "required": [ + "kbcStorageToken", + "kbcWorkspaceSchema" + ], + "additionalProperties": false, + "properties": { + "kbcStorageToken": { + "type": "string" + }, + "kbcWorkspaceSchema": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/keboola-mcp/overview" + }, + "kong": { + "title": "Kong Konnect", + "description": "A Model Context Protocol (MCP) server for interacting with Kong Konnect APIs, allowing AI assistants to query and analyze Kong Gateway configurations, traffic, and analytics.", + "required": [ + "konnectAccessToken", + "region" + ], + "additionalProperties": false, + "properties": { + "konnectAccessToken": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kong/overview" + }, + "kubectl": { + "title": "Kubectl MCP Server", + "description": "MCP Server that enables AI assistants to interact with Kubernetes clusters via kubectl operations.", + "required": [ + "kubeconfig" + ], + "additionalProperties": false, + "properties": { + "kubeconfig": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kubectl-mcp-server/overview" + }, + "kubernetes": { + "title": "Kubernetes", + "description": "Connect to a Kubernetes cluster and manage it.", + "required": [ + "configPath" + ], + "additionalProperties": false, + "properties": { + "configPath": { + "description": "the path to the host .kube/config", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/kubernetes/overview" + }, + "lara": { + "title": "Lara Translate", + "description": "Connect to Lara Translate API, enabling powerful translation capabilities with support for language detection and context-aware translations.", + "required": [ + "keyId" + ], + "additionalProperties": false, + "properties": { + "accessKeySecret": { + "type": "string" + }, + "keyId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/lara/overview" + }, + "line": { + "title": "LINE", + "description": "MCP server that integrates the LINE Messaging API to connect an AI Agent to the LINE Official Account.", + "required": [ + "userId" + ], + "additionalProperties": false, + "properties": { + "channelAccessToken": { + "type": "string" + }, + "userId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/line/overview" + }, + "linkedin": { + "title": "LinkedIn MCP Server", + "description": "This MCP server allows Claude and other AI assistants to access your LinkedIn. Scrape LinkedIn profiles and companies, get your recommended jobs, and perform job searches. Set your li_at LinkedIn cookie to use this server.", + "required": [ + "linkedinCookie", + "userAgent" + ], + "additionalProperties": false, + "properties": { + "linkedinCookie": { + "type": "string" + }, + "userAgent": { + "description": "Custom user agent string (optional, helps avoid detection and cookie login issues)", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/linkedin-mcp-server/overview" + }, + "llmtxt": { + "title": "LLM Text", + "description": "Discovers and retrieves llms.txt from websites.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/llmtxt/overview" + }, + "maestro": { + "title": "Maestro MCP Server", + "description": "A Model Context Protocol (MCP) server exposing Bitcoin blockchain data through the Maestro API platform. Provides tools to explore blocks, transactions, addresses, inscriptions, runes, and other metaprotocol data.", + "required": [ + "apiKeyApiKey" + ], + "additionalProperties": false, + "properties": { + "apiKeyApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/maestro-mcp-server/overview" + }, + "manifold": { + "title": "Manifold", + "description": "Tools for accessing the Manifold Markets online prediction market platform.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/manifold/overview" + }, + "mapbox": { + "title": "Mapbox MCP Server", + "description": "Transform any AI agent into a geospatially-aware system with Mapbox APIs. Provides geocoding, POI search, routing, travel time matrices, isochrones, and static map generation.", + "required": [ + "accessToken" + ], + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mapbox/overview" + }, + "mapboxDevkit": { + "title": "Mapbox Developer MCP Server", + "description": "Direct access to Mapbox developer APIs for AI assistants. Enables style management, token management, GeoJSON preview, and other developer tools for building Mapbox applications.", + "required": [ + "mapboxAccessToken" + ], + "additionalProperties": false, + "properties": { + "mapboxAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mapbox-devkit/overview" + }, + "markdownify": { + "title": "Markdownify", + "description": "A Model Context Protocol server for converting almost anything to Markdown.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/markdownify/overview" + }, + "markitdown": { + "title": "Markitdown", + "description": "A lightweight MCP server for calling MarkItDown.", + "required": [ + "paths" + ], + "additionalProperties": false, + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/markitdown/overview" + }, + "mavenTools": { + "title": "Maven Tools MCP Server", + "description": "JVM dependency intelligence for any build tool using Maven Central Repository. Includes Context7 integration for upgrade documentation and guidance.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/maven-tools-mcp/overview" + }, + "memory": { + "title": "Memory (Reference)", + "description": "Knowledge graph-based persistent memory system.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/memory/overview" + }, + "mercadoLibre": { + "title": "Mercado Libre", + "description": "Provides access to Mercado Libre E-Commerce API.", + "required": [ + "mercadoLibreApiKey" + ], + "additionalProperties": false, + "properties": { + "mercadoLibreApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mercado-libre/overview" + }, + "mercadoPago": { + "title": "Mercado Pago", + "description": "Provides access to Mercado Pago Marketplace API.", + "required": [ + "mercadoPagoApiKey" + ], + "additionalProperties": false, + "properties": { + "mercadoPagoApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mercado-pago/overview" + }, + "metabase": { + "title": "Metabase MCP", + "description": "A comprehensive MCP server for Metabase with 70+ tools.", + "required": [ + "apiKey", + "metabaseurl", + "metabaseusername", + "password" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "metabaseurl": { + "type": "string" + }, + "metabaseusername": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/metabase/overview" + }, + "minecraftWiki": { + "title": "Minecraft Wiki", + "description": "A MCP Server for browsing the official Minecraft Wiki!.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/minecraft-wiki/overview" + }, + "mongodb": { + "title": "MongoDB", + "description": "A Model Context Protocol server to connect to MongoDB databases and MongoDB Atlas Clusters.", + "required": [ + "mdbMcpConnectionString" + ], + "additionalProperties": false, + "properties": { + "mdbMcpConnectionString": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mongodb/overview" + }, + "multiversxMx": { + "title": "MultiversX", + "description": "MCP Server for MultiversX.", + "required": [ + "network", + "wallet" + ], + "additionalProperties": false, + "properties": { + "network": { + "type": "string" + }, + "wallet": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/multiversx-mx/overview" + }, + "nasdaqDataLink": { + "title": "Nasdaq Data Link", + "description": "MCP server to interact with the data feeds provided by the Nasdaq Data Link. Developed by the community and maintained by Stefano Amorelli.", + "required": [ + "nasdaqDataLinkApiKey" + ], + "additionalProperties": false, + "properties": { + "nasdaqDataLinkApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/nasdaq-data-link/overview" + }, + "needle": { + "title": "Needle", + "description": "Production-ready RAG service to search and retrieve data from your documents.", + "required": [ + "needleApiKey" + ], + "additionalProperties": false, + "properties": { + "needleApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/needle-mcp/overview" + }, + "neo4jCloudAuraApi": { + "title": "Neo4j Cloud Aura Api", + "description": "Manage Neo4j Aura database instances through the Neo4j Aura API.", + "required": [ + "clientId" + ], + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string" + }, + "neo4jAuraClientSecret": { + "type": "string" + }, + "serverAllowOrigins": { + "type": "string" + }, + "serverAllowedHosts": { + "type": "string" + }, + "serverHost": { + "type": "string" + }, + "serverPath": { + "type": "string" + }, + "serverPort": { + "type": "string" + }, + "transport": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neo4j-cloud-aura-api/overview" + }, + "neo4jCypher": { + "title": "Neo4j Cypher", + "description": "Interact with Neo4j using Cypher graph queries.", + "required": [ + "url", + "username" + ], + "additionalProperties": false, + "properties": { + "database": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "neo4jPassword": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "readTimeout": { + "type": "string" + }, + "responseTokenLimit": { + "type": "string" + }, + "serverAllowOrigins": { + "type": "string" + }, + "serverAllowedHosts": { + "type": "string" + }, + "serverHost": { + "type": "string" + }, + "serverPath": { + "type": "string" + }, + "serverPort": { + "type": "string" + }, + "transport": { + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neo4j-cypher/overview" + }, + "neo4jDataModeling": { + "title": "Neo4j Data Modeling", + "description": "MCP server that assists in creating, validating and visualizing graph data models.", + "required": [ + "serverAllowOrigins", + "serverAllowedHosts", + "serverHost", + "serverPath", + "serverPort", + "transport" + ], + "additionalProperties": false, + "properties": { + "serverAllowOrigins": { + "type": "string" + }, + "serverAllowedHosts": { + "type": "string" + }, + "serverHost": { + "type": "string" + }, + "serverPath": { + "type": "string" + }, + "serverPort": { + "type": "string" + }, + "transport": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neo4j-data-modeling/overview" + }, + "neo4jMemory": { + "title": "Neo4j Memory", + "description": "Provide persistent memory capabilities through Neo4j graph database integration.", + "required": [ + "url", + "username" + ], + "additionalProperties": false, + "properties": { + "database": { + "type": "string" + }, + "neo4jPassword": { + "type": "string" + }, + "serverAllowOrigins": { + "type": "string" + }, + "serverAllowedHosts": { + "type": "string" + }, + "serverHost": { + "type": "string" + }, + "serverPath": { + "type": "string" + }, + "serverPort": { + "type": "string" + }, + "transport": { + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neo4j-memory/overview" + }, + "neon": { + "title": "Neon", + "description": "MCP server for interacting with Neon Management API and databases.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/neon/overview" + }, + "nodeCodeSandbox": { + "title": "Node.js Sandbox", + "description": "A Node.js–based Model Context Protocol server that spins up disposable Docker containers to execute arbitrary JavaScript.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/node-code-sandbox/overview" + }, + "notion": { + "title": "Notion", + "description": "Official Notion MCP Server.", + "required": [ + "internalIntegrationToken" + ], + "additionalProperties": false, + "properties": { + "internalIntegrationToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/notion/overview" + }, + "novita": { + "title": "Novita", + "description": "Seamless interaction with Novita AI platform resources.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/novita/overview" + }, + "npmSentinel": { + "title": "NPM Sentinel", + "description": "MCP server that enables intelligent NPM package analysis powered by AI.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/npm-sentinel/overview" + }, + "obsidian": { + "title": "Obsidian", + "description": "MCP server that interacts with Obsidian via the Obsidian rest API community plugin.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/obsidian/overview" + }, + "oktaMcpFctr": { + "title": "Okta MCP Server", + "description": "Secure Okta identity and access management via Model Context Protocol (MCP). Access Okta users, groups, applications, logs, and policies through AI assistants with enterprise-grade security.", + "required": [ + "clientOrgurl" + ], + "additionalProperties": false, + "properties": { + "clientOrgurl": { + "description": "Okta organization URL (e.g., https://dev-123456.okta.com)", + "type": "string" + }, + "concurrentLimit": { + "description": "Maximum concurrent requests to Okta API", + "type": "string" + }, + "logLevel": { + "description": "Logging level for server output", + "type": "string" + }, + "oktaApiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/okta-mcp-fctr/overview" + }, + "omi": { + "title": "omi-mcp", + "description": "A Model Context Protocol server for Omi interaction and automation. This server provides tools to read, search, and manipulate Memories and Conversations.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/omi/overview" + }, + "onlyofficeDocspace": { + "title": "ONLYOFFICE DocSpace", + "description": "ONLYOFFICE DocSpace is a room-based collaborative platform which allows organizing a clear file structure depending on users' needs or project goals.", + "required": [ + "baseUrl", + "docspaceApiKey", + "docspaceAuthToken", + "docspacePassword", + "docspaceUsername", + "dynamic", + "origin", + "toolsets", + "userAgent" + ], + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string" + }, + "docspaceApiKey": { + "type": "string" + }, + "docspaceAuthToken": { + "type": "string" + }, + "docspacePassword": { + "type": "string" + }, + "docspaceUsername": { + "type": "string" + }, + "dynamic": { + "type": "boolean" + }, + "origin": { + "type": "string" + }, + "toolsets": { + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/onlyoffice-docspace/overview" + }, + "openapi": { + "title": "OpenAPI Toolkit for MCP", + "description": "Fetch, validate, and generate code or curl from any OpenAPI or Swagger spec - all from a single URL.", + "required": [ + "mode" + ], + "additionalProperties": false, + "properties": { + "mode": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openapi/overview" + }, + "openapiSchema": { + "title": "OpenAPI Schema", + "description": "OpenAPI Schema Model Context Protocol Server.", + "required": [ + "SchemaPath" + ], + "additionalProperties": false, + "properties": { + "SchemaPath": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openapi-schema/overview" + }, + "openbnbAirbnb": { + "title": "Airbnb Search", + "description": "MCP Server for searching Airbnb and get listing details.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openbnb-airbnb/overview" + }, + "openmesh": { + "title": "OpenMesh", + "description": "Discover and connect to a curated marketplace of MCP servers for extending AI agent capabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openmesh/overview" + }, + "openweather": { + "title": "Openweather", + "description": "A simple MCP service that provides current weather and 5-day forecast using the free OpenWeatherMap API.", + "required": [ + "owmApiKey" + ], + "additionalProperties": false, + "properties": { + "owmApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openweather/overview" + }, + "openzeppelinCairo": { + "title": "OpenZeppelin Cairo Contracts", + "description": "Access to OpenZeppelin Cairo Contracts.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openzeppelin-cairo/overview" + }, + "openzeppelinSolidity": { + "title": "OpenZeppelin Solidity Contracts", + "description": "Access to OpenZeppelin Solidity Contracts.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openzeppelin-solidity/overview" + }, + "openzeppelinStellar": { + "title": "OpenZeppelin Stellar Contracts", + "description": "Access to OpenZeppelin Stellar Contracts.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openzeppelin-stellar/overview" + }, + "openzeppelinStylus": { + "title": "OpenZeppelin Stylus Contracts", + "description": "Access to OpenZeppelin Stylus Contracts.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/openzeppelin-stylus/overview" + }, + "opik": { + "title": "Opik", + "description": "Model Context Protocol (MCP) implementation for Opik enabling seamless IDE integration and unified access to prompts, projects, traces, and metrics.", + "required": [ + "apiBaseUrl", + "apiKey", + "workspaceName" + ], + "additionalProperties": false, + "properties": { + "apiBaseUrl": { + "type": "string" + }, + "apiKey": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/opik/overview" + }, + "opine": { + "title": "Opine MCP Server", + "description": "A Model Context Protocol (MCP) server for querying deals and evaluations from the Opine CRM API.", + "required": [ + "opineApiKey" + ], + "additionalProperties": false, + "properties": { + "opineApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/opine-mcp-server/overview" + }, + "oracle": { + "title": "Oracle Database MCP Server", + "description": "Connect to Oracle databases via MCP, providing secure read-only access with support for schema exploration, query execution, and metadata inspection.", + "required": [ + "oracleConnectionString", + "oracleUser", + "password" + ], + "additionalProperties": false, + "properties": { + "oracleConnectionString": { + "type": "string" + }, + "oracleUser": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/oracle/overview" + }, + "ospMarketingTools": { + "title": "OSP Marketing Tools", + "description": "A Model Context Protocol (MCP) server that empowers LLMs to use some of Open Srategy Partners' core writing and product marketing techniques.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/osp_marketing_tools/overview" + }, + "oxylabs": { + "title": "Oxylabs", + "description": "A Model Context Protocol (MCP) server that enables AI assistants like Claude to seamlessly access web data through Oxylabs' powerful web scraping technology.", + "required": [ + "username" + ], + "additionalProperties": false, + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/oxylabs/overview" + }, + "paperSearch": { + "title": "Paper Search", + "description": "A MCP for searching and downloading academic papers from multiple sources like arXiv, PubMed, bioRxiv, etc.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/paper-search/overview" + }, + "perplexityAsk": { + "title": "Perplexity", + "description": "Connector for Perplexity API, to enable real-time, web-wide research.", + "required": [ + "perplexityApiKey" + ], + "additionalProperties": false, + "properties": { + "perplexityApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/perplexity-ask/overview" + }, + "pia": { + "title": "Program Integrity Alliance", + "description": "An MCP server to help make U.S. Government open datasets AI-friendly.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pia/overview" + }, + "pinecone": { + "title": "Pinecone Assistant", + "description": "Pinecone Assistant MCP server.", + "required": [ + "apiKey", + "assistantHost" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "assistantHost": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pinecone/overview" + }, + "playwright": { + "title": "ExecuteAutomation Playwright MCP", + "description": "Playwright Model Context Protocol Server - Tool to automate Browsers and APIs in Claude Desktop, Cline, Cursor IDE and More 🔌.", + "required": [ + "data" + ], + "additionalProperties": false, + "properties": { + "data": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/playwright-mcp-server/overview" + }, + "pluggedinMcpProxy": { + "title": "Plugged.in MCP Proxy", + "description": "A unified MCP proxy that aggregates multiple MCP servers into one interface, enabling seamless tool discovery and management across all your AI interactions. Manage all your MCP servers from a single connection point with RAG capabilities and real-time notifications.", + "required": [ + "pluggedinApiBaseUrl", + "pluggedinApiKey" + ], + "additionalProperties": false, + "properties": { + "pluggedinApiBaseUrl": { + "description": "Base URL for the Plugged.in API (optional, defaults to https://plugged.in for cloud or http://localhost:12005 for self-hosted)", + "type": "string" + }, + "pluggedinApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pluggedin-mcp-proxy/overview" + }, + "polarSignals": { + "title": "Polar Signals", + "description": "MCP server for Polar Signals Cloud continuous profiling platform, enabling AI assistants to analyze CPU performance, memory usage, and identify optimization opportunities in production systems.", + "required": [ + "polarSignalsApiKey" + ], + "additionalProperties": false, + "properties": { + "polarSignalsApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/polar-signals/overview" + }, + "pomodash": { + "title": "PomoDash", + "description": "Connect your AI assistant to PomoDash for seamless task and project management.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pomodash/overview" + }, + "postgres": { + "title": "PostgreSQL readonly (Archived)", + "description": "Connect with read-only access to PostgreSQL databases. This server enables LLMs to inspect database schemas and execute read-only queries.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/postgres/overview" + }, + "postman": { + "title": "Postman MCP server", + "description": "Postman's MCP server connects AI agents, assistants, and chatbots directly to your APIs on Postman. Use natural language to prompt AI to automate work across your Postman collections, environments, workspaces, and more.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/postman/overview" + }, + "prefEditor": { + "title": "Pref Editor", + "description": "Pref Editor is a tool for viewing and editing Android app preferences during development.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/pref-editor/overview" + }, + "prometheus": { + "title": "Prometheus", + "description": "A Model Context Protocol (MCP) server that enables AI assistants to query and analyze Prometheus metrics through standardized interfaces. Connect to your Prometheus instance to retrieve metrics, perform queries, and gain insights into your system's performance and health.", + "required": [ + "prometheusUrl" + ], + "additionalProperties": false, + "properties": { + "prometheusUrl": { + "description": "The URL of your Prometheus server", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/prometheus/overview" + }, + "puppeteer": { + "title": "Puppeteer (Archived)", + "description": "Browser automation and web scraping using Puppeteer.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/puppeteer/overview" + }, + "pythonRefactoring": { + "title": "Python Refactoring Assistant", + "description": "Educational Python refactoring assistant that provides guided suggestions for AI assistants. Features: • Step-by-step refactoring instructions without modifying code • Comprehensive code analysis using professional tools (Rope, Radon, Vulture, Jedi, LibCST, Pyrefly) • Educational approach teaching refactoring patterns through guided practice • Support for both guide-only and apply-changes modes • Identifies long functions, high complexity, dead code, and type issues • Provides precise line numbers and specific refactoring instructions • Compatible with all AI assistants (Claude, GPT, Cursor, Continue, etc.) Perfect for developers learning refactoring patterns while maintaining full control over code changes. Acts as a refactoring mentor rather than an automated code modifier.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-python-refactoring/overview" + }, + "quantconnect": { + "title": "QuantConnect MCP Server", + "description": "The QuantConnect MCP Server is a bridge for AIs (such as Claude and OpenAI o3 Pro) to interact with our cloud platform. When equipped with our MCP, the AI can perform tasks on your behalf through our API such as updating projects, writing strategies, backtesting, and deploying strategies to production live-trading.", + "required": [ + "agentname", + "quantconnectapitoken", + "quantconnectuserid" + ], + "additionalProperties": false, + "properties": { + "agentname": { + "type": "string" + }, + "quantconnectapitoken": { + "type": "string" + }, + "quantconnectuserid": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/quantconnect/overview" + }, + "ramparts": { + "title": "Ramparts MCP Security Scanner", + "description": "A comprehensive security scanner for MCP servers with YARA rules and static analysis capabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/ramparts/overview" + }, + "razorpay": { + "title": "Razorpay", + "description": "Razorpay's Official MCP Server.", + "required": [ + "keyId" + ], + "additionalProperties": false, + "properties": { + "keyId": { + "type": "string" + }, + "keySecret": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/razorpay/overview" + }, + "reddit": { + "title": "Mcp reddit", + "description": "A comprehensive Model Context Protocol (MCP) server for Reddit integration. This server enables AI agents to interact with Reddit programmatically through a standardized interface.", + "required": [ + "redditClientId", + "redditClientSecret", + "redditPassword", + "username" + ], + "additionalProperties": false, + "properties": { + "redditClientId": { + "type": "string" + }, + "redditClientSecret": { + "type": "string" + }, + "redditPassword": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/mcp-reddit/overview" + }, + "redis": { + "title": "Redis", + "description": "Access to Redis database operations.", + "required": [ + "caCerts", + "caPath", + "certReqs", + "clusterMode", + "host", + "port", + "pwd", + "ssl", + "sslCertfile", + "sslKeyfile", + "username" + ], + "additionalProperties": false, + "properties": { + "caCerts": { + "type": "string" + }, + "caPath": { + "type": "string" + }, + "certReqs": { + "type": "string" + }, + "clusterMode": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "pwd": { + "type": "string" + }, + "ssl": { + "type": "boolean" + }, + "sslCertfile": { + "type": "string" + }, + "sslKeyfile": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/redis/overview" + }, + "redisCloud": { + "title": "Redis Cloud", + "description": "MCP Server for Redis Cloud's API, allowing you to manage your Redis Cloud resources using natural language.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "secretKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/redis-cloud/overview" + }, + "ref": { + "title": "Ref - up-to-date docs", + "description": "Ref powerful search tool connets your coding tools with documentation context. It includes an up-to-date index of public documentation and it can ingest your private documentation (eg. GitHub repos, PDFs) as well.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/ref/overview" + }, + "remote": { + "title": "Remote MCP", + "description": "Tools for finding remote MCP servers.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/remote-mcp/overview" + }, + "render": { + "title": "Render", + "description": "Interact with your Render resources via LLMs.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/render/overview" + }, + "resend": { + "title": "Send emails", + "description": "Send emails directly from Cursor with this email sending MCP server.", + "required": [ + "replyTo", + "sender" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "replyTo": { + "description": "comma separated list of reply to email addresses", + "type": "string" + }, + "sender": { + "description": "sender email address", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/resend/overview" + }, + "risken": { + "title": "RISKEN", + "description": "RISKEN's official MCP Server.", + "required": [ + "accessToken", + "url" + ], + "additionalProperties": false, + "properties": { + "accessToken": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/risken/overview" + }, + "root": { + "title": "Root.io Vulnerability Remediation MCP", + "description": "MCP server that provides container image vulnerability scanning and remediation capabilities through Root.io.", + "required": [ + "apiAccessToken" + ], + "additionalProperties": false, + "properties": { + "apiAccessToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/root/overview" + }, + "ros2": { + "title": "WiseVision ROS2 MCP Server", + "description": "Python server implementing Model Context Protocol (MCP) for ROS2.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/ros2/overview" + }, + "rube": { + "title": "Rube", + "description": "Access to Rube's catalog of remote MCP servers.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/rube/overview" + }, + "rustMcpFilesystem": { + "title": "Blazing-fast, asynchronous MCP server for seamless filesystem operations.", + "description": "The Rust MCP Filesystem is a high-performance, asynchronous, and lightweight Model Context Protocol (MCP) server built in Rust for secure and efficient filesystem operations. Designed with security in mind, it operates in read-only mode by default and restricts clients from updating allowed directories via MCP Roots unless explicitly enabled, ensuring robust protection against unauthorized access. Leveraging asynchronous I/O, it delivers blazingly fast performance with a minimal resource footprint. Optimized for token efficiency, the Rust MCP Filesystem enables large language models (LLMs) to precisely target searches and edits within specific sections of large files and restrict operations by file size range, making it ideal for efficient file exploration, automation, and system integration.", + "required": [ + "allowWrite", + "allowedDirectories", + "enableRoots" + ], + "additionalProperties": false, + "properties": { + "allowWrite": { + "description": "Enable read/write mode. If false, the app operates in read-only mode.", + "type": "boolean" + }, + "allowedDirectories": { + "description": "List of directories that rust-mcp-filesystem can access.", + "items": { + "type": "string" + }, + "type": "array" + }, + "enableRoots": { + "description": "Enable dynamic directory access control via MCP client-side Roots.", + "type": "boolean" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/rust-mcp-filesystem/overview" + }, + "schemacrawlerAi": { + "title": "SchemaCrawler AI", + "description": "The SchemaCrawler AI MCP Server enables natural language interaction with your database schema using an MCP client in \"Agent\" mode. It allows users to explore tables, columns, foreign keys, triggers, stored procedures and more simply by asking questions like \"Explain the code for the interest calculation stored procedure\". You can also ask it to help with SQL, since it knows your schema. This is ideal for developers, DBAs, and data analysts who want to streamline schema comprehension and query development without diving into dense documentation.", + "required": [ + "urlConnectionJdbcUrl", + "serverConnectionServer", + "generalInfoLevel", + "volumeHostShare" + ], + "additionalProperties": false, + "properties": { + "generalInfoLevel": { + "description": "--info-level How much database metadata to retrieve", + "type": "string" + }, + "generalLogLevel": { + "type": "string" + }, + "schcrwlrDatabasePassword": { + "type": "string" + }, + "schcrwlrDatabaseUser": { + "type": "string" + }, + "serverConnectionDatabase": { + "description": "--database Database to connect to (optional)", + "type": "string" + }, + "serverConnectionHost": { + "description": "--host Database host (optional)", + "type": "string" + }, + "serverConnectionPort": { + "description": "--port Database port (optional)", + "type": "integer" + }, + "serverConnectionServer": { + "description": "--server SchemaCrawler database plugin", + "type": "string" + }, + "urlConnectionJdbcUrl": { + "description": "--url JDBC URL for database connection", + "type": "string" + }, + "volumeHostShare": { + "description": "Host volume to map within the Docker container", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/schemacrawler-ai/overview" + }, + "schoginiMcpImageBorder": { + "title": "Schogini MCP Image Border", + "description": "This adds a border to an image and returns base64 encoded image.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/schogini-mcp-image-border/overview" + }, + "scrapegraph": { + "title": "ScrapeGraph", + "description": "ScapeGraph MCP Server.", + "required": [ + "sgaiApiKey" + ], + "additionalProperties": false, + "properties": { + "sgaiApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/scrapegraph/overview" + }, + "scrapezy": { + "title": "Scrapezy", + "description": "A Model Context Protocol server for Scrapezy that enables AI models to extract structured data from websites.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/scrapezy/overview" + }, + "securenoteLink": { + "title": "Securenote.link mcp server", + "description": "SecureNote.link MCP Server - allowing AI agents to securely share sensitive information through end-to-end encrypted notes.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/securenote-link-mcp-server/overview" + }, + "semgrep": { + "title": "Semgrep", + "description": "MCP server for using Semgrep to scan code for security vulnerabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/semgrep/overview" + }, + "sentry": { + "title": "Sentry (Archived)", + "description": "A Model Context Protocol server for retrieving and analyzing issues from Sentry.io. This server provides tools to inspect error reports, stacktraces, and other debugging information from your Sentry account.", + "required": [ + "authToken" + ], + "additionalProperties": false, + "properties": { + "authToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/sentry/overview" + }, + "sequa": { + "title": "Sequa.AI", + "description": "Stop stitching context for Copilot and Cursor. With Sequa MCP, your AI tools know your entire codebase and docs out of the box.", + "required": [ + "apiKey", + "mcpServerUrl" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "mcpServerUrl": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/sequa/overview" + }, + "sequentialthinking": { + "title": "Sequential Thinking (Reference)", + "description": "Dynamic and reflective problem-solving through thought sequences.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/sequentialthinking/overview" + }, + "shortIo": { + "title": "Short.io", + "description": "Access to Short.io's link shortener and analytics tools.", + "required": [ + "shortIoApiKey" + ], + "additionalProperties": false, + "properties": { + "shortIoApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/short-io/overview" + }, + "simplechecklist": { + "title": "SimpleCheckList MCP Server", + "description": "Advanced SimpleCheckList with MCP server and SQLite database for comprehensive task management. Features: • Complete project and task management system • Hierarchical organization (Projects → Groups → Task Lists → Tasks → Subtasks) • SQLite database for data persistence • RESTful API with comprehensive endpoints • MCP protocol compliance for AI assistant integration • Docker-optimized deployment with stability improvements **v1.0.1 Update**: Enhanced Docker stability with improved container lifecycle management. Default mode optimized for containerized deployment with reliable startup and shutdown processes. Perfect for AI assistants managing complex project workflows and task hierarchies.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/simplechecklist/overview" + }, + "singlestore": { + "title": "Singlestore", + "description": "MCP server for interacting with SingleStore Management API and services.", + "required": [ + "mcpApiKey" + ], + "additionalProperties": false, + "properties": { + "mcpApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/singlestore/overview" + }, + "slack": { + "title": "Slack (Archived)", + "description": "Interact with Slack Workspaces over the Slack API.", + "required": [ + "teamId" + ], + "additionalProperties": false, + "properties": { + "botToken": { + "type": "string" + }, + "channelIds": { + "type": "string" + }, + "teamId": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/slack/overview" + }, + "smartbear": { + "title": "SmartBear MCP Server", + "description": "MCP server for AI access to SmartBear tools, including BugSnag, Reflect, API Hub, PactFlow.", + "required": [ + "apiHubApiKey", + "bugsnagApiKey", + "bugsnagAuthToken", + "bugsnagEndpoint", + "pactBrokerBaseUrl", + "pactBrokerPassword", + "pactBrokerToken", + "pactBrokerUsername", + "reflectApiToken" + ], + "additionalProperties": false, + "properties": { + "apiHubApiKey": { + "type": "string" + }, + "bugsnagApiKey": { + "type": "string" + }, + "bugsnagAuthToken": { + "type": "string" + }, + "bugsnagEndpoint": { + "type": "string" + }, + "pactBrokerBaseUrl": { + "type": "string" + }, + "pactBrokerPassword": { + "type": "string" + }, + "pactBrokerToken": { + "type": "string" + }, + "pactBrokerUsername": { + "type": "string" + }, + "reflectApiToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/smartbear/overview" + }, + "sonarqube": { + "title": "SonarQube", + "description": "Interact with SonarQube Cloud, Server and Community build over the web API. Analyze code to identify quality and security issues.", + "required": [ + "org", + "token", + "url" + ], + "additionalProperties": false, + "properties": { + "org": { + "description": "Organization key for SonarQube Cloud, not required for SonarQube Server or Community Build", + "type": "string" + }, + "token": { + "type": "string" + }, + "url": { + "description": "URL of the SonarQube instance, to provide only for SonarQube Server or Community Build", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/sonarqube/overview" + }, + "sqlite": { + "title": "SQLite (Archived)", + "description": "Database interaction and business intelligence capabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/SQLite/overview" + }, + "stackgen": { + "title": "StackGen", + "description": "AI-powered DevOps assistant for managing cloud infrastructure and applications.", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "token": { + "type": "string" + }, + "url": { + "description": "URL of your StackGen instance", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/stackgen/overview" + }, + "stackhawk": { + "title": "StackHawk", + "description": "A Model Context Protocol (MCP) server for integrating with StackHawk's security scanning platform. Provides security analytics, YAML configuration management, sensitive data/threat surface analysis, and anti-hallucination tools for LLMs.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/stackhawk/overview" + }, + "stripe": { + "title": "Stripe", + "description": "Interact with Stripe services over the Stripe API.", + "required": [ + "secretKey" + ], + "additionalProperties": false, + "properties": { + "secretKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/stripe/overview" + }, + "supadata": { + "title": "Supadata", + "description": "Official Supadata MCP Server - Adds powerful video \u0026 web scraping to Cursor, Claude and any other LLM clients.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/supadata/overview" + }, + "suzieq": { + "title": "Suzieq MCP", + "description": "MCP Server to interact with a SuzieQ network observability instance via its REST API.", + "required": [ + "apiEndpoint", + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiEndpoint": { + "type": "string" + }, + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/suzieq/overview" + }, + "taskOrchestrator": { + "title": "Task orchestrator", + "description": "Model Context Protocol (MCP) server for comprehensive task and feature management, providing AI assistants with a structured, context-efficient way to interact with project data.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/task-orchestrator/overview" + }, + "tavily": { + "title": "Tavily", + "description": "The Tavily MCP server provides seamless interaction with the tavily-search and tavily-extract tools, real-time web search capabilities through the tavily-search tool and Intelligent data extraction from web pages via the tavily-extract tool.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/tavily/overview" + }, + "teamwork": { + "title": "Teamwork", + "description": "Tools for Teamwork.com products.", + "required": [ + "twMcpBearerToken" + ], + "additionalProperties": false, + "properties": { + "twMcpBearerToken": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/teamwork/overview" + }, + "telnyx": { + "title": "Telnyx", + "description": "Enables interaction with powerful telephony, messaging, and AI assistant APIs.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/telnyx/overview" + }, + "tembo": { + "title": "Tembo", + "description": "MCP server for Tembo Cloud's platform API.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/tembo/overview" + }, + "terraform": { + "title": "Hashicorp Terraform", + "description": "The Terraform MCP Server provides seamless integration with Terraform ecosystem, enabling advanced automation and interaction capabilities for Infrastructure as Code (IaC) development.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/terraform/overview" + }, + "textToGraphql": { + "title": "Text-to-GraphQL", + "description": "Transform natural language queries into GraphQL queries using an AI agent. Provides schema management, query validation, execution, and history tracking.", + "required": [ + "graphqlApiKey", + "graphqlAuthType", + "graphqlEndpoint", + "modelName", + "modelTemperature", + "openaiApiKey" + ], + "additionalProperties": false, + "properties": { + "graphqlApiKey": { + "type": "string" + }, + "graphqlAuthType": { + "description": "Authentication method for GraphQL API", + "type": "string" + }, + "graphqlEndpoint": { + "type": "string" + }, + "modelName": { + "description": "OpenAI model to use", + "type": "string" + }, + "modelTemperature": { + "description": "Model temperature for responses", + "type": "number" + }, + "openaiApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/text-to-graphql/overview" + }, + "tigris": { + "title": "Tigris Data", + "description": "Tigris is a globally distributed S3-compatible object storage service that provides low latency anywhere in the world, enabling developers to store and access any amount of data for a wide range of use cases.", + "required": [ + "awsAccessKeyId", + "awsEndpointUrlS3" + ], + "additionalProperties": false, + "properties": { + "awsAccessKeyId": { + "type": "string" + }, + "awsEndpointUrlS3": { + "type": "string" + }, + "awsSecretAccessKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/tigris/overview" + }, + "time": { + "title": "Time (Reference)", + "description": "Time and timezone conversion capabilities.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/time/overview" + }, + "triplewhale": { + "title": "Triplewhale", + "description": "Triplewhale MCP Server.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/triplewhale/overview" + }, + "unrealEngine": { + "title": "Unreal Engine MCP Server", + "description": "A comprehensive Model Context Protocol (MCP) server that enables AI assistants to control Unreal Engine via Remote Control API. Built with TypeScript and designed for game development automation.", + "required": [ + "ueHost", + "ueRcHttpPort", + "ueRcWsPort" + ], + "additionalProperties": false, + "properties": { + "logLevel": { + "description": "Logging level", + "type": "string" + }, + "ueHost": { + "description": "Unreal Engine host address. Use: host.docker.internal for local UE on Windows/Mac Docker, 127.0.0.1 for Linux without Docker, or actual IP address (e.g., 192.168.1.100) for remote UE", + "type": "string" + }, + "ueRcHttpPort": { + "description": "Remote Control HTTP port", + "type": "string" + }, + "ueRcWsPort": { + "description": "Remote Control WebSocket port", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/unreal-engine-mcp-server/overview" + }, + "veyrax": { + "title": "VeyraX", + "description": "VeyraX MCP is the only connection you need to access all your tools in any MCP-compatible environment.", + "required": [ + "apiKey" + ], + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/veyrax/overview" + }, + "vizro": { + "title": "Vizro", + "description": "provides tools and templates to create a functioning Vizro chart or dashboard step by step.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/vizro/overview" + }, + "vulnNist": { + "title": "Vuln nist mcp server", + "description": "This MCP server exposes tools to query the NVD/CVE REST API and return formatted text results suitable for LLM consumption via the MCP protocol. It includes automatic query chunking for large date ranges and parallel processing for improved performance.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/vuln-nist-mcp-server/overview" + }, + "wayfound": { + "title": "Wayfound MCP", + "description": "Wayfound’s MCP server allows business users to govern, supervise, and improve AI Agents.", + "required": [ + "mcpApiKey" + ], + "additionalProperties": false, + "properties": { + "mcpApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/wayfound/overview" + }, + "webflow": { + "title": "Webflow", + "description": "Model Context Protocol (MCP) server for the Webflow Data API.", + "required": [ + "token" + ], + "additionalProperties": false, + "properties": { + "token": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/webflow/overview" + }, + "wikipedia": { + "title": "Wikipedia", + "description": "A Model Context Protocol (MCP) server that retrieves information from Wikipedia to provide context to LLMs.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/wikipedia-mcp/overview" + }, + "wolframAlpha": { + "title": "WolframAlpha", + "description": "Connect your chat repl to wolfram alpha computational intelligence.", + "required": [ + "wolframApiKey" + ], + "additionalProperties": false, + "properties": { + "wolframApiKey": { + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/wolfram-alpha/overview" + }, + "youtubeTranscript": { + "title": "YouTube transcripts", + "description": "Retrieves transcripts for given YouTube video URLs.", + "additionalProperties": false, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/youtube_transcript/overview" + }, + "zerodhaKite": { + "title": "Zerodha Kite Connect", + "description": "MCP server for Zerodha Kite Connect API - India's leading stock broker trading platform. Execute trades, manage portfolios, and access real-time market data for NSE, BSE, and other Indian exchanges.", + "required": [ + "kiteApiKey" + ], + "additionalProperties": false, + "properties": { + "kiteAccessToken": { + "description": "Access token obtained after OAuth authentication (optional - can be generated at runtime)", + "type": "string" + }, + "kiteApiKey": { + "description": "Your Kite Connect API key from the developer console", + "type": "string" + }, + "kiteApiSecret": { + "type": "string" + }, + "kiteRedirectUrl": { + "description": "OAuth redirect URL configured in your Kite Connect app", + "type": "string" + } + }, + "type": "object", + "x-dockerHubUrl": "https://hub.docker.com/mcp/server/zerodha-kite/overview" + } + }, + "type": "object" +} \ No newline at end of file diff --git a/compat/e2b/spec/e2b/openapi-volumecontent.yml b/compat/e2b/spec/e2b/openapi-volumecontent.yml new file mode 100644 index 00000000..64809b33 --- /dev/null +++ b/compat/e2b/spec/e2b/openapi-volumecontent.yml @@ -0,0 +1,329 @@ +openapi: 3.0.0 +info: + version: 0.1.0 + title: E2B API + +security: + - VolumeJWT: [] + +components: + securitySchemes: + VolumeJWT: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + volumeID: + name: volumeID + in: path + required: true + schema: + type: string + path: + name: path + in: query + required: true + schema: + type: string + + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authentication error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Error: + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + + VolumeEntryStat: + type: object + properties: + name: + type: string + type: + type: string + enum: [unknown, file, directory, symlink] + path: + type: string + size: + type: integer + format: int64 + mode: + type: integer + format: uint32 + uid: + type: integer + format: uint32 + gid: + type: integer + format: uint32 + atime: + type: string + format: date-time + mtime: + type: string + format: date-time + ctime: + type: string + format: date-time + target: + type: string + required: + - name + - type + - path + - size + - mode + - uid + - gid + - atime + - mtime + - ctime + + VolumeDirectoryListing: + type: array + items: + $ref: '#/components/schemas/VolumeEntryStat' + +paths: + /volumecontent/{volumeID}/path: + get: + description: Get path information + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + responses: + '200': + description: Successfully retrieved path information + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeEntryStat' + '404': + $ref: '#/components/responses/404' + + patch: + description: Update path metadata + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + uid: + type: integer + format: uint32 + gid: + type: integer + format: uint32 + mode: + type: integer + format: uint32 + responses: + '200': + description: "Successfully updated a file's metadata" + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeEntryStat' + '400': + description: 'Invalid metadata provided' + '404': + description: 'path not found' + '500': + description: 'Internal server error' + + delete: + description: Delete a path + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + responses: + '204': + description: Successfully deleted a path + '404': + $ref: '#/components/responses/404' + + /volumecontent/{volumeID}/dir: + get: + description: List directory contents + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + - name: depth + in: query + description: Number of layers deep to recurse into the directory + schema: + type: integer + format: uint32 + default: 1 + responses: + '200': + description: 'Successfully retrieved a directory listing' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeDirectoryListing' + '400': + description: 'Invalid path provided' + '404': + description: 'path not found' + '500': + $ref: '#/components/responses/500' + post: + description: 'Create a directory' + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + - name: uid + in: query + description: User ID of the created directory + schema: + type: integer + format: uint32 + - name: gid + in: query + description: Group ID of the created directory + schema: + type: integer + format: uint32 + - name: mode + in: query + description: Mode of the created directory + schema: + type: integer + format: uint32 + - name: force + in: query + description: Create the parents of a directory if they don't exist + schema: + type: boolean + responses: + '201': + description: 'Successfully created a directory' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeEntryStat' + '404': + description: 'path not found' + '500': + $ref: '#/components/responses/500' + + /volumecontent/{volumeID}/file: + get: + description: Download file + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + responses: + '200': + description: 'Successfully downloaded a file' + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: 'path not found' + '500': + $ref: '#/components/responses/500' + put: + description: Upload file + tags: [volumes] + parameters: + - $ref: '#/components/parameters/volumeID' + - $ref: '#/components/parameters/path' + - name: uid + in: query + description: User ID of the uploaded file + schema: + type: integer + format: uint32 + - name: gid + in: query + description: Group ID of the uploaded file + schema: + type: integer + format: uint32 + - name: mode + in: query + description: Mode of the uploaded file + schema: + type: integer + format: uint32 + - name: force + in: query + description: Force overwrite of an existing file + schema: + type: boolean + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '201': + description: 'Successfully created a file' + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeEntryStat' + '404': + description: 'path not found' + '500': + $ref: '#/components/responses/500' diff --git a/compat/e2b/spec/e2b/openapi.yml b/compat/e2b/spec/e2b/openapi.yml new file mode 100644 index 00000000..0f20f8d7 --- /dev/null +++ b/compat/e2b/spec/e2b/openapi.yml @@ -0,0 +1,3653 @@ +openapi: 3.0.0 +info: + version: 0.1.0 + title: E2B API + +servers: + - url: https://api.e2b.app + +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + AccessTokenAuth: + type: http + scheme: bearer + bearerFormat: access_token + # Generated code uses security schemas in the alphabetical order. + # In order to check first the token, and then the team (so we can already use the user), + # there is a 1 and 2 present in the names of the security schemas. + Supabase1TokenAuth: + type: apiKey + in: header + name: X-Supabase-Token + Supabase2TeamAuth: + type: apiKey + in: header + name: X-Supabase-Team + # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name + # so Bearer is validated before Team (same reason as Supabase1/2 above). + AuthProviderBearerAuth: + type: http + scheme: bearer + bearerFormat: access_token + AuthProviderTeamAuth: + type: apiKey + in: header + name: X-Team-ID + AdminTokenAuth: + type: apiKey + in: header + name: X-Admin-Token + + parameters: + templateID: + name: templateID + in: path + required: true + schema: + type: string + buildID: + name: buildID + in: path + required: true + schema: + type: string + sandboxID: + name: sandboxID + in: path + required: true + schema: + type: string + teamID: + name: teamID + in: path + required: true + schema: + type: string + nodeID: + name: nodeID + in: path + required: true + schema: + type: string + apiKeyID: + name: apiKeyID + in: path + required: true + schema: + type: string + accessTokenID: + name: accessTokenID + in: path + required: true + schema: + type: string + snapshotID: + name: snapshotID + in: path + required: true + schema: + type: string + description: Identifier of the snapshot (template ID) + tag: + name: tag + in: path + required: true + schema: + type: string + description: Tag name + paginationLimit: + name: limit + in: query + description: Maximum number of items to return per page + required: false + schema: + type: integer + format: int32 + minimum: 1 + default: 100 + maximum: 100 + paginationNextToken: + name: nextToken + in: query + description: Cursor to start the list from + required: false + schema: + type: string + volumeID: + name: volumeID + in: path + required: true + schema: + type: string + + responses: + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Authentication error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Team: + required: + - teamID + - name + - apiKey + - isDefault + properties: + teamID: + type: string + description: Identifier of the team + name: + type: string + description: Name of the team + apiKey: + type: string + description: API key for the team + isDefault: + type: boolean + description: Whether the team is the default team + + TeamUser: + required: + - id + - email + properties: + id: + type: string + format: uuid + description: Identifier of the user + email: + type: string + nullable: true + deprecated: true + default: null + description: Email of the user + + TemplateUpdateRequest: + properties: + public: + type: boolean + description: Whether the template is public or only accessible by the team + + TemplateUpdateResponse: + required: + - names + properties: + names: + type: array + description: Names of the template (namespace/alias format when namespaced) + items: + type: string + + CPUCount: + type: integer + format: int32 + minimum: 1 + description: CPU cores for the sandbox + + MemoryMB: + type: integer + format: int32 + minimum: 128 + description: Memory for the sandbox in MiB + + DiskSizeMB: + type: integer + format: int32 + minimum: 0 + description: Disk size for the sandbox in MiB + + EnvdVersion: + type: string + description: Version of the envd running in the sandbox + + SandboxMetadata: + additionalProperties: + type: string + description: Metadata of the sandbox + + SandboxState: + type: string + description: State of the sandbox + enum: + - running + - paused + + SnapshotInfo: + type: object + required: + - snapshotID + - names + properties: + snapshotID: + type: string + description: Identifier of the snapshot template including the tag. Uses namespace/alias when a name was provided (e.g. team-slug/my-snapshot:default), otherwise falls back to the raw template ID (e.g. abc123:default). + names: + type: array + items: + type: string + description: Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2) + + EnvVars: + additionalProperties: + type: string + description: Environment variables for the sandbox + + Mcp: + type: object + description: MCP configuration for the sandbox + additionalProperties: {} + nullable: true + + SandboxNetworkConfig: + type: object + properties: + allowPublicTraffic: + type: boolean + default: true + description: Specify if the sandbox URLs should be accessible only with authentication. + allowOut: + type: array + description: List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. + items: + type: string + denyOut: + type: array + description: List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. + items: + type: string + maskRequestHost: + type: string + description: Specify host mask which will be used for all sandbox requests + rules: + type: object + description: > + Per-domain transform rules applied to matching egress HTTP/HTTPS requests. + Keys are domains (e.g. "api.example.com", "example.com"). + A domain listed here is not automatically allowed - use allowOut to permit the traffic. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SandboxNetworkRule' + + SandboxNetworkUpdateConfig: + type: object + description: Network configuration update for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting a field clears it. + properties: + allowOut: + type: array + description: List of allowed destinations for egress traffic. Each entry can be a CIDR block (e.g. "8.8.8.8/32"), a bare IP address (e.g. "8.8.8.8"), or a domain name (e.g. "example.com", "*.example.com"). Allowed entries always take precedence over denied entries. + items: + type: string + denyOut: + type: array + description: List of denied CIDR blocks or IP addresses for egress traffic. Domain names are not supported for deny rules. + items: + type: string + rules: + type: object + description: Per-domain transform rules. Replaces all existing rules when provided. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SandboxNetworkRule' + allow_internet_access: + type: boolean + description: + Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut + to 0.0.0.0/0 in the network config. + + SandboxNetworkRule: + type: object + description: Transform rule applied to egress requests matching a domain pattern. + properties: + transform: + $ref: '#/components/schemas/SandboxNetworkTransform' + + SandboxNetworkTransform: + type: object + description: Transformations applied to matching egress requests before forwarding. + properties: + headers: + type: object + description: > + HTTP headers to inject or override in matching requests. + An existing header with the same name is replaced. Values are plain strings; + secret resolution happens client-side before sending to the API. + additionalProperties: + type: string + + SandboxAutoResumeEnabled: + type: boolean + description: Auto-resume enabled flag for paused sandboxes. Default false. + default: false + + SandboxAutoResumeConfig: + type: object + description: Auto-resume configuration for paused sandboxes. + required: + - enabled + properties: + enabled: + $ref: '#/components/schemas/SandboxAutoResumeEnabled' + + SandboxOnTimeout: + type: string + description: Action taken when the sandbox times out. + enum: + - kill + - pause + + SandboxLifecycle: + type: object + description: Sandbox lifecycle policy returned by sandbox info. + required: + - autoResume + - onTimeout + properties: + autoResume: + type: boolean + description: Whether the sandbox can auto-resume. + onTimeout: + $ref: '#/components/schemas/SandboxOnTimeout' + + SandboxLog: + description: Log entry with timestamp and line + required: + - timestamp + - line + properties: + timestamp: + type: string + format: date-time + description: Timestamp of the log entry + line: + type: string + description: Log line content + + SandboxLogEntry: + required: + - timestamp + - level + - message + - fields + properties: + timestamp: + type: string + format: date-time + description: Timestamp of the log entry + message: + type: string + description: Log message content + level: + $ref: '#/components/schemas/LogLevel' + fields: + type: object + additionalProperties: + type: string + + SandboxLogs: + required: + - logs + - logEntries + properties: + logs: + description: Logs of the sandbox + type: array + items: + $ref: '#/components/schemas/SandboxLog' + logEntries: + description: Structured logs of the sandbox + type: array + items: + $ref: '#/components/schemas/SandboxLogEntry' + + SandboxLogsV2Response: + required: + - logs + properties: + logs: + default: [] + description: Sandbox logs structured + type: array + items: + $ref: '#/components/schemas/SandboxLogEntry' + + SandboxMetric: + description: Metric entry with timestamp and line + required: + - timestamp + - timestampUnix + - cpuCount + - cpuUsedPct + - memUsed + - memTotal + - memCache + - diskUsed + - diskTotal + properties: + timestamp: + type: string + format: date-time + deprecated: true + description: Timestamp of the metric entry + timestampUnix: + type: integer + format: int64 + description: Timestamp of the metric entry in Unix time (seconds since epoch) + cpuCount: + type: integer + format: int32 + description: Number of CPU cores + cpuUsedPct: + type: number + format: float + description: CPU usage percentage + memUsed: + type: integer + format: int64 + description: Memory used in bytes + memTotal: + type: integer + format: int64 + description: Total memory in bytes + memCache: + type: integer + format: int64 + description: Cached memory (page cache) in bytes + diskUsed: + type: integer + format: int64 + description: Disk used in bytes + diskTotal: + type: integer + format: int64 + description: Total disk space in bytes + + SandboxVolumeMount: + type: object + properties: + name: + type: string + description: Name of the volume + path: + type: string + description: Path of the volume + required: + - name + - path + + Sandbox: + required: + - templateID + - sandboxID + - clientID + - envdVersion + properties: + templateID: + type: string + description: Identifier of the template from which is the sandbox created + sandboxID: + type: string + description: Identifier of the sandbox + alias: + type: string + description: Alias of the template + clientID: + type: string + deprecated: true + description: Identifier of the client + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + envdAccessToken: + type: string + description: Access token used for envd communication + trafficAccessToken: + type: string + nullable: true + description: Token required for accessing sandbox via proxy. + domain: + type: string + nullable: true + description: Base domain where the sandbox traffic is accessible + + SandboxDetail: + required: + - templateID + - sandboxID + - clientID + - startedAt + - cpuCount + - memoryMB + - diskSizeMB + - endAt + - state + - envdVersion + properties: + templateID: + type: string + description: Identifier of the template from which is the sandbox created + alias: + type: string + description: Alias of the template + sandboxID: + type: string + description: Identifier of the sandbox + clientID: + type: string + deprecated: true + description: Identifier of the client + startedAt: + type: string + format: date-time + description: Time when the sandbox was started + endAt: + type: string + format: date-time + description: Time when the sandbox will expire + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + envdAccessToken: + type: string + description: Access token used for envd communication + allowInternetAccess: + type: boolean + nullable: true + description: Whether internet access was explicitly enabled or disabled for the sandbox. Null means it was not explicitly set. + domain: + type: string + nullable: true + description: Base domain where the sandbox traffic is accessible + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + metadata: + $ref: '#/components/schemas/SandboxMetadata' + state: + $ref: '#/components/schemas/SandboxState' + network: + $ref: '#/components/schemas/SandboxNetworkConfig' + lifecycle: + $ref: '#/components/schemas/SandboxLifecycle' + volumeMounts: + type: array + items: + $ref: '#/components/schemas/SandboxVolumeMount' + + ListedSandbox: + required: + - templateID + - sandboxID + - clientID + - startedAt + - cpuCount + - memoryMB + - diskSizeMB + - endAt + - state + - envdVersion + properties: + templateID: + type: string + description: Identifier of the template from which is the sandbox created + alias: + type: string + description: Alias of the template + sandboxID: + type: string + description: Identifier of the sandbox + clientID: + type: string + deprecated: true + description: Identifier of the client + startedAt: + type: string + format: date-time + description: Time when the sandbox was started + endAt: + type: string + format: date-time + description: Time when the sandbox will expire + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + metadata: + $ref: '#/components/schemas/SandboxMetadata' + state: + $ref: '#/components/schemas/SandboxState' + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + volumeMounts: + type: array + items: + $ref: '#/components/schemas/SandboxVolumeMount' + + SandboxesWithMetrics: + required: + - sandboxes + properties: + sandboxes: + additionalProperties: + $ref: '#/components/schemas/SandboxMetric' + + NewSandbox: + required: + - templateID + properties: + templateID: + type: string + description: Identifier of the required template + timeout: + type: integer + format: int32 + minimum: 0 + default: 15 + description: Time to live for the sandbox in seconds. + autoPause: + type: boolean + default: false + description: Automatically pauses the sandbox after the timeout + autoPauseMemory: + type: boolean + default: true + description: >- + Controls the snapshot kind taken when the sandbox auto-pauses on + timeout (only relevant when autoPause is true). When false, the + auto-pause drops the in-memory state and persists only the + filesystem (a filesystem-only snapshot); resuming it cold-boots + (reboots) the sandbox from disk. Such a snapshot cannot be + auto-resumed by traffic and must be resumed explicitly, so it cannot + be combined with autoResume. Defaults to true (full memory snapshot). + autoResume: + $ref: '#/components/schemas/SandboxAutoResumeConfig' + secure: + type: boolean + description: Secure all system communication with sandbox + allow_internet_access: + type: boolean + description: + Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut + to 0.0.0.0/0 in the network config. + network: + $ref: '#/components/schemas/SandboxNetworkConfig' + metadata: + $ref: '#/components/schemas/SandboxMetadata' + envVars: + $ref: '#/components/schemas/EnvVars' + mcp: + $ref: '#/components/schemas/Mcp' + volumeMounts: + type: array + items: + $ref: '#/components/schemas/SandboxVolumeMount' + + ResumedSandbox: + properties: + timeout: + type: integer + format: int32 + minimum: 0 + default: 15 + description: Time to live for the sandbox in seconds. + autoPause: + type: boolean + deprecated: true + description: Automatically pauses the sandbox after the timeout + + SandboxPauseRequest: + type: object + properties: + memory: + type: boolean + default: true + description: >- + Whether to capture a full memory snapshot. When false, only the + filesystem is persisted and resuming the sandbox cold-boots + (reboots) it from disk, losing in-memory state, running processes, + and open connections. Resume it with an explicit request (connect or + resume); auto-resume, which can be triggered by arbitrary traffic, + refuses such a sandbox. Defaults to true. + + ConnectSandbox: + type: object + required: + - timeout + properties: + timeout: + description: Timeout in seconds from the current time after which the sandbox should expire + type: integer + format: int32 + minimum: 0 + + TeamMetric: + description: Team metric with timestamp + required: + - timestamp + - timestampUnix + - concurrentSandboxes + - sandboxStartRate + properties: + timestamp: + type: string + format: date-time + deprecated: true + description: Timestamp of the metric entry + timestampUnix: + type: integer + format: int64 + description: Timestamp of the metric entry in Unix time (seconds since epoch) + concurrentSandboxes: + type: integer + format: int32 + description: The number of concurrent sandboxes for the team + sandboxStartRate: + type: number + format: float + description: Number of sandboxes started per second + + MaxTeamMetric: + description: Team metric with timestamp + required: + - timestamp + - timestampUnix + - value + properties: + timestamp: + type: string + format: date-time + deprecated: true + description: Timestamp of the metric entry + timestampUnix: + type: integer + format: int64 + description: Timestamp of the metric entry in Unix time (seconds since epoch) + value: + type: number + description: The maximum value of the requested metric in the given interval + + AdminSandboxKillResult: + required: + - killedCount + - failedCount + properties: + killedCount: + type: integer + description: Number of sandboxes successfully killed + failedCount: + type: integer + description: Number of sandboxes that failed to kill + + AdminBuildCancelResult: + required: + - cancelledCount + - failedCount + properties: + cancelledCount: + type: integer + description: Number of builds successfully cancelled + failedCount: + type: integer + description: Number of builds that failed to cancel + + VolumeToken: + type: object + properties: + token: + type: string + required: + - token + + Template: + required: + - templateID + - buildID + - cpuCount + - memoryMB + - diskSizeMB + - public + - createdAt + - updatedAt + - createdBy + - lastSpawnedAt + - spawnCount + - buildCount + - envdVersion + - aliases + - names + - buildStatus + properties: + templateID: + type: string + description: Identifier of the template + buildID: + type: string + description: Identifier of the last successful build for given template + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + public: + type: boolean + description: Whether the template is public or only accessible by the team + aliases: + type: array + description: Aliases of the template + deprecated: true + items: + type: string + names: + type: array + description: Names of the template (namespace/alias format when namespaced) + items: + type: string + createdAt: + type: string + format: date-time + description: Time when the template was created + updatedAt: + type: string + format: date-time + description: Time when the template was last updated + createdBy: + allOf: + - $ref: '#/components/schemas/TeamUser' + nullable: true + lastSpawnedAt: + type: string + nullable: true + format: date-time + description: Time when the template was last used + spawnCount: + type: integer + format: int64 + description: Number of times the template was used + buildCount: + type: integer + format: int32 + description: Number of times the template was built + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + buildStatus: + $ref: '#/components/schemas/TemplateBuildStatus' + + TemplateRequestResponseV3: + required: + - templateID + - buildID + - public + - aliases + - names + - tags + properties: + templateID: + type: string + description: Identifier of the template + buildID: + type: string + description: Identifier of the last successful build for given template + public: + type: boolean + description: Whether the template is public or only accessible by the team + names: + type: array + description: Names of the template + items: + type: string + tags: + type: array + description: Tags assigned to the template build + items: + type: string + aliases: + type: array + description: Aliases of the template + deprecated: true + items: + type: string + + TemplateLegacy: + required: + - templateID + - buildID + - cpuCount + - memoryMB + - diskSizeMB + - public + - createdAt + - updatedAt + - createdBy + - lastSpawnedAt + - spawnCount + - buildCount + - envdVersion + - aliases + properties: + templateID: + type: string + description: Identifier of the template + buildID: + type: string + description: Identifier of the last successful build for given template + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + public: + type: boolean + description: Whether the template is public or only accessible by the team + aliases: + type: array + description: Aliases of the template + items: + type: string + createdAt: + type: string + format: date-time + description: Time when the template was created + updatedAt: + type: string + format: date-time + description: Time when the template was last updated + createdBy: + allOf: + - $ref: '#/components/schemas/TeamUser' + nullable: true + lastSpawnedAt: + type: string + nullable: true + format: date-time + description: Time when the template was last used + spawnCount: + type: integer + format: int64 + description: Number of times the template was used + buildCount: + type: integer + format: int32 + description: Number of times the template was built + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + + TemplateBuild: + required: + - buildID + - status + - createdAt + - updatedAt + - cpuCount + - memoryMB + properties: + buildID: + type: string + format: uuid + description: Identifier of the build + status: + $ref: '#/components/schemas/TemplateBuildStatus' + createdAt: + type: string + format: date-time + description: Time when the build was created + updatedAt: + type: string + format: date-time + description: Time when the build was last updated + finishedAt: + type: string + format: date-time + description: Time when the build was finished + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + diskSizeMB: + $ref: '#/components/schemas/DiskSizeMB' + envdVersion: + $ref: '#/components/schemas/EnvdVersion' + + TemplateWithBuilds: + required: + - templateID + - public + - aliases + - names + - createdAt + - updatedAt + - lastSpawnedAt + - spawnCount + - builds + properties: + templateID: + type: string + description: Identifier of the template + public: + type: boolean + description: Whether the template is public or only accessible by the team + aliases: + type: array + description: Aliases of the template + deprecated: true + items: + type: string + names: + type: array + description: Names of the template (namespace/alias format when namespaced) + items: + type: string + createdAt: + type: string + format: date-time + description: Time when the template was created + updatedAt: + type: string + format: date-time + description: Time when the template was last updated + lastSpawnedAt: + type: string + nullable: true + format: date-time + description: Time when the template was last used + spawnCount: + type: integer + format: int64 + description: Number of times the template was used + builds: + type: array + description: List of builds for the template + items: + $ref: '#/components/schemas/TemplateBuild' + + TemplateAliasResponse: + required: + - templateID + - public + properties: + templateID: + type: string + description: Identifier of the template + public: + type: boolean + description: Whether the template is public or only accessible by the team + + TemplateBuildRequest: + required: + - dockerfile + properties: + alias: + description: Alias of the template + type: string + dockerfile: + description: Dockerfile for the template + type: string + teamID: + type: string + description: Identifier of the team + startCmd: + description: Start command to execute in the template after the build + type: string + readyCmd: + description: Ready check command to execute in the template after the build + type: string + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + + TemplateStep: + description: Step in the template build process + required: + - type + properties: + type: + type: string + description: Type of the step + args: + default: [] + type: array + description: Arguments for the step + items: + type: string + filesHash: + type: string + description: Hash of the files used in the step + force: + default: false + type: boolean + description: Whether the step should be forced to run regardless of the cache + + TemplateBuildRequestV3: + properties: + name: + description: Name of the template. Can include a tag with colon separator (e.g. "my-template" or "my-template:v1"). If tag is included, it will be treated as if the tag was provided in the tags array. + type: string + tags: + type: array + description: Tags to assign to the template build + items: + type: string + alias: + description: Alias of the template. Deprecated, use name instead. + type: string + deprecated: true + teamID: + deprecated: true + type: string + description: Identifier of the team + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + + TemplateBuildRequestV2: + required: + - alias + properties: + alias: + description: Alias of the template + type: string + teamID: + deprecated: true + type: string + description: Identifier of the team + cpuCount: + $ref: '#/components/schemas/CPUCount' + memoryMB: + $ref: '#/components/schemas/MemoryMB' + + FromImageRegistry: + oneOf: + - $ref: '#/components/schemas/AWSRegistry' + - $ref: '#/components/schemas/GCPRegistry' + - $ref: '#/components/schemas/GeneralRegistry' + discriminator: + propertyName: type + mapping: + aws: '#/components/schemas/AWSRegistry' + gcp: '#/components/schemas/GCPRegistry' + registry: '#/components/schemas/GeneralRegistry' + + AWSRegistry: + type: object + required: + - type + - awsAccessKeyId + - awsSecretAccessKey + - awsRegion + properties: + type: + type: string + enum: [aws] + description: Type of registry authentication + awsAccessKeyId: + type: string + description: AWS Access Key ID for ECR authentication + awsSecretAccessKey: + type: string + description: AWS Secret Access Key for ECR authentication + awsRegion: + type: string + description: AWS Region where the ECR registry is located + + GCPRegistry: + type: object + required: + - type + - serviceAccountJson + properties: + type: + type: string + enum: [gcp] + description: Type of registry authentication + serviceAccountJson: + type: string + description: Service Account JSON for GCP authentication + + GeneralRegistry: + type: object + required: + - type + - username + - password + properties: + type: + type: string + enum: [registry] + description: Type of registry authentication + username: + type: string + description: Username to use for the registry + password: + type: string + description: Password to use for the registry + + TemplateBuildStartV2: + type: object + properties: + fromImage: + type: string + description: Image to use as a base for the template build + fromTemplate: + type: string + description: Template to use as a base for the template build + fromImageRegistry: + $ref: '#/components/schemas/FromImageRegistry' + force: + default: false + type: boolean + description: Whether the whole build should be forced to run regardless of the cache + steps: + default: [] + description: List of steps to execute in the template build + type: array + items: + $ref: '#/components/schemas/TemplateStep' + startCmd: + description: Start command to execute in the template after the build + type: string + readyCmd: + description: Ready check command to execute in the template after the build + type: string + + TemplateBuildFileUpload: + required: + - present + properties: + present: + type: boolean + description: Whether the file is already present in the cache + url: + description: Url where the file should be uploaded to + type: string + + LogLevel: + type: string + description: State of the sandbox + enum: + - debug + - info + - warn + - error + + BuildLogEntry: + required: + - timestamp + - message + - level + properties: + timestamp: + type: string + format: date-time + description: Timestamp of the log entry + message: + type: string + description: Log message content + level: + $ref: '#/components/schemas/LogLevel' + step: + type: string + description: Step in the build process related to the log entry + + BuildStatusReason: + required: + - message + properties: + message: + type: string + description: Message with the status reason, currently reporting only for error status + step: + type: string + description: Step that failed + logEntries: + default: [] + description: Log entries related to the status reason + type: array + items: + $ref: '#/components/schemas/BuildLogEntry' + + TemplateBuildStatus: + type: string + description: Status of the template build + enum: + - building + - waiting + - ready + - error + + TemplateBuildInfo: + required: + - templateID + - buildID + - status + - logs + - logEntries + properties: + logs: + default: [] + description: Build logs + type: array + items: + type: string + logEntries: + default: [] + description: Build logs structured + type: array + items: + $ref: '#/components/schemas/BuildLogEntry' + templateID: + type: string + description: Identifier of the template + buildID: + type: string + description: Identifier of the build + status: + $ref: '#/components/schemas/TemplateBuildStatus' + reason: + $ref: '#/components/schemas/BuildStatusReason' + + TemplateBuildLogsResponse: + required: + - logs + properties: + logs: + default: [] + description: Build logs structured + type: array + items: + $ref: '#/components/schemas/BuildLogEntry' + + LogsDirection: + type: string + description: Direction of the logs that should be returned + enum: + - forward + - backward + x-enum-varnames: + - LogsDirectionForward + - LogsDirectionBackward + + LogsSource: + type: string + description: Source of the logs that should be returned + enum: + - temporary + - persistent + x-enum-varnames: + - LogsSourceTemporary + - LogsSourcePersistent + + NodeStatus: + type: string + description: | + Status of the node. + - draining: the node is bound to be shut down. It will not accept new sandboxes and will stop once all existing sandboxes are done. + - standby: the node is not actively used, but it can return to ready and continue serving traffic. + enum: + - ready + - draining + - connecting + - unhealthy + - standby + x-enum-varnames: + - NodeStatusReady + - NodeStatusDraining + - NodeStatusConnecting + - NodeStatusUnhealthy + - NodeStatusStandby + + NodeStatusChange: + required: + - status + properties: + clusterID: + type: string + format: uuid + description: Identifier of the cluster + status: + $ref: '#/components/schemas/NodeStatus' + + DiskMetrics: + required: + - mountPoint + - device + - filesystemType + - usedBytes + - totalBytes + properties: + mountPoint: + type: string + description: Mount point of the disk + device: + type: string + description: Device name + filesystemType: + type: string + description: Filesystem type (e.g., ext4, xfs) + usedBytes: + type: integer + format: uint64 + description: Used space in bytes + totalBytes: + type: integer + format: uint64 + description: Total space in bytes + + NodeMetrics: + description: Node metrics + required: + - allocatedCPU + - allocatedMemoryBytes + - cpuPercent + - memoryUsedBytes + - cpuCount + - memoryTotalBytes + - disks + properties: + allocatedCPU: + type: integer + format: uint32 + description: Number of allocated CPU cores + cpuPercent: + type: integer + format: uint32 + description: Node CPU usage percentage + cpuCount: + type: integer + format: uint32 + description: Total number of CPU cores on the node + allocatedMemoryBytes: + type: integer + format: uint64 + description: Amount of allocated memory in bytes + memoryUsedBytes: + type: integer + format: uint64 + description: Node memory used in bytes + memoryTotalBytes: + type: integer + format: uint64 + description: Total node memory in bytes + disks: + type: array + description: Detailed metrics for each disk/mount point + items: + $ref: '#/components/schemas/DiskMetrics' + MachineInfo: + required: + - cpuFamily + - cpuModel + - cpuModelName + - cpuArchitecture + properties: + cpuFamily: + type: string + description: CPU family of the node + cpuModel: + type: string + description: CPU model of the node + cpuModelName: + type: string + description: CPU model name of the node + cpuArchitecture: + type: string + description: CPU architecture of the node + + Node: + required: + - id + - serviceInstanceID + - clusterID + - status + - sandboxCount + - metrics + - createSuccesses + - createFails + - sandboxStartingCount + - version + - commit + - machineInfo + properties: + version: + type: string + description: Version of the orchestrator + commit: + type: string + description: Commit of the orchestrator + id: + type: string + description: Identifier of the node + serviceInstanceID: + type: string + description: Service instance identifier of the node + clusterID: + type: string + description: Identifier of the cluster + machineInfo: + $ref: '#/components/schemas/MachineInfo' + status: + $ref: '#/components/schemas/NodeStatus' + sandboxCount: + type: integer + format: uint32 + description: Number of sandboxes running on the node + metrics: + $ref: '#/components/schemas/NodeMetrics' + createSuccesses: + type: integer + format: uint64 + description: Number of sandbox create successes + createFails: + type: integer + format: uint64 + description: Number of sandbox create fails + sandboxStartingCount: + type: integer + format: int + description: Number of starting Sandboxes + + NodeDetail: + required: + - id + - serviceInstanceID + - clusterID + - status + - sandboxCount + - cachedBuilds + - createSuccesses + - createFails + - version + - commit + - metrics + - machineInfo + properties: + clusterID: + type: string + description: Identifier of the cluster + version: + type: string + description: Version of the orchestrator + commit: + type: string + description: Commit of the orchestrator + id: + type: string + description: Identifier of the node + serviceInstanceID: + type: string + description: Service instance identifier of the node + machineInfo: + $ref: '#/components/schemas/MachineInfo' + status: + $ref: '#/components/schemas/NodeStatus' + sandboxCount: + type: integer + format: uint32 + description: Number of sandboxes running on the node + metrics: + $ref: '#/components/schemas/NodeMetrics' + cachedBuilds: + type: array + description: List of cached builds id on the node + items: + type: string + createSuccesses: + type: integer + format: uint64 + description: Number of sandbox create successes + createFails: + type: integer + format: uint64 + description: Number of sandbox create fails + + CreatedAccessToken: + required: + - id + - name + - token + - mask + - createdAt + properties: + id: + type: string + format: uuid + description: Identifier of the access token + name: + type: string + description: Name of the access token + token: + type: string + description: The fully created access token + mask: + $ref: '#/components/schemas/IdentifierMaskingDetails' + createdAt: + type: string + format: date-time + description: Timestamp of access token creation + + NewAccessToken: + required: + - name + properties: + name: + type: string + description: Name of the access token + + TeamAPIKey: + required: + - id + - name + - mask + - createdAt + properties: + id: + type: string + format: uuid + description: Identifier of the API key + name: + type: string + description: Name of the API key + mask: + $ref: '#/components/schemas/IdentifierMaskingDetails' + createdAt: + type: string + format: date-time + description: Timestamp of API key creation + createdBy: + allOf: + - $ref: '#/components/schemas/TeamUser' + nullable: true + lastUsed: + type: string + format: date-time + description: Last time this API key was used + nullable: true + + CreatedTeamAPIKey: + required: + - id + - key + - mask + - name + - createdAt + properties: + id: + type: string + format: uuid + description: Identifier of the API key + key: + type: string + description: Raw value of the API key + mask: + $ref: '#/components/schemas/IdentifierMaskingDetails' + name: + type: string + description: Name of the API key + createdAt: + type: string + format: date-time + description: Timestamp of API key creation + createdBy: + allOf: + - $ref: '#/components/schemas/TeamUser' + nullable: true + lastUsed: + type: string + format: date-time + description: Last time this API key was used + nullable: true + + NewTeamAPIKey: + required: + - name + properties: + name: + type: string + description: Name of the API key + + UpdateTeamAPIKey: + required: + - name + properties: + name: + type: string + description: New name for the API key + + AssignedTemplateTags: + required: + - tags + - buildID + properties: + tags: + type: array + items: + type: string + description: Assigned tags of the template + buildID: + type: string + format: uuid + description: Identifier of the build associated with these tags + + TemplateTag: + required: + - tag + - buildID + - createdAt + properties: + tag: + type: string + description: The tag name + buildID: + type: string + format: uuid + description: Identifier of the build associated with this tag + createdAt: + type: string + format: date-time + description: Time when the tag was assigned + + AssignTemplateTagsRequest: + required: + - target + - tags + properties: + target: + type: string + description: Target template in "name:tag" format + tags: + description: Tags to assign to the template + type: array + items: + type: string + + DeleteTemplateTagsRequest: + required: + - name + - tags + properties: + name: + type: string + description: Name of the template + tags: + description: Tags to delete + type: array + items: + type: string + + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + description: Error code + message: + type: string + description: Error + + IdentifierMaskingDetails: + required: + - prefix + - valueLength + - maskedValuePrefix + - maskedValueSuffix + properties: + prefix: + type: string + description: Prefix that identifies the token or key type + valueLength: + type: integer + description: Length of the token or key + maskedValuePrefix: + type: string + description: Prefix used in masked version of the token or key + maskedValueSuffix: + type: string + description: Suffix used in masked version of the token or key + + Volume: + type: object + properties: + volumeID: + type: string + description: ID of the volume + name: + type: string + description: Name of the volume + required: + - volumeID + - name + + VolumeAndToken: + type: object + properties: + volumeID: + type: string + description: ID of the volume + name: + type: string + description: Name of the volume + token: + type: string + description: Auth token to use for interacting with volume content + required: + - volumeID + - name + - token + + NewVolume: + type: object + properties: + name: + type: string + description: Name of the volume + pattern: '^[a-zA-Z0-9_-]+$' + required: + - name + +tags: + - name: templates + - name: sandboxes + - name: auth + - name: access-tokens + - name: api-keys + - name: tags + - name: volumes + +paths: + /health: + get: + description: Health check + responses: + '204': + description: The service is healthy + '401': + $ref: '#/components/responses/401' + + /teams: + get: + description: List all teams + tags: [auth] + security: + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] + responses: + '200': + description: Successfully returned all teams + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/Team' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /teams/{teamID}/metrics: + get: + description: Get metrics for the team + tags: [auth] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/teamID' + - in: query + name: start + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the start of the interval, in seconds, for which the metrics + - in: query + name: end + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the end of the interval, in seconds, for which the metrics + responses: + '200': + description: Successfully returned the team metrics + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TeamMetric' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '500': + $ref: '#/components/responses/500' + + /teams/{teamID}/metrics/max: + get: + description: Get the maximum metrics for the team in the given interval + tags: [auth] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/teamID' + - in: query + name: start + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the start of the interval, in seconds, for which the metrics + - in: query + name: end + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the end of the interval, in seconds, for which the metrics + - in: query + name: metric + required: true + schema: + type: string + enum: [concurrent_sandboxes, sandbox_start_rate] + description: Metric to retrieve the maximum value for + responses: + '200': + description: Successfully returned the team metrics + content: + application/json: + schema: + $ref: '#/components/schemas/MaxTeamMetric' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '500': + $ref: '#/components/responses/500' + + /sandboxes: + get: + description: List all running sandboxes + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: metadata + in: query + description: Metadata query used to filter the sandboxes (e.g. "user=abc&app=prod"). Each key and values must be URL encoded. + required: false + schema: + type: string + responses: + '200': + description: Successfully returned all running sandboxes + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/ListedSandbox' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + post: + description: Create a sandbox from the template + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewSandbox' + responses: + '201': + description: The sandbox was created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Sandbox' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + + /v2/sandboxes: + get: + description: List all sandboxes + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: metadata + in: query + description: Metadata query used to filter the sandboxes (e.g. "user=abc&app=prod"). Each key and values must be URL encoded. + required: false + schema: + type: string + - name: state + in: query + description: Filter sandboxes by one or more states + required: false + schema: + type: array + items: + $ref: '#/components/schemas/SandboxState' + style: form + explode: false + - $ref: '#/components/parameters/paginationNextToken' + - $ref: '#/components/parameters/paginationLimit' + responses: + '200': + description: Successfully returned all running sandboxes + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/ListedSandbox' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + + /sandboxes/metrics: + get: + description: List metrics for given sandboxes + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: sandbox_ids + in: query + required: true + description: Comma-separated list of sandbox IDs to get metrics for + explode: false + schema: + type: array + items: + type: string + maxItems: 100 + uniqueItems: true + responses: + '200': + description: Successfully returned all running sandboxes with metrics + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxesWithMetrics' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/logs: + get: + description: Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + deprecated: true + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + - in: query + name: start + schema: + type: integer + format: int64 + minimum: 0 + description: Starting timestamp of the logs that should be returned in milliseconds + - in: query + name: limit + schema: + default: 1000 + format: int32 + minimum: 0 + type: integer + description: Maximum number of logs that should be returned + responses: + '200': + description: Successfully returned the sandbox logs + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxLogs' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v2/sandboxes/{sandboxID}/logs: + get: + description: Get sandbox logs + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + - in: query + name: cursor + schema: + type: integer + format: int64 + minimum: 0 + description: Starting timestamp of the logs that should be returned in milliseconds + - in: query + name: limit + schema: + default: 1000 + type: integer + format: int32 + minimum: 0 + maximum: 1000 + description: Maximum number of logs that should be returned + - in: query + name: direction + schema: + $ref: '#/components/schemas/LogsDirection' + description: Direction of the logs that should be returned + - in: query + name: level + schema: + $ref: '#/components/schemas/LogLevel' + description: Minimum log level to return. Logs below this level are excluded + - in: query + name: search + schema: + type: string + maxLength: 256 + description: Case-sensitive substring match on log message content + responses: + '200': + description: Successfully returned the sandbox logs + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxLogsV2Response' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}: + get: + description: Get a sandbox by id + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '200': + description: Successfully returned the sandbox + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxDetail' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + delete: + description: Kill a sandbox + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '204': + description: The sandbox was killed successfully + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/metrics: + get: + description: Get sandbox metrics + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + - in: query + name: start + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the start of the interval, in seconds, for which the metrics + - in: query + name: end + schema: + type: integer + format: int64 + minimum: 0 + description: Unix timestamp for the end of the interval, in seconds, for which the metrics + + responses: + '200': + description: Successfully returned the sandbox metrics + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SandboxMetric' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + # TODO: Pause and resume might be exposed as POST /sandboxes/{sandboxID}/snapshot and then POST /sandboxes with specified snapshotting setup + /sandboxes/{sandboxID}/pause: + post: + description: Pause the sandbox + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxPauseRequest' + responses: + '204': + description: The sandbox was paused successfully and can be resumed + '409': + $ref: '#/components/responses/409' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/resume: + post: + deprecated: true + description: Resume the sandbox + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResumedSandbox' + responses: + '201': + description: The sandbox was resumed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Sandbox' + '409': + $ref: '#/components/responses/409' + '404': + $ref: '#/components/responses/404' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/connect: + post: + description: Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ConnectSandbox' + responses: + '200': + description: The sandbox was already running + content: + application/json: + schema: + $ref: '#/components/schemas/Sandbox' + '201': + description: The sandbox was resumed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Sandbox' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/timeout: + post: + description: Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. Calling this method multiple times overwrites the TTL, each time using the current timestamp as the starting point to measure the timeout duration. + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + tags: [sandboxes] + requestBody: + content: + application/json: + schema: + type: object + required: + - timeout + properties: + timeout: + description: Timeout in seconds from the current time after which the sandbox should expire + type: integer + format: int32 + minimum: 0 + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '204': + description: Successfully set the sandbox timeout + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/network: + put: + description: Update the network configuration for a running sandbox. Replaces the current egress rules with the provided configuration. Omitting field clears it. + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + tags: [sandboxes] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SandboxNetworkUpdateConfig' + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '204': + description: Successfully updated the sandbox network configuration + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + $ref: '#/components/responses/409' + '500': + $ref: '#/components/responses/500' + + /sandboxes/{sandboxID}/refreshes: + post: + description: Refresh the sandbox extending its time to live + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + tags: [sandboxes] + requestBody: + content: + application/json: + schema: + type: object + properties: + duration: + description: Duration for which the sandbox should be kept alive in seconds + type: integer + maximum: 3600 # 1 hour + minimum: 0 + parameters: + - $ref: '#/components/parameters/sandboxID' + responses: + '204': + description: Successfully refreshed the sandbox + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + + /sandboxes/{sandboxID}/snapshots: + post: + description: Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new sandboxes and persist beyond the original sandbox's lifetime. + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/sandboxID' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + responses: + '201': + description: Snapshot created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SnapshotInfo' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /snapshots: + get: + description: List all snapshots for the team + tags: [snapshots] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: sandboxID + in: query + required: false + schema: + type: string + description: Filter snapshots by source sandbox ID + - $ref: '#/components/parameters/paginationLimit' + - $ref: '#/components/parameters/paginationNextToken' + responses: + '200': + description: Successfully returned snapshots + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SnapshotInfo' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v3/templates: + post: + description: Create a new template + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildRequestV3' + + responses: + '202': + description: The build was requested successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateRequestResponseV3' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '500': + $ref: '#/components/responses/500' + + /v2/templates: + post: + description: Create a new template + deprecated: true + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildRequestV2' + + responses: + '202': + description: The build was requested successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateLegacy' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/files/{hash}: + get: + description: Get an upload link for a tar file containing build layer files + tags: [templates] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - in: path + name: hash + required: true + schema: + type: string + description: Hash of the files + + responses: + '201': + description: The upload link where to upload the tar file + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildFileUpload' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates: + get: + description: List all templates + tags: [templates] + security: + - ApiKeyAuth: [] + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - in: query + required: false + name: teamID + schema: + type: string + description: Identifier of the team + responses: + '200': + description: Successfully returned all templates + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/Template' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + post: + description: Create a new template + deprecated: true + tags: [templates] + security: + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildRequest' + + responses: + '202': + description: The build was accepted + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateLegacy' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}: + get: + description: List all builds for a template + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/paginationNextToken' + - $ref: '#/components/parameters/paginationLimit' + responses: + '200': + description: Successfully returned the template with its builds + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateWithBuilds' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + post: + description: Rebuild an template + deprecated: true + tags: [templates] + security: + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildRequest' + + responses: + '202': + description: The build was accepted + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateLegacy' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + delete: + description: Delete a template + tags: [templates] + security: + - ApiKeyAuth: [] + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + responses: + '204': + description: The template was deleted successfully + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + patch: + description: Update template + deprecated: true + tags: [templates] + security: + - ApiKeyAuth: [] + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateUpdateRequest' + responses: + '200': + description: The template was updated successfully + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/builds/{buildID}: + post: + description: Start the build + deprecated: true + tags: [templates] + security: + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/buildID' + responses: + '202': + description: The build has started + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v2/templates/{templateID}/builds/{buildID}: + post: + description: Start the build + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/buildID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildStartV2' + responses: + '202': + description: The build has started + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /v2/templates/{templateID}: + patch: + description: Update template + tags: [templates] + security: + - ApiKeyAuth: [] + - AccessTokenAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateUpdateRequest' + responses: + '200': + description: The template was updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateUpdateResponse' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/builds/{buildID}/status: + get: + description: Get template build info + tags: [templates] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/buildID' + - in: query + name: logsOffset + schema: + default: 0 + type: integer + format: int32 + minimum: 0 + description: Index of the starting build log that should be returned with the template + - in: query + name: limit + schema: + default: 100 + type: integer + format: int32 + minimum: 0 + maximum: 100 + description: Maximum number of logs that should be returned + - in: query + name: level + schema: + $ref: '#/components/schemas/LogLevel' + responses: + '200': + description: Successfully returned the template + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildInfo' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/builds/{buildID}/logs: + get: + description: Get template build logs + tags: [templates] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + - $ref: '#/components/parameters/buildID' + - in: query + name: cursor + schema: + type: integer + format: int64 + minimum: 0 + description: Starting timestamp of the logs that should be returned in milliseconds + - in: query + name: limit + schema: + default: 100 + type: integer + format: int32 + minimum: 0 + maximum: 100 + description: Maximum number of logs that should be returned + - in: query + name: direction + schema: + $ref: '#/components/schemas/LogsDirection' + - in: query + name: level + schema: + $ref: '#/components/schemas/LogLevel' + - in: query + name: source + schema: + $ref: '#/components/schemas/LogsSource' + description: Source of the logs that should be returned from + responses: + '200': + description: Successfully returned the template build logs + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateBuildLogsResponse' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates/tags: + post: + description: Assign tag(s) to a template build + tags: [tags] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AssignTemplateTagsRequest' + responses: + '201': + description: Tag assigned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AssignedTemplateTags' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + delete: + description: Delete multiple tags from templates + tags: [tags] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteTemplateTagsRequest' + responses: + '204': + description: Tags deleted successfully + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates/{templateID}/tags: + get: + description: List all tags for a template + tags: [tags] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/templateID' + responses: + '200': + description: Successfully returned the template tags + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TemplateTag' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /templates/aliases/{alias}: + get: + description: Check if template with given alias exists + tags: [templates] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - name: alias + in: path + required: true + schema: + type: string + description: Template alias + responses: + '200': + description: Successfully queried template by alias + content: + application/json: + schema: + $ref: '#/components/schemas/TemplateAliasResponse' + '400': + $ref: '#/components/responses/400' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /nodes: + get: + description: List all nodes + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - in: query + name: clusterID + description: Identifier of the cluster + required: false + schema: + type: string + format: uuid + responses: + '200': + description: Successfully returned all nodes + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: '#/components/schemas/Node' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /nodes/{nodeID}: + get: + description: Get node info + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - $ref: '#/components/parameters/nodeID' + - in: query + name: clusterID + description: Identifier of the cluster + required: false + schema: + type: string + format: uuid + responses: + '200': + description: Successfully returned the node + content: + application/json: + schema: + $ref: '#/components/schemas/NodeDetail' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + post: + description: Change status of a node + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - $ref: '#/components/parameters/nodeID' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/NodeStatusChange' + responses: + '204': + description: The node status was changed successfully + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /admin/teams/{teamID}/sandboxes/kill: + post: + summary: Kill all sandboxes for a team + description: Kills all sandboxes for the specified team + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + responses: + '200': + description: Successfully killed sandboxes + content: + application/json: + schema: + $ref: '#/components/schemas/AdminSandboxKillResult' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /admin/teams/{teamID}/builds/cancel: + post: + summary: Cancel all builds for a team + description: Cancels all in-progress and pending builds for the specified team + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + responses: + '200': + description: Successfully cancelled builds + content: + application/json: + schema: + $ref: '#/components/schemas/AdminBuildCancelResult' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /admin/teams/{teamID}/api-keys: + post: + summary: Create team API key as admin + description: Creates a team API key for internal service workflows. + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewTeamAPIKey' + responses: + '201': + description: Team API key created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedTeamAPIKey' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /admin/teams/{teamID}/api-keys/{apiKeyID}: + delete: + summary: Delete team API key as admin + description: Deletes a team API key for internal service workflows. + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - name: teamID + in: path + required: true + schema: + type: string + format: uuid + description: Team ID + - $ref: '#/components/parameters/apiKeyID' + responses: + '204': + description: Team API key deleted successfully + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /access-tokens: + post: + description: Create a new access token + tags: [access-tokens] + security: + - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewAccessToken' + responses: + '201': + description: Access token created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedAccessToken' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /access-tokens/{accessTokenID}: + delete: + description: Delete an access token + tags: [access-tokens] + security: + - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] + parameters: + - $ref: '#/components/parameters/accessTokenID' + responses: + '204': + description: Access token deleted successfully + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /api-keys: + get: + description: List all team API keys + tags: [api-keys] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + responses: + '200': + description: Successfully returned all team API keys + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TeamAPIKey' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + post: + description: Create a new team API key + tags: [api-keys] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewTeamAPIKey' + responses: + '201': + description: Team API key created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedTeamAPIKey' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /api-keys/{apiKeyID}: + patch: + description: Update a team API key + tags: [api-keys] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/apiKeyID' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTeamAPIKey' + responses: + '200': + description: Team API key updated successfully + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + delete: + description: Delete a team API key + tags: [api-keys] + security: + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/apiKeyID' + responses: + '204': + description: Team API key deleted successfully + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + /volumes: + get: + description: List all team volumes + tags: [volumes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + responses: + '200': + description: Successfully listed all team volumes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Volume' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + post: + description: Create a new team volume + tags: [volumes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewVolume' + responses: + '201': + description: Successfully created a new team volume + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeAndToken' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '500': + $ref: '#/components/responses/500' + + /volumes/{volumeID}: + get: + description: Get team volume info + tags: [volumes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/volumeID' + responses: + '200': + description: Successfully retrieved a team volume + content: + application/json: + schema: + $ref: '#/components/schemas/VolumeAndToken' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' + + delete: + description: Delete a team volume + tags: [volumes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] + parameters: + - $ref: '#/components/parameters/volumeID' + responses: + '204': + description: Successfully deleted a team volume + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '500': + $ref: '#/components/responses/500' diff --git a/compat/e2b/spec/e2b/public-exports/python-init.py b/compat/e2b/spec/e2b/public-exports/python-init.py new file mode 100644 index 00000000..c8b65aa7 --- /dev/null +++ b/compat/e2b/spec/e2b/public-exports/python-init.py @@ -0,0 +1,260 @@ +""" +Secure sandboxed cloud environments made for AI agents and AI apps. + +Check docs [here](https://e2b.dev/docs). + +E2B Sandbox is a secure cloud sandbox environment made for AI agents and AI +apps. +Sandboxes allow AI agents and apps to have long running cloud secure environments. +In these environments, large language models can use the same tools as humans do. + +E2B Python SDK supports both sync and async API: + +```py +from e2b import Sandbox + +# Create sandbox +sandbox = Sandbox.create() +``` + +```py +from e2b import AsyncSandbox + +# Create sandbox +sandbox = await AsyncSandbox.create() +``` +""" + +from .api import ( + ApiClient, + client, +) +from .connection_config import ( + ApiParams, + ConnectionConfig, + ProxyTypes, + Username, +) +from .volume.connection_config import VolumeApiParams, VolumeConnectionConfig +from .exceptions import ( + AuthenticationException, + FileNotFoundException, + GitAuthException, + GitUpstreamException, + BuildException, + FileUploadException, + InvalidArgumentException, + NotEnoughSpaceException, + NotFoundException, + RateLimitException, + SandboxException, + SandboxNotFoundException, + TemplateException, + TimeoutException, + VolumeException, +) +from .sandbox.commands.command_handle import ( + CommandExitException, + CommandResult, + PtyOutput, + PtySize, + Stderr, + Stdout, +) +from .sandbox.commands.main import ProcessInfo +from .sandbox.filesystem.filesystem import EntryInfo, FileType, WriteInfo +from .sandbox.filesystem.watch_handle import ( + FilesystemEvent, + FilesystemEventType, +) +from .sandbox._git import GitBranches, GitFileStatus, GitResetMode, GitStatus +from .sandbox_sync.git import Git +from .sandbox.network import ALL_TRAFFIC +from .sandbox.signature import get_signature +from .sandbox.sandbox_api import ( + GitHubMcpServer, + GitHubMcpServerConfig, + McpServer, + SandboxInfo, + SandboxInfoLifecycle, + SandboxMetrics, + SandboxLifecycle, + SandboxOnTimeout, + SandboxNetworkInfo, + SandboxNetworkOpts, + SandboxNetworkRule, + SandboxNetworkRuleInfo, + SandboxNetworkRules, + SandboxNetworkSelector, + SandboxNetworkSelectorContext, + SandboxNetworkTransform, + SandboxNetworkUpdate, + SandboxQuery, + SandboxState, + SnapshotInfo, +) +from .sandbox_async.commands.command_handle import AsyncCommandHandle +from .sandbox_async.filesystem.watch_handle import AsyncWatchHandle +from .sandbox_async.main import AsyncSandbox +from .sandbox_async.paginator import AsyncSandboxPaginator, AsyncSnapshotPaginator +from .sandbox_async.utils import OutputHandler +from .sandbox_sync.commands.command_handle import CommandHandle +from .sandbox_sync.filesystem.watch_handle import WatchHandle +from .sandbox_sync.main import Sandbox +from .sandbox_sync.paginator import SandboxPaginator, SnapshotPaginator +from .template.logger import ( + LogEntry, + LogEntryEnd, + LogEntryLevel, + LogEntryStart, + default_build_logger, +) +from .template.main import TemplateBase, TemplateClass +from .template.readycmd import ( + ReadyCmd, + wait_for_file, + wait_for_port, + wait_for_process, + wait_for_timeout, + wait_for_url, +) +from .template.types import ( + BuildInfo, + BuildStatusReason, + CopyItem, + TemplateBuildStatus, + TemplateBuildStatusResponse, + TemplateTag, + TemplateTagInfo, +) +from .template_async.main import AsyncTemplate +from .template_sync.main import Template + +from .volume.volume_sync import Volume +from .volume.volume_async import AsyncVolume +from .volume.types import ( + VolumeInfo, + VolumeAndToken, + VolumeEntryStat, + VolumeFileType, +) + +__all__ = [ + # API + "ApiClient", + "client", + # Connection config + "ConnectionConfig", + "VolumeConnectionConfig", + "ProxyTypes", + "ApiParams", + "VolumeApiParams", + "Username", + # Exceptions + "SandboxException", + "TimeoutException", + "NotFoundException", + "FileNotFoundException", + "SandboxNotFoundException", + "AuthenticationException", + "GitAuthException", + "GitUpstreamException", + "InvalidArgumentException", + "NotEnoughSpaceException", + "TemplateException", + "BuildException", + "FileUploadException", + "RateLimitException", + "VolumeException", + # Sandbox API + "SandboxInfo", + "SandboxInfoLifecycle", + "SandboxMetrics", + "ProcessInfo", + "SandboxQuery", + "SandboxState", + "SandboxMetrics", + "GitStatus", + "GitBranches", + "GitFileStatus", + "GitResetMode", + # Command handle + "CommandResult", + "Stderr", + "Stdout", + "CommandExitException", + "PtyOutput", + "PtySize", + # Filesystem + "FilesystemEvent", + "FilesystemEventType", + "EntryInfo", + "WriteInfo", + "FileType", + # Network + "SandboxNetworkOpts", + "SandboxNetworkInfo", + "SandboxNetworkSelector", + "SandboxNetworkSelectorContext", + "SandboxNetworkRule", + "SandboxNetworkRuleInfo", + "SandboxNetworkRules", + "SandboxNetworkTransform", + "SandboxNetworkUpdate", + "SandboxLifecycle", + "SandboxOnTimeout", + "ALL_TRAFFIC", + # Snapshot + "SnapshotInfo", + "SnapshotPaginator", + "AsyncSnapshotPaginator", + # Signature + "get_signature", + # Sync sandbox + "Sandbox", + "SandboxPaginator", + "WatchHandle", + "CommandHandle", + # Async sandbox + "OutputHandler", + "AsyncSandboxPaginator", + "AsyncSandbox", + "AsyncWatchHandle", + "AsyncCommandHandle", + # Template + "Template", + "AsyncTemplate", + "TemplateBase", + "TemplateClass", + "CopyItem", + "BuildInfo", + "BuildStatusReason", + "TemplateBuildStatus", + "TemplateBuildStatusResponse", + "TemplateTag", + "TemplateTagInfo", + "ReadyCmd", + "wait_for_file", + "wait_for_url", + "wait_for_port", + "wait_for_process", + "wait_for_timeout", + "LogEntry", + "LogEntryStart", + "LogEntryEnd", + "LogEntryLevel", + "default_build_logger", + # MCP + "McpServer", + "GitHubMcpServer", + "GitHubMcpServerConfig", + # Git + "Git", + # Volume + "Volume", + "AsyncVolume", + "VolumeInfo", + "VolumeAndToken", + "VolumeEntryStat", + "VolumeFileType", +] diff --git a/compat/e2b/spec/e2b/public-exports/typescript-index.ts b/compat/e2b/spec/e2b/public-exports/typescript-index.ts new file mode 100644 index 00000000..e20436ed --- /dev/null +++ b/compat/e2b/spec/e2b/public-exports/typescript-index.ts @@ -0,0 +1,151 @@ +export { ApiClient } from './api' +export type { components, paths } from './api' + +export { ConnectionConfig } from './connectionConfig' +export type { + ConnectionConfigOpts, + ConnectionOpts, + Username, +} from './connectionConfig' +export { + AuthenticationError, + FileNotFoundError, + GitAuthError, + GitUpstreamError, + InvalidArgumentError, + NotEnoughSpaceError, + NotFoundError, + SandboxError, + SandboxNotFoundError, + TemplateError, + TimeoutError, + RateLimitError, + BuildError, + FileUploadError, + VolumeError, +} from './errors' +export type { Logger } from './logs' + +export { getSignature } from './sandbox/signature' + +export { FileType } from './sandbox/filesystem' +export type { + WriteInfo, + EntryInfo, + Filesystem, + FilesystemWriteOpts, + FilesystemReadOpts, +} from './sandbox/filesystem' +export { FilesystemEventType } from './sandbox/filesystem/watchHandle' +export type { + FilesystemEvent, + WatchHandle, +} from './sandbox/filesystem/watchHandle' + +export { CommandExitError } from './sandbox/commands/commandHandle' +export type { + CommandResult, + Stdout, + Stderr, + PtyOutput, + CommandHandle, +} from './sandbox/commands/commandHandle' +export type { + SandboxInfo, + SandboxMetrics, + SandboxOpts, + SandboxApiOpts, + SandboxConnectOpts, + SandboxMetricsOpts, + SandboxPauseOpts, + SandboxState, + SandboxListOpts, + SandboxPaginator, + SandboxNetworkOpts, + SandboxNetworkInfo, + SandboxNetworkSelector, + SandboxNetworkSelectorContext, + SandboxNetworkRule, + SandboxNetworkRuleInfo, + SandboxNetworkRules, + SandboxNetworkTransform, + SandboxNetworkUpdate, + SandboxOnTimeout, + SandboxLifecycle, + SandboxInfoLifecycle, + SnapshotInfo, + SnapshotListOpts, + SnapshotPaginator, + CreateSnapshotOpts, +} from './sandbox/sandboxApi' + +export type { McpServer } from './sandbox/mcp' + +export { ALL_TRAFFIC } from './sandbox/network' + +export type { + ProcessInfo, + CommandRequestOpts, + CommandConnectOpts, + CommandStartOpts, + Commands, + Pty, +} from './sandbox/commands' + +export { Git } from './sandbox/git' +export type { + GitRequestOpts, + GitCloneOpts, + GitInitOpts, + GitRemoteAddOpts, + GitCommitOpts, + GitAddOpts, + GitDeleteBranchOpts, + GitPushOpts, + GitPullOpts, + GitDangerouslyAuthenticateOpts, + GitConfigOpts, + GitConfigScope, + GitBranches, + GitFileStatus, + GitStatus, +} from './sandbox/git' + +export { Volume, VolumeFileType } from './volume' +export type { + VolumeInfo, + VolumeAndToken, + VolumeEntryStat, + VolumeMetadataOpts, + VolumeReadOpts, + VolumeWriteOpts, + VolumeApiOpts, + VolumeConnectionConfig, + // Deprecated aliases, kept for backwards compatibility. + VolumeMetadataOptions, + VolumeWriteOptions, +} from './volume' + +export { Sandbox } +import { Sandbox } from './sandbox' + +export default Sandbox + +export * from './template' + +export { + ReadyCmd, + waitForPort, + waitForURL, + waitForProcess, + waitForFile, + waitForTimeout, +} from './template/readycmd' + +export { + LogEntry, + LogEntryStart, + LogEntryEnd, + type LogEntryLevel, + defaultBuildLogger, +} from './template/logger' diff --git a/compat/e2b/spec/e2b/public-exports/typescript-template-index.ts b/compat/e2b/spec/e2b/public-exports/typescript-template-index.ts new file mode 100644 index 00000000..7e75c92d --- /dev/null +++ b/compat/e2b/spec/e2b/public-exports/typescript-template-index.ts @@ -0,0 +1,1375 @@ +import type { PathLike } from 'node:fs' +import { ApiClient } from '../api' +import { ConnectionConfig, ConnectionOpts } from '../connectionConfig' +import { BuildError, InvalidArgumentError } from '../errors' +import { runtime, shellQuote } from '../utils' +import { + assignTags, + checkAliasExists, + getTemplateTags, + removeTags, + getBuildStatus, + getFileUploadLink, + requestBuild, + triggerBuild, + TriggerBuildTemplate, + uploadFile, + waitForBuildFinish, +} from './buildApi' +import { GZIP, RESOLVE_SYMLINKS, STACK_TRACE_DEPTH } from './consts' +import { parseDockerfile } from './dockerfileParser' +import { LogEntry, LogEntryEnd, LogEntryStart } from './logger' +import { ReadyCmd, waitForFile } from './readycmd' +import { + BuildInfo, + BuildOptions, + CopyItem, + GetBuildStatusOptions, + Instruction, + InstructionType, + McpServerName, + RegistryConfig, + TemplateBuilder, + TemplateBuildStatusResponse, + TemplateClass, + TemplateFinal, + TemplateFromImage, + TemplateOptions, + TemplateTag, + TemplateTagInfo, +} from './types' +import { + calculateFilesHash, + getCallerDirectory, + getCallerFrame, + normalizeBuildArguments, + padOctal, + readDockerignore, + readGCPServiceAccountJSON, + validateRelativePath, +} from './utils' + +/** + * Base class for building E2B sandbox templates. + */ +export class TemplateBase + implements TemplateFromImage, TemplateBuilder, TemplateFinal +{ + private defaultBaseImage: string = 'e2bdev/base' + private baseImage: string | undefined = this.defaultBaseImage + private baseTemplate: string | undefined = undefined + private registryConfig: RegistryConfig | undefined = undefined + private startCmd: string | undefined = undefined + private readyCmd: string | undefined = undefined + // Force the whole template to be rebuilt + private force: boolean = false + // Force the next layer to be rebuilt + private forceNextLayer: boolean = false + private instructions: Instruction[] = [] + private fileContextPath: PathLike = + runtime === 'browser' ? '.' : (getCallerDirectory(STACK_TRACE_DEPTH) ?? '.') + private fileIgnorePatterns: string[] = [] + private logsRefreshFrequency: number = 200 + private stackTraces: (string | undefined)[] = [] + private stackTracesEnabled: boolean = true + private stackTracesOverride: string | undefined = undefined + + constructor(options?: TemplateOptions) { + this.fileContextPath = options?.fileContextPath ?? this.fileContextPath + this.fileIgnorePatterns = + options?.fileIgnorePatterns ?? this.fileIgnorePatterns + } + + /** + * Convert a template to JSON representation. + * + * @param template The template to convert + * @param computeHashes Whether to compute file hashes for cache invalidation + * @returns JSON string representation of the template + */ + static toJSON( + template: TemplateClass, + computeHashes: boolean = true + ): Promise { + return (template as TemplateBase).toJSON(computeHashes) + } + + /** + * Convert a template to Dockerfile format. + * Note: Templates based on other E2B templates cannot be converted to Dockerfile. + * + * @param template The template to convert + * @returns Dockerfile string representation + * @throws Error if the template is based on another E2B template + */ + static toDockerfile(template: TemplateClass): string { + return (template as TemplateBase).toDockerfile() + } + + /** + * Build and deploy a template to E2B infrastructure. + * + * @param template The template to build + * @param name Template name in 'name' or 'name:tag' format + * @param options Optional build configuration options + * + * @example + * ```ts + * const template = Template().fromPythonImage('3') + * + * // Build with single tag in name + * await Template.build(template, 'my-python-env:v1.0') + * + * // Build with multiple tags + * await Template.build(template, 'my-python-env', { tags: ['v1.0', 'stable'] }) + * ``` + */ + static async build( + template: TemplateClass, + name: string, + options?: Omit + ): Promise + /** + * Build and deploy a template to E2B infrastructure. + * + * @param template The template to build + * @param options Build configuration options with alias (deprecated) + * + * @deprecated Use the overload with `name` parameter instead. + * @example + * ```ts + * // Deprecated: + * await Template.build(template, { alias: 'my-python-env' }) + * + * // Use instead: + * await Template.build(template, 'my-python-env:v1.0') + * ``` + */ + static async build( + template: TemplateClass, + options: BuildOptions + ): Promise + static async build( + template: TemplateClass, + nameOrOptions: string | BuildOptions, + options?: Omit + ): Promise { + const { name, buildOptions } = normalizeBuildArguments( + nameOrOptions, + options + ) + + try { + buildOptions.onBuildLogs?.(new LogEntryStart(new Date(), 'Build started')) + const baseTemplate = template as TemplateBase + + const config = new ConnectionConfig(buildOptions) + const client = new ApiClient(config) + + const data = await baseTemplate.build(client, config, name, buildOptions) + + buildOptions.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'Waiting for logs...') + ) + + await waitForBuildFinish(client, { + templateID: data.templateId, + buildID: data.buildId, + onBuildLogs: buildOptions.onBuildLogs, + logsRefreshFrequency: baseTemplate.logsRefreshFrequency, + stackTraces: baseTemplate.stackTraces, + signal: buildOptions.signal, + requestTimeoutMs: config.requestTimeoutMs, + }) + + return data + } finally { + buildOptions.onBuildLogs?.(new LogEntryEnd(new Date(), 'Build finished')) + } + } + + /** + * Build and deploy a template to E2B infrastructure without waiting for completion. + * + * @param template The template to build + * @param name Template name in 'name' or 'name:tag' format + * @param options Optional build configuration options + * + * @example + * ```ts + * const template = Template().fromPythonImage('3') + * + * // Build with single tag in name + * const data = await Template.buildInBackground(template, 'my-python-env:v1.0') + * + * // Build with multiple tags + * const data = await Template.buildInBackground(template, 'my-python-env', { tags: ['v1.0', 'stable'] }) + * ``` + */ + static async buildInBackground( + template: TemplateClass, + name: string, + options?: Omit + ): Promise + /** + * Build and deploy a template to E2B infrastructure without waiting for completion. + * + * @param template The template to build + * @param options Build configuration options with alias (deprecated) + * + * @deprecated Use the overload with `name` parameter instead. + * @example + * ```ts + * // Deprecated: + * await Template.buildInBackground(template, { alias: 'my-python-env' }) + * + * // Use instead: + * await Template.buildInBackground(template, 'my-python-env:v1.0') + * ``` + */ + static async buildInBackground( + template: TemplateClass, + options: BuildOptions + ): Promise + static async buildInBackground( + template: TemplateClass, + nameOrOptions: string | BuildOptions, + options?: Omit + ): Promise { + const { name, buildOptions } = normalizeBuildArguments( + nameOrOptions, + options + ) + + const config = new ConnectionConfig(buildOptions) + const client = new ApiClient(config) + + return (template as TemplateBase).build(client, config, name, buildOptions) + } + + /** + * Get the status of a build. + * + * @param data Build identifiers + * @param options Authentication options + * + * @example + * ```ts + * const status = await Template.getBuildStatus(data, { logsOffset: 0 }) + * ``` + */ + static async getBuildStatus( + data: Pick, + options?: GetBuildStatusOptions + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + + return await getBuildStatus( + client, + { + templateID: data.templateId, + buildID: data.buildId, + logsOffset: options?.logsOffset ?? 0, + }, + config.getSignal(undefined, options?.signal) + ) + } + + /** + * Check if a template with the given name exists. + * + * @param name Template name to check + * @param options Authentication options + * @returns True if the name exists, false otherwise + * + * @example + * ```ts + * const exists = await Template.exists('my-python-env') + * if (exists) { + * console.log('Template exists!') + * } + * ``` + */ + static async exists( + name: string, + options?: ConnectionOpts + ): Promise { + return TemplateBase.aliasExists(name, options) + } + + /** + * Check if a template with the given alias exists. + * + * @param alias Template alias to check + * @param options Authentication options + * @returns True if the alias exists, false otherwise + * + * @deprecated Use `exists` instead. + * @example + * ```ts + * const exists = await Template.aliasExists('my-python-env') + * if (exists) { + * console.log('Template exists!') + * } + * ``` + */ + static async aliasExists( + alias: string, + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + + return checkAliasExists( + client, + { alias }, + config.getSignal(undefined, options?.signal) + ) + } + + /** + * Assign tag(s) to an existing template build. + * + * @param targetName Template name in 'name:tag' format (the source build to tag from) + * @param tags Tag or tags to assign + * @param options Authentication options + * @returns Tag info with buildId and assigned tags + * + * @example + * ```ts + * // Assign a single tag + * await Template.assignTags('my-template:v1.0', 'production') + * + * // Assign multiple tags + * await Template.assignTags('my-template:v1.0', ['production', 'stable']) + * ``` + */ + static async assignTags( + targetName: string, + tags: string | string[], + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + const normalizedTags = Array.isArray(tags) ? tags : [tags] + return assignTags( + client, + { targetName, tags: normalizedTags }, + config.getSignal(undefined, options?.signal) + ) + } + + /** + * Remove tag(s) from a template. + * + * @param name Template name + * @param tags Tag or tags to remove + * @param options Authentication options + * + * @example + * ```ts + * // Remove a single tag + * await Template.removeTags('my-template', 'production') + * + * // Remove multiple tags from a template + * await Template.removeTags('my-template', ['production', 'staging']) + * ``` + */ + static async removeTags( + name: string, + tags: string | string[], + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + const normalizedTags = Array.isArray(tags) ? tags : [tags] + return removeTags( + client, + { name, tags: normalizedTags }, + config.getSignal(undefined, options?.signal) + ) + } + + /** + * Get all tags for a template. + * + * @param templateId Template ID or name + * @param options Authentication options + * @returns Array of tag details including tag name, buildId, and creation date + * + * @example + * ```ts + * const tags = await Template.getTags('my-template') + * for (const tag of tags) { + * console.log(`Tag: ${tag.tag}, Build: ${tag.buildId}, Created: ${tag.createdAt}`) + * } + * ``` + */ + static async getTags( + templateId: string, + options?: ConnectionOpts + ): Promise { + const config = new ConnectionConfig(options) + const client = new ApiClient(config) + return getTemplateTags( + client, + { templateID: templateId }, + config.getSignal(undefined, options?.signal) + ) + } + + fromDebianImage(variant: string = 'stable'): TemplateBuilder { + return this.fromImage(`debian:${variant}`) + } + + fromUbuntuImage(variant: string = 'latest'): TemplateBuilder { + return this.fromImage(`ubuntu:${variant}`) + } + + fromPythonImage(version: string = '3'): TemplateBuilder { + return this.fromImage(`python:${version}`) + } + + fromNodeImage(variant: string = 'lts'): TemplateBuilder { + return this.fromImage(`node:${variant}`) + } + + fromBunImage(variant: string = 'latest'): TemplateBuilder { + return this.fromImage(`oven/bun:${variant}`) + } + + fromBaseImage(): TemplateBuilder { + return this.fromImage(this.defaultBaseImage) + } + + fromImage( + baseImage: string, + credentials?: { username: string; password: string } + ): TemplateBuilder { + // Validate before mutating the builder. + if (credentials && (!credentials.username || !credentials.password)) { + throw new InvalidArgumentError( + 'Both username and password are required when providing registry credentials', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + + this.baseImage = baseImage + this.baseTemplate = undefined + + // Set the registry config if provided + if (credentials) { + this.registryConfig = { + type: 'registry', + username: credentials.username, + password: credentials.password, + } + } + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + fromTemplate(template: string): TemplateBuilder { + this.baseTemplate = template + this.baseImage = undefined + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + fromDockerfile(dockerfileContentOrPath: string): TemplateBuilder { + const { baseImage } = this.runInStackTraceOverrideContext( + () => parseDockerfile(dockerfileContentOrPath, this), + // -1 as we're going up the call stack from the parseDockerfile function + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + this.baseImage = baseImage + this.baseTemplate = undefined + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + fromAWSRegistry( + image: string, + credentials: { + accessKeyId: string + secretAccessKey: string + region: string + } + ): TemplateBuilder { + this.baseImage = image + this.baseTemplate = undefined + + // Set the registry config if provided + this.registryConfig = { + type: 'aws', + awsAccessKeyId: credentials.accessKeyId, + awsSecretAccessKey: credentials.secretAccessKey, + awsRegion: credentials.region, + } + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + fromGCPRegistry( + image: string, + credentials: { + serviceAccountJSON: string | object + } + ): TemplateBuilder { + this.baseImage = image + this.baseTemplate = undefined + + // Set the registry config if provided + this.registryConfig = { + type: 'gcp', + serviceAccountJson: readGCPServiceAccountJSON( + this.fileContextPath.toString(), + credentials.serviceAccountJSON + ), + } + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + this.collectStackTrace() + return this + } + + copy( + src: PathLike | PathLike[], + dest: PathLike, + options?: { + forceUpload?: true + user?: string + mode?: number + resolveSymlinks?: boolean + gzip?: boolean + } + ): TemplateBuilder { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for copy') + } + + const srcs = Array.isArray(src) ? src : [src] + const stackTrace = getCallerFrame(STACK_TRACE_DEPTH - 1) + + for (const src of srcs) { + const srcString = src.toString() + + // Validate that the source path is a relative path within the context directory + validateRelativePath(srcString, stackTrace) + + const args = [ + srcString, + dest.toString(), + options?.user ?? '', + options?.mode ? padOctal(options.mode) : '', + ] + + this.instructions.push({ + type: InstructionType.COPY, + args, + force: options?.forceUpload || this.forceNextLayer, + forceUpload: options?.forceUpload, + resolveSymlinks: options?.resolveSymlinks, + gzip: options?.gzip, + }) + + // Collect one stack trace per pushed instruction so build steps stay + // aligned with their stack traces when copying multiple sources + this.collectStackTrace() + } + + return this + } + + copyItems(items: CopyItem[]): TemplateBuilder { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for copyItems') + } + + // Stack trace that will be used to re-throw the error with + const stackTrace = getCallerFrame(STACK_TRACE_DEPTH - 1) + + // Use the override so each copied item collects this stack trace, + // keeping build steps aligned with their stack traces + this.runInStackTraceOverrideContext(() => { + for (const item of items) { + try { + this.copy(item.src, item.dest, { + forceUpload: item.forceUpload, + user: item.user, + mode: item.mode, + resolveSymlinks: item.resolveSymlinks, + gzip: item.gzip, + }) + } catch (error) { + const copyError = error as Error + copyError.stack = stackTrace + throw copyError + } + } + }, stackTrace) + + return this + } + + remove( + path: PathLike | PathLike[], + options?: { force?: boolean; recursive?: boolean; user?: string } + ): TemplateBuilder { + const paths = Array.isArray(path) ? path : [path] + const args = ['rm'] + if (options?.recursive) { + args.push('-r') + } + if (options?.force) { + args.push('-f') + } + args.push(...paths.map((p) => shellQuote(p.toString()))) + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + rename( + src: PathLike, + dest: PathLike, + options?: { force?: boolean; user?: string } + ): TemplateBuilder { + const args = ['mv', shellQuote(src.toString()), shellQuote(dest.toString())] + if (options?.force) { + args.push('-f') + } + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + makeDir( + path: PathLike | PathLike[], + options?: { mode?: number; user?: string } + ): TemplateBuilder { + const paths = Array.isArray(path) ? path : [path] + const args = ['mkdir', '-p'] + if (options?.mode) { + args.push(`-m ${padOctal(options.mode)}`) + } + args.push(...paths.map((p) => shellQuote(p.toString()))) + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + makeSymlink( + src: PathLike, + dest: PathLike, + options?: { user?: string; force?: boolean } + ): TemplateBuilder { + const args = ['ln', '-s'] + if (options?.force) { + args.push('-f') + } + args.push(shellQuote(src.toString()), shellQuote(dest.toString())) + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + runCmd(command: string, options?: { user?: string }): TemplateBuilder + runCmd(commands: string[], options?: { user?: string }): TemplateBuilder + runCmd( + commandOrCommands: string | string[], + options?: { user?: string } + ): TemplateBuilder { + const cmds = Array.isArray(commandOrCommands) + ? commandOrCommands + : [commandOrCommands] + + const args = [cmds.join(' && ')] + if (options?.user) { + args.push(options.user) + } + + this.instructions.push({ + type: InstructionType.RUN, + args, + force: this.forceNextLayer, + }) + + this.collectStackTrace() + return this + } + + setWorkdir(workdir: PathLike): TemplateBuilder { + this.instructions.push({ + type: InstructionType.WORKDIR, + args: [workdir.toString()], + force: this.forceNextLayer, + }) + + this.collectStackTrace() + return this + } + + setUser(user: string): TemplateBuilder { + this.instructions.push({ + type: InstructionType.USER, + args: [user], + force: this.forceNextLayer, + }) + + this.collectStackTrace() + return this + } + + pipInstall( + packages?: string | string[], + options?: { g?: boolean } + ): TemplateBuilder { + const g = options?.g ?? true + + const args = ['pip', 'install'] + const packageList = packages + ? Array.isArray(packages) + ? packages + : [packages] + : undefined + if (g === false) { + args.push('--user') + } + if (packageList) { + args.push(...packageList) + } else { + args.push('.') + } + + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { + user: g ? 'root' : undefined, + }) + ) + } + + npmInstall( + packages?: string | string[], + options?: { g?: boolean; dev?: boolean } + ): TemplateBuilder { + const args = ['npm', 'install'] + const packageList = packages + ? Array.isArray(packages) + ? packages + : [packages] + : undefined + if (options?.g) { + args.push('-g') + } + if (options?.dev) { + args.push('--save-dev') + } + if (packageList) { + args.push(...packageList) + } + + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { + user: options?.g ? 'root' : undefined, + }) + ) + } + + bunInstall( + packages?: string | string[], + options?: { g?: boolean; dev?: boolean } + ): TemplateBuilder { + const args = ['bun', 'install'] + const packageList = packages + ? Array.isArray(packages) + ? packages + : [packages] + : undefined + if (options?.g) { + args.push('-g') + } + if (options?.dev) { + args.push('--dev') + } + if (packageList) { + args.push(...packageList) + } + + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { + user: options?.g ? 'root' : undefined, + }) + ) + } + + aptInstall( + packages: string | string[], + options?: { noInstallRecommends?: boolean; fixMissing?: boolean } + ): TemplateBuilder { + const packageList = Array.isArray(packages) ? packages : [packages] + return this.runInNewStackTraceContext(() => + this.runCmd( + [ + 'apt-get update', + `DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y ${options?.noInstallRecommends ? '--no-install-recommends ' : ''}${options?.fixMissing ? '--fix-missing ' : ''}${packageList.join( + ' ' + )}`, + ], + { user: 'root' } + ) + ) + } + + addMcpServer(servers: McpServerName | McpServerName[]): TemplateBuilder { + if (this.baseTemplate !== 'mcp-gateway') { + throw new BuildError( + 'MCP servers can only be added to mcp-gateway template', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + + const serverList = Array.isArray(servers) ? servers : [servers] + return this.runInNewStackTraceContext(() => + this.runCmd(`mcp-gateway pull ${serverList.join(' ')}`, { + user: 'root', + }) + ) + } + + gitClone( + url: string, + path?: PathLike, + options?: { branch?: string; depth?: number; user?: string } + ): TemplateBuilder { + const args = ['git', 'clone', shellQuote(url)] + if (options?.branch) { + args.push(`--branch ${shellQuote(options.branch)}`) + args.push('--single-branch') + } + if (options?.depth) { + args.push(`--depth ${options.depth}`) + } + if (path) { + args.push(shellQuote(path.toString())) + } + + return this.runInNewStackTraceContext(() => + this.runCmd(args.join(' '), { user: options?.user }) + ) + } + + setStartCmd( + startCommand: string, + readyCommand: string | ReadyCmd + ): TemplateFinal { + this.startCmd = startCommand + + if (readyCommand instanceof ReadyCmd) { + this.readyCmd = readyCommand.getCmd() + } else { + this.readyCmd = readyCommand + } + + this.collectStackTrace() + return this + } + + setReadyCmd(readyCommand: string | ReadyCmd): TemplateFinal { + if (readyCommand instanceof ReadyCmd) { + this.readyCmd = readyCommand.getCmd() + } else { + this.readyCmd = readyCommand + } + + this.collectStackTrace() + return this + } + + setEnvs(envs: Record): TemplateBuilder { + if (Object.keys(envs).length === 0) { + return this + } + + this.instructions.push({ + type: InstructionType.ENV, + args: Object.entries(envs).flatMap(([key, value]) => [key, value]), + force: this.forceNextLayer, + }) + this.collectStackTrace() + return this + } + + skipCache(): this { + this.forceNextLayer = true + return this + } + + betaDevContainerPrebuild(devcontainerDirectory: string): TemplateBuilder { + if (this.baseTemplate !== 'devcontainer') { + throw new BuildError( + 'Devcontainers can only used in the devcontainer template', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + + return this.runInNewStackTraceContext(() => { + return this.runCmd( + `devcontainer build --workspace-folder ${shellQuote(devcontainerDirectory)}`, + { user: 'root' } + ) + }) + } + + betaSetDevContainerStart(devcontainerDirectory: string): TemplateFinal { + if (this.baseTemplate !== 'devcontainer') { + throw new BuildError( + 'Devcontainers can only used in the devcontainer template', + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + + return this.runInNewStackTraceContext(() => { + const dir = shellQuote(devcontainerDirectory) + return this.setStartCmd( + `sudo devcontainer up --workspace-folder ${dir} && sudo /prepare-exec.sh ${dir} | sudo tee /devcontainer.sh > /dev/null && sudo chmod +x /devcontainer.sh && sudo touch /devcontainer.up`, + waitForFile('/devcontainer.up') + ) + }) + } + + /** + * Collect the current stack trace for debugging purposes. + * + * @param stackTracesDepth Depth to traverse in the call stack + * @returns this for method chaining + */ + private collectStackTrace(stackTracesDepth: number = STACK_TRACE_DEPTH) { + if (!this.stackTracesEnabled) { + return this + } + + if (this.stackTracesOverride) { + this.stackTraces.push(this.stackTracesOverride) + return this + } + + this.stackTraces.push(getCallerFrame(stackTracesDepth)) + return this + } + + /** + * Temporarily disable stack trace collection. + * + * @returns this for method chaining + */ + private disableStackTrace() { + this.stackTracesEnabled = false + return this + } + + /** + * Re-enable stack trace collection. + * + * @returns this for method chaining + */ + private enableStackTrace() { + this.stackTracesEnabled = true + return this + } + + /** + * Execute a function in a clean stack trace context. + * + * @param fn Function to execute + * @returns The result of the function + */ + private runInNewStackTraceContext(fn: () => T): T { + this.disableStackTrace() + let result: T + try { + result = fn() + } finally { + this.enableStackTrace() + } + this.collectStackTrace(STACK_TRACE_DEPTH + 1) + return result + } + + private runInStackTraceOverrideContext( + fn: () => T, + stackTraceOverride: string | undefined + ): T { + this.stackTracesOverride = stackTraceOverride + try { + return fn() + } finally { + this.stackTracesOverride = undefined + } + } + + /** + * Convert the template to JSON representation. + * + * @param computeHashes Whether to compute file hashes for COPY instructions + * @returns JSON string representation of the template + */ + private async toJSON(computeHashes: boolean): Promise { + let instructions = this.instructions + if (computeHashes) { + instructions = await this.instructionsWithHashes() + } + + return JSON.stringify(this.serialize(instructions), undefined, 2) + } + + /** + * Convert the template to Dockerfile format. + * + * Note: Only templates based on Docker images can be converted to Dockerfile. + * Templates based on other E2B templates cannot be converted because they + * may use features not available in standard Dockerfiles. + * + * @returns Dockerfile string representation + * @throws Error if template is based on another E2B template or has no base image + */ + private toDockerfile(): string { + if (this.baseTemplate !== undefined) { + throw new Error( + 'Cannot convert template built from another template to Dockerfile. ' + + 'Templates based on other templates can only be built using the E2B API.' + ) + } + + if (this.baseImage === undefined) { + throw new Error('No base image specified for template') + } + + let dockerfile = `FROM ${this.baseImage}\n` + for (const instruction of this.instructions) { + if (instruction.type === InstructionType.RUN) { + dockerfile += `RUN ${instruction.args[0]}\n` + continue + } + if (instruction.type === InstructionType.COPY) { + dockerfile += `COPY ${instruction.args[0]} ${instruction.args[1]}\n` + continue + } + if (instruction.type === InstructionType.ENV) { + const values: string[] = [] + for (let i = 0; i < instruction.args.length; i += 2) { + values.push(`${instruction.args[i]}=${instruction.args[i + 1]}`) + } + dockerfile += `ENV ${values.join(' ')}\n` + continue + } + dockerfile += `${instruction.type} ${instruction.args.join(' ')}\n` + } + if (this.startCmd) { + dockerfile += `ENTRYPOINT ${this.startCmd}\n` + } + return dockerfile + } + + /** + * Internal implementation of the template build process. + * + * @param client API client for communicating with E2B backend + * @param name Template name in 'name' or 'name:tag' format + * @param tags Additional tags to assign to the build + * @param options Build configuration options + * @throws BuildError if the build fails + */ + private async build( + client: ApiClient, + config: ConnectionConfig, + name: string, + options: Omit + ): Promise { + if (options.skipCache) { + this.force = true + } + + // Create template + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Requesting build for template: ${name}${options.tags && options.tags.length > 0 ? ` with tags ${options.tags.join(', ')}` : ''}` + ) + ) + + const { + templateID, + buildID, + tags: responseTags, + } = await requestBuild( + client, + { + name, + tags: options.tags, + cpuCount: options.cpuCount ?? 2, + memoryMB: options.memoryMB ?? 1024, + }, + config.getSignal(undefined, options.signal) + ) + + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Template created with ID: ${templateID}, Build ID: ${buildID}` + ) + ) + + const instructionsWithHashes = await this.instructionsWithHashes() + + // Upload files in parallel + const uploadPromises = instructionsWithHashes.map( + async (instruction, index) => { + if (instruction.type !== InstructionType.COPY) { + return + } + + const src = instruction.args.length > 0 ? instruction.args[0] : null + const filesHash = instruction.filesHash ?? null + if (src === null || filesHash === null) { + throw new Error('Source path and files hash are required') + } + + const forceUpload = instruction.forceUpload + let stackTrace = undefined + if (index + 1 >= 0 && index + 1 < this.stackTraces.length) { + stackTrace = this.stackTraces[index + 1] + } + + const { present, url } = await getFileUploadLink( + client, + { + templateID, + filesHash, + }, + stackTrace, + config.getSignal(undefined, options.signal) + ) + + if ( + (forceUpload && url != null) || + (present === false && url != null) + ) { + await uploadFile( + { + fileName: src, + fileContextPath: this.fileContextPath.toString(), + url, + ignorePatterns: [ + ...this.fileIgnorePatterns, + ...readDockerignore(this.fileContextPath.toString()), + ], + resolveSymlinks: instruction.resolveSymlinks ?? RESOLVE_SYMLINKS, + gzip: instruction.gzip ?? GZIP, + }, + stackTrace, + // Forward `requestTimeoutMs` only when the caller set it — we + // never want to slap the 60s default on a multi-hundred-MB S3 + // upload, but a user-set per-build timeout should govern the + // whole operation, including uploads. + { + signal: options.signal, + requestTimeoutMs: options.requestTimeoutMs, + } + ) + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', `Uploaded '${src}'`) + ) + } else { + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Skipping upload of '${src}', already cached` + ) + ) + } + } + ) + + await Promise.all(uploadPromises) + + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'All file uploads completed') + ) + + // Start build + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'Starting building...') + ) + + await triggerBuild( + client, + { + templateID, + buildID, + template: this.serialize(instructionsWithHashes), + }, + config.getSignal(undefined, options.signal) + ) + + return { + alias: name, + name: name, + tags: responseTags, + templateId: templateID, + buildId: buildID, + } + } + + /** + * Add file hashes to COPY instructions for cache invalidation. + * + * @returns Copy of instructions array with filesHash added to COPY instructions + */ + private async instructionsWithHashes(): Promise { + return Promise.all( + this.instructions.map(async (instruction, index) => { + if (instruction.type !== InstructionType.COPY) { + return instruction + } + + const src = instruction.args.length > 0 ? instruction.args[0] : null + const dest = instruction.args.length > 1 ? instruction.args[1] : null + if (src === null || dest === null) { + throw new Error('Source path and destination path are required') + } + + let stackTrace = undefined + if (index + 1 >= 0 && index + 1 < this.stackTraces.length) { + stackTrace = this.stackTraces[index + 1] + } + + return { + ...instruction, + filesHash: await calculateFilesHash( + src, + dest, + this.fileContextPath.toString(), + [ + ...this.fileIgnorePatterns, + ...(runtime === 'browser' + ? [] + : readDockerignore(this.fileContextPath.toString())), + ], + instruction.resolveSymlinks ?? RESOLVE_SYMLINKS, + stackTrace + ), + } + }) + ) + } + + /** + * Serialize the template to the API request format. + * + * @param steps Array of build instructions with file hashes + * @returns Template data formatted for the API + */ + private serialize(steps: Instruction[]): TriggerBuildTemplate { + const templateData: TriggerBuildTemplate = { + startCmd: this.startCmd, + readyCmd: this.readyCmd, + steps, + force: this.force, + } + + if (this.baseImage !== undefined) { + templateData.fromImage = this.baseImage + } + + if (this.baseTemplate !== undefined) { + templateData.fromTemplate = this.baseTemplate + } + + if (this.registryConfig !== undefined) { + templateData.fromImageRegistry = this.registryConfig + } + + return templateData + } +} + +/** + * Create a new E2B template builder instance. + * + * @param options Optional configuration for the template builder + * @returns A new template builder instance + * + * @example + * ```ts + * import { Template } from 'e2b' + * + * const template = Template() + * .fromPythonImage('3') + * .copy('requirements.txt', '/app/') + * .pipInstall() + * + * await Template.build(template, 'my-python-app:v1.0') + * ``` + */ +export function Template(options?: TemplateOptions): TemplateFromImage { + return new TemplateBase(options) +} + +Template.build = TemplateBase.build +Template.buildInBackground = TemplateBase.buildInBackground +Template.getBuildStatus = TemplateBase.getBuildStatus +Template.exists = TemplateBase.exists +Template.aliasExists = TemplateBase.aliasExists +Template.assignTags = TemplateBase.assignTags +Template.removeTags = TemplateBase.removeTags +Template.getTags = TemplateBase.getTags +Template.toJSON = TemplateBase.toJSON +Template.toDockerfile = TemplateBase.toDockerfile + +export type { + BuildInfo, + BuildOptions, + BuildStatusReason, + CopyItem, + GetBuildStatusOptions, + McpServerName, + TemplateBuilder, + TemplateBuildStatus, + TemplateBuildStatusResponse, + TemplateClass, + TemplateTag, + TemplateTagInfo, +} from './types' diff --git a/compat/e2b/upstream.lock.json b/compat/e2b/upstream.lock.json new file mode 100644 index 00000000..11bd5453 --- /dev/null +++ b/compat/e2b/upstream.lock.json @@ -0,0 +1,153 @@ +{ + "schema_version": 1, + "compatibility": { + "id": "e2b-2026-07-14", + "version": "3.0.10-preview.1", + "control_plane_tags": [ + "auth", + "sandboxes", + "snapshots", + "tags", + "templates", + "volumes" + ] + }, + "sources": { + "code-interpreter": { + "repository": "https://github.com/e2b-dev/code-interpreter", + "commit": "5aeca43fe3fae2df260b1fb17c71fed5b5dac852", + "packages": { + "python": "2.8.1", + "typescript": "2.6.1" + } + }, + "e2b": { + "repository": "https://github.com/e2b-dev/e2b", + "commit": "423a1b73025ce871d9b9bfe338396c6b316be845", + "packages": { + "python": "2.32.0", + "typescript": "2.33.0" + } + } + }, + "artifacts": [ + { + "id": "python-code-interpreter-wheel", + "source": "code-interpreter", + "language": "python", + "package": "e2b-code-interpreter", + "version": "2.8.1", + "url": "https://files.pythonhosted.org/packages/df/47/c9acfeb0a63925d5b2e782b496be1287665bb649eee48d947fc46b9bdeee/e2b_code_interpreter-2.8.1-py3-none-any.whl", + "sha256": "sha256:8449ff1abb507e4c28134c9cbc4e59f76c09fadceab879c8c4031399b31a126a" + }, + { + "id": "python-e2b-wheel", + "source": "e2b", + "language": "python", + "package": "e2b", + "version": "2.32.0", + "url": "https://files.pythonhosted.org/packages/74/70/e8fb37a38eaa9eae74b7cf47e0a533fc05bd1801fe1438e835760ae93085/e2b-2.32.0-py3-none-any.whl", + "sha256": "sha256:0f1971f8e287aa717ad3e44c2cbe26753da97acf34da24225b07675a07c57300" + }, + { + "id": "typescript-code-interpreter-tarball", + "source": "code-interpreter", + "language": "typescript", + "package": "@e2b/code-interpreter", + "version": "2.6.1", + "url": "https://registry.npmjs.org/@e2b/code-interpreter/-/code-interpreter-2.6.1.tgz", + "sha256": "sha256:df9350312e46f6f6c4f62004da528fd15176078b3027b4507f7358a6eab0fe57", + "integrity": "sha512-5sKJaw2w/XZNHq7NpcNm8cpGu3IHlwLjavRd6V5BsVQyyl+5zGnSWE+EvA6W1VHDbPXiSdm6pN8cREWAifdrJw==" + }, + { + "id": "typescript-e2b-tarball", + "source": "e2b", + "language": "typescript", + "package": "e2b", + "version": "2.33.0", + "url": "https://registry.npmjs.org/e2b/-/e2b-2.33.0.tgz", + "sha256": "sha256:53581eae5f11efb4b1020b64c05d7e8fc46fe3e14b5172322b5feb35c262e579", + "integrity": "sha512-n6W0nsJMetz8m30sLPMYfliPTW0VpuaDTUOjB0ZdYZ87li4zyX0UQqeQ5S/YPvq8EX9zEYcy3SijBxn1FGFlkQ==" + } + ], + "files": [ + { + "local_path": "spec/code-interpreter/LICENSE", + "source": "code-interpreter", + "source_path": "LICENSE", + "sha256": "sha256:c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" + }, + { + "local_path": "spec/code-interpreter/public-exports/python-init.py", + "source": "code-interpreter", + "source_path": "python/e2b_code_interpreter/__init__.py", + "sha256": "sha256:340d49fe2ceb40c352204d7dbfaa7c940d346914e983aed4e41d59010bbd39da" + }, + { + "local_path": "spec/code-interpreter/public-exports/typescript-index.ts", + "source": "code-interpreter", + "source_path": "js/src/index.ts", + "sha256": "sha256:0bf39f20b44f8a618196d82e8a26d6b6309e05b9055b17c26b1317754e814c1f" + }, + { + "local_path": "spec/e2b/LICENSE", + "source": "e2b", + "source_path": "LICENSE", + "sha256": "sha256:b4ef1bf811cb4095229fb86b574199e467c78f0ac4078cf8be62189e1fbd0818" + }, + { + "local_path": "spec/e2b/envd/envd.yaml", + "source": "e2b", + "source_path": "spec/envd/envd.yaml", + "sha256": "sha256:0e0b41036eb8a99de8e37d16d30b9fbad2fb32024bfb500d4b6681d253240caf" + }, + { + "local_path": "spec/e2b/envd/filesystem/filesystem.proto", + "source": "e2b", + "source_path": "spec/envd/filesystem/filesystem.proto", + "sha256": "sha256:9669e25b5ce244df6b543fdfbaa9e5855d04b43d914d4ce032a98ab3b5714dcf" + }, + { + "local_path": "spec/e2b/envd/process/process.proto", + "source": "e2b", + "source_path": "spec/envd/process/process.proto", + "sha256": "sha256:481fb7dab1bbafc78b84d26d06f14fa3d4314bdbffa4cd9ef9cbab586ee17402" + }, + { + "local_path": "spec/e2b/mcp-server.json", + "source": "e2b", + "source_path": "spec/mcp-server.json", + "sha256": "sha256:82457dc19eb9c7ae29ed66034ac9d763e92f5fa48f9398910d0e1dbf8b06e70e" + }, + { + "local_path": "spec/e2b/openapi-volumecontent.yml", + "source": "e2b", + "source_path": "spec/openapi-volumecontent.yml", + "sha256": "sha256:73e7829e9e5c06acd878d0929d20622e217d22f900a79c3d024b5f834d892933" + }, + { + "local_path": "spec/e2b/openapi.yml", + "source": "e2b", + "source_path": "spec/openapi.yml", + "sha256": "sha256:cea884caee4391153e2056fb1fe9acf691e4887cd990c3a0419a639a8e8a0aac" + }, + { + "local_path": "spec/e2b/public-exports/python-init.py", + "source": "e2b", + "source_path": "packages/python-sdk/e2b/__init__.py", + "sha256": "sha256:72dc3e2bbcf4f2079390b438afe4e336078aac8414a38aec12a2194ad813c091" + }, + { + "local_path": "spec/e2b/public-exports/typescript-index.ts", + "source": "e2b", + "source_path": "packages/js-sdk/src/index.ts", + "sha256": "sha256:dd033b55ee158884a52c1cc3a6d878546a6d1db5dce9e2625b8bc5f088ca7199" + }, + { + "local_path": "spec/e2b/public-exports/typescript-template-index.ts", + "source": "e2b", + "source_path": "packages/js-sdk/src/template/index.ts", + "sha256": "sha256:dba54f66606659642937d3f60b6d96c3accbdc51d0005004ce598cd8ce80ef30" + } + ] +} diff --git a/deploy/e2b/Dockerfile b/deploy/e2b/Dockerfile new file mode 100644 index 00000000..acfdb521 --- /dev/null +++ b/deploy/e2b/Dockerfile @@ -0,0 +1,123 @@ +# syntax=docker/dockerfile:1.7 + +ARG GO_IMAGE=golang:1.26.3-bookworm +ARG RUNTIME_IMAGE=node:20-bookworm-slim + +FROM ${GO_IMAGE} AS envd-builder + +ARG TARGETARCH +ARG E2B_INFRA_COMMIT=fda7bef1095afb909197e272c0a8a123797f0bfb + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* +RUN git init /src/infra \ + && git -C /src/infra remote add origin https://github.com/e2b-dev/infra.git \ + && git -C /src/infra fetch --depth=1 origin "${E2B_INFRA_COMMIT}" \ + && git -C /src/infra checkout --detach FETCH_HEAD \ + && test "$(git -C /src/infra rev-parse HEAD)" = "${E2B_INFRA_COMMIT}" \ + && grep -F 'const Version = "0.6.9"' /src/infra/packages/envd/pkg/version.go +WORKDIR /src/infra/packages/envd +RUN CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" \ + go build -trimpath -buildvcs=false -a \ + -ldflags "-X=main.commitSHA=${E2B_INFRA_COMMIT} -s -w -buildid=" \ + -o /out/envd . + +FROM debian:bookworm-slim AS code-interpreter-source + +ARG CODE_INTERPRETER_COMMIT=5aeca43fe3fae2df260b1fb17c71fed5b5dac852 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* +RUN git init /src/code-interpreter \ + && git -C /src/code-interpreter remote add origin https://github.com/e2b-dev/code-interpreter.git \ + && git -C /src/code-interpreter fetch --depth=1 origin "${CODE_INTERPRETER_COMMIT}" \ + && git -C /src/code-interpreter checkout --detach FETCH_HEAD \ + && test "$(git -C /src/code-interpreter rev-parse HEAD)" = "${CODE_INTERPRETER_COMMIT}" + +FROM ${RUNTIME_IMAGE} + +ARG E2B_INFRA_COMMIT=fda7bef1095afb909197e272c0a8a123797f0bfb +ARG CODE_INTERPRETER_COMMIT=5aeca43fe3fae2df260b1fb17c71fed5b5dac852 +ARG IJAVASCRIPT_COMMIT=79cb7d56dcfe0df9ff55eff0ca4dc920a6dcc361 + +LABEL org.opencontainers.image.title="A3S Box E2B runtime template" \ + org.opencontainers.image.description="Pinned envd and Code Interpreter services for A3S Box Sandboxes" \ + org.opencontainers.image.source="https://github.com/A3S-Lab/Box" \ + org.opencontainers.image.licenses="Apache-2.0" \ + io.a3s.e2b.infra.commit="${E2B_INFRA_COMMIT}" \ + io.a3s.e2b.code-interpreter.commit="${CODE_INTERPRETER_COMMIT}" \ + io.a3s.e2b.envd.version="0.6.9" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN printf 'Acquire::Retries "5";\nAcquire::https::Timeout "30";\n' \ + >/etc/apt/apt.conf.d/80-a3s-retries \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + bash build-essential ca-certificates curl git python3 python3-pip python3-venv \ + sudo tini util-linux \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/opt/a3s/e2b/jupyter/bin:/opt/a3s/e2b/code-interpreter/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +RUN python3 -m venv /opt/a3s/e2b/jupyter \ + && /opt/a3s/e2b/jupyter/bin/pip install \ + e2b-charts==1.0.0 \ + ipykernel==6.31.0 \ + ipython==9.15.0 \ + jupyter-server==2.20.0 \ + matplotlib==3.10.9 \ + numpy==2.3.5 \ + orjson==3.11.9 \ + pandas==2.2.3 \ + pillow==12.2.0 \ + && /opt/a3s/e2b/jupyter/bin/ipython kernel install --name python3 --prefix /usr/local \ + && npm install --global --unsafe-perm \ + "git+https://github.com/e2b-dev/ijavascript.git#${IJAVASCRIPT_COMMIT}" \ + && ijsinstall --install=global + +COPY --from=envd-builder /out/envd /usr/local/bin/envd +COPY --from=code-interpreter-source /src/code-interpreter/template/server/ \ + /opt/a3s/e2b/code-interpreter/ +COPY --from=code-interpreter-source /src/code-interpreter/template/jupyter_server_config.py \ + /opt/a3s/e2b/config/jupyter_server_config.py +COPY --from=code-interpreter-source /src/code-interpreter/template/ipython_kernel_config.py \ + /opt/a3s/e2b/config/ipython_kernel_config.py +COPY --from=code-interpreter-source /src/code-interpreter/template/startup_scripts/ \ + /opt/a3s/e2b/config/startup/ +COPY --from=code-interpreter-source /src/code-interpreter/LICENSE \ + /usr/share/licenses/e2b-code-interpreter/LICENSE +COPY --from=envd-builder /src/infra/LICENSE /usr/share/licenses/e2b-infra/LICENSE + +RUN python3 -m venv /opt/a3s/e2b/code-interpreter/.venv \ + && /opt/a3s/e2b/code-interpreter/.venv/bin/pip install \ + -r /opt/a3s/e2b/code-interpreter/requirements.txt \ + && useradd --create-home --shell /bin/bash user \ + && install -d -o user -g user \ + /home/user/.ipython/profile_default/startup \ + /home/user/.jupyter \ + && cp /opt/a3s/e2b/config/jupyter_server_config.py \ + /home/user/.jupyter/jupyter_server_config.py \ + && cp /opt/a3s/e2b/config/ipython_kernel_config.py \ + /home/user/.ipython/profile_default/ipython_kernel_config.py \ + && cp /opt/a3s/e2b/config/startup/* \ + /home/user/.ipython/profile_default/startup/ \ + && chown -R user:user /home/user \ + && printf 'user ALL=(ALL) NOPASSWD: ALL\n' >/etc/sudoers.d/a3s-box-user \ + && chmod 0440 /etc/sudoers.d/a3s-box-user \ + && ln -s /usr/bin/python3 /usr/local/bin/python \ + && envd -version | grep -Fx '0.6.9' + +COPY deploy/e2b/init-envd.py /usr/local/lib/a3s-box-e2b/init-envd.py +COPY deploy/e2b/entrypoint.sh /usr/local/bin/a3s-box-e2b-entrypoint + +RUN chmod 0755 /usr/local/bin/a3s-box-e2b-entrypoint \ + && chmod 0644 /usr/local/lib/a3s-box-e2b/init-envd.py + +WORKDIR /home/user +EXPOSE 49983 49999 +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/a3s-box-e2b-entrypoint"] diff --git a/deploy/e2b/README.md b/deploy/e2b/README.md new file mode 100644 index 00000000..6d807d38 --- /dev/null +++ b/deploy/e2b/README.md @@ -0,0 +1,45 @@ +# E2B Runtime OCI Template + +This directory defines the OCI image used by an A3S Box template whose envd +service runs inside a `--isolation sandbox` execution. The image is built by +GitHub Actions; it is not intended to be built on developer workstations. + +The build pins: + +- E2B infra commit `fda7bef1095afb909197e272c0a8a123797f0bfb` and envd `0.6.9`; +- Code Interpreter commit `5aeca43fe3fae2df260b1fb17c71fed5b5dac852`; +- the JavaScript kernel commit declared in the Dockerfile. + +The image waits for Jupyter and the Code Interpreter service on port `49999` +before it starts envd on port `49983` and initializes envd's default user and +working directory. A production ACL template selects the in-Sandbox daemon +explicitly: + +```acl +template_policy "code-interpreter-v1" { + image = "ghcr.io/a3s-lab/box-e2b-runtime:" + envd_version = "0.6.9" + envd_mode = "runtime" + isolation = "sandbox" + + resources { + vcpus = 2 + memory_mb = 2048 + disk_mb = 8192 + } + + route { + port = 49983 + token_scope = "envd" + } + + route { + port = 49999 + token_scope = "traffic" + } +} +``` + +Use an immutable image tag or digest in production. Edge access tokens are +validated by the A3S compatibility gateway and are not forwarded into the +Sandbox service. diff --git a/deploy/e2b/entrypoint.sh b/deploy/e2b/entrypoint.sh new file mode 100644 index 00000000..9a325fb5 --- /dev/null +++ b/deploy/e2b/entrypoint.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -euo pipefail + +children=() + +terminate() { + if ((${#children[@]})); then + kill -TERM "${children[@]}" 2>/dev/null || true + wait "${children[@]}" 2>/dev/null || true + fi +} +trap terminate EXIT INT TERM + +wait_for_service() { + local name="$1" + local url="$2" + local pid="$3" + + for attempt in {1..150}; do + if curl --fail --silent --output /dev/null "${url}"; then + return 0 + fi + if ! kill -0 "${pid}" 2>/dev/null; then + local status=0 + wait "${pid}" || status=$? + echo "${name} exited before becoming healthy with status ${status}" >&2 + if ((status == 0)); then + status=1 + fi + return "${status}" + fi + sleep 0.2 + done + + echo "${name} did not become healthy within 30 seconds" >&2 + return 1 +} + +runuser -u user -- env \ + HOME=/home/user \ + PATH="${PATH}" \ + /opt/a3s/e2b/jupyter/bin/jupyter server \ + --IdentityProvider.token= \ + --ServerApp.root_dir=/home/user & +children+=("$!") +jupyter_pid="$!" + +wait_for_service "Jupyter" "http://127.0.0.1:8888/api/status" "${jupyter_pid}" + +runuser -u user -- env \ + HOME=/home/user \ + PATH="${PATH}" \ + /opt/a3s/e2b/code-interpreter/.venv/bin/uvicorn \ + main:app \ + --app-dir /opt/a3s/e2b/code-interpreter \ + --host 0.0.0.0 \ + --port 49999 \ + --workers 1 \ + --no-access-log \ + --no-use-colors \ + --timeout-keep-alive 640 & +children+=("$!") +code_interpreter_pid="$!" + +wait_for_service \ + "Code Interpreter" \ + "http://127.0.0.1:49999/health" \ + "${code_interpreter_pid}" + +/usr/local/bin/envd -isnotfc -no-cgroups & +children+=("$!") + +/usr/bin/python3 /usr/local/lib/a3s-box-e2b/init-envd.py + +set +e +wait -n "${children[@]}" +status=$? +set -e +echo "An E2B runtime service exited with status ${status}" >&2 +exit "${status}" diff --git a/deploy/e2b/init-envd.py b/deploy/e2b/init-envd.py new file mode 100644 index 00000000..8d34abb7 --- /dev/null +++ b/deploy/e2b/init-envd.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +import json +import os +import time +import urllib.error +import urllib.request +from collections.abc import Mapping + + +ENVD_URL = "http://127.0.0.1:49983" +INTERNAL_ENVIRONMENT = { + "A3S_BOOTSTRAP_MODE", + "A3S_EXEC_LISTENER_FD", + "A3S_INIT_LOG_FD", + "A3S_PTY_LISTENER_FD", + "HOSTNAME", + "PWD", + "SHLVL", + "_", +} +DEFAULT_USER_ENVIRONMENT = { + "HOME": "/home/user", + "LOGNAME": "user", + "SHELL": "/bin/bash", + "USER": "user", +} + + +def build_environment(source: Mapping[str, str]) -> dict[str, str]: + environment = { + key: value + for key, value in source.items() + if key not in INTERNAL_ENVIRONMENT and key not in DEFAULT_USER_ENVIRONMENT + } + environment.update(DEFAULT_USER_ENVIRONMENT) + return environment + + +def wait_for_envd() -> None: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"{ENVD_URL}/health", timeout=1) as response: + if 200 <= response.status < 300: + return + except (OSError, urllib.error.URLError): + pass + time.sleep(0.05) + raise TimeoutError("envd did not become healthy within 30 seconds") + + +def initialize_envd() -> None: + environment = build_environment(os.environ) + body = json.dumps( + { + "defaultUser": "user", + "defaultWorkdir": "/home/user", + "envVars": environment, + }, + separators=(",", ":"), + ).encode() + request = urllib.request.Request( + f"{ENVD_URL}/init", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=10) as response: + if response.status != 204: + raise RuntimeError(f"envd initialization returned HTTP {response.status}") + + +if __name__ == "__main__": + wait_for_envd() + initialize_envd() diff --git a/deploy/e2b/test_init_envd.py b/deploy/e2b/test_init_envd.py new file mode 100644 index 00000000..efe6b1aa --- /dev/null +++ b/deploy/e2b/test_init_envd.py @@ -0,0 +1,35 @@ +import importlib.util +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("init-envd.py") +SPEC = importlib.util.spec_from_file_location("a3s_box_init_envd", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +INIT_ENVD = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(INIT_ENVD) + + +class BuildEnvironmentTests(unittest.TestCase): + def test_default_user_identity_replaces_root_process_values(self) -> None: + environment = INIT_ENVD.build_environment( + { + "HOME": "/root", + "LOGNAME": "root", + "PATH": "/usr/bin:/bin", + "PWD": "/root", + "SHELL": "/bin/sh", + "USER": "root", + } + ) + + self.assertEqual(environment["HOME"], "/home/user") + self.assertEqual(environment["LOGNAME"], "user") + self.assertEqual(environment["SHELL"], "/bin/bash") + self.assertEqual(environment["USER"], "user") + self.assertEqual(environment["PATH"], "/usr/bin:/bin") + self.assertNotIn("PWD", environment) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/helm/a3s-box/Chart.yaml b/deploy/helm/a3s-box/Chart.yaml index c8042982..8bc9a340 100644 --- a/deploy/helm/a3s-box/Chart.yaml +++ b/deploy/helm/a3s-box/Chart.yaml @@ -10,9 +10,9 @@ keywords: - cri - tee - confidential-computing -home: https://github.com/AI45Lab/Box +home: https://github.com/A3S-Lab/Box sources: - - https://github.com/AI45Lab/Box + - https://github.com/A3S-Lab/Box maintainers: - name: A3S Lab url: https://github.com/A3S-Lab diff --git a/deploy/scripts/install-runtimeclass.sh b/deploy/scripts/install-runtimeclass.sh index bd88dacd..9fbeb27c 100755 --- a/deploy/scripts/install-runtimeclass.sh +++ b/deploy/scripts/install-runtimeclass.sh @@ -16,8 +16,8 @@ # Usage: # install-runtimeclass.sh [--version vX.Y.Z] [--repo OWNER/REPO] [--from-dir DIR] # -# --version release tag to install (default: v2.6.0) -# --repo GitHub repo to download artifacts from (default: AI45Lab/Box) +# --version release tag to install (default: v3.0.2) +# --repo GitHub repo to download artifacts from (default: A3S-Lab/Box) # --from-dir install from a local directory instead of downloading; the dir must # contain a3s-box--linux-.tar.gz and a containerd shim # binary (containerd-shim-a3s-box-v2[-linux-]). @@ -25,8 +25,8 @@ # Idempotent: safe to re-run (re-installs binaries, rewrites the containerd drop-in). set -euo pipefail -VERSION="v2.6.0" -REPO="AI45Lab/Box" +VERSION="v3.0.2" +REPO="A3S-Lab/Box" FROM_DIR="" WARMUP_IMAGE="busybox:latest" # first box on a fresh node builds a one-time cache # (~40s+); booting one here primes it so the first diff --git a/docs/ANNOUNCEMENT-v2.1.0.md b/docs/ANNOUNCEMENT-v2.1.0.md index 59432db6..a96ae36e 100644 --- a/docs/ANNOUNCEMENT-v2.1.0.md +++ b/docs/ANNOUNCEMENT-v2.1.0.md @@ -1,6 +1,6 @@ # A3S Box v2.1.0:虚拟机的隔离,容器的启动速度 -> 发布日期:2026-06-13 · 仓库:[AI45Lab/Box](https://github.com/AI45Lab/Box) +> 发布日期:2026-06-13 · 仓库:[A3S-Lab/Box](https://github.com/A3S-Lab/Box) 长期以来,运行不可信代码只有两个糟糕的选择:**容器**快、密度高,但和宿主机共享同一个内核,隔离薄弱;**虚拟机**有自己的内核、隔离过硬,却启动慢、扛不住规模化。A3S Box v2.1.0 把这个取舍彻底拆掉了——让每个工作负载拥有**自己的真实内核**(虚拟机级别的隔离),同时获得**容器级别的启动速度与密度**。 @@ -12,7 +12,7 @@ ## A3S Box 是什么 -A3S Box 是一个类 Docker 的 MicroVM 运行时(开源,仓库 `AI45Lab/Box`)。它把每个 Linux OCI 工作负载跑在自己的 libkrun MicroVM 里(**一个 box 一个真实内核**),并提供: +A3S Box 是一个类 Docker 的 MicroVM 运行时(开源,仓库 `A3S-Lab/Box`)。它把每个 Linux OCI 工作负载跑在自己的 libkrun MicroVM 里(**一个 box 一个真实内核**),并提供: - Docker 式 CLI(`run` / `build` / `exec` / `logs` / `compose`) - OCI 镜像存储 @@ -140,7 +140,7 @@ a3s-box info ## 链接 -- **发布页**:https://github.com/AI45Lab/Box/releases/tag/v2.1.0 -- **仓库**:https://github.com/AI45Lab/Box +- **发布页**:https://github.com/A3S-Lab/Box/releases/tag/v2.1.0 +- **仓库**:https://github.com/A3S-Lab/Box 虚拟机的隔离,容器的速度。现在就 `brew install a3s-lab/tap/a3s-box`,跑一条 `a3s-box info` 看看你的宿主机能解锁什么。 diff --git a/docs/ANNOUNCEMENT-v2.2.0.md b/docs/ANNOUNCEMENT-v2.2.0.md index 6b011d7a..6a2fbc3d 100644 --- a/docs/ANNOUNCEMENT-v2.2.0.md +++ b/docs/ANNOUNCEMENT-v2.2.0.md @@ -1,6 +1,6 @@ # A3S Box v2.2.0:把 v2.1.0 的能力焊死在正确性上 -> 发布日期:2026-06-15 · 仓库:[AI45Lab/Box](https://github.com/AI45Lab/Box) +> 发布日期:2026-06-15 · 仓库:[A3S-Lab/Box](https://github.com/A3S-Lab/Box) v2.1.0 带来了原生 snapshot-fork(虚拟机的隔离 + 容器的启动速度)。v2.2.0 不加新卖点,而是把已有能力**焊死在正确性上**:24 个修复,覆盖 CLI 状态机、运行时资源限制、guest-init 的 I/O、OCI 镜像存储、网络、温池(warm pool),以及 CRI 服务端。没有破坏性变更,CRI 一致性零回归。 diff --git a/docs/WINGET_PUBLISHING.md b/docs/WINGET_PUBLISHING.md index fd1d0946..aaf9a273 100644 --- a/docs/WINGET_PUBLISHING.md +++ b/docs/WINGET_PUBLISHING.md @@ -57,7 +57,7 @@ manifest 文件位于 `.winget/` 目录: # 下载发布资产 $Version = "0.8.0" $Tag = "v$Version" -$Url = "https://github.com/AI45Lab/Box/releases/download/$Tag/a3s-box-$Tag-windows-x86_64.zip" +$Url = "https://github.com/A3S-Lab/Box/releases/download/$Tag/a3s-box-$Tag-windows-x86_64.zip" Invoke-WebRequest -Uri $Url -OutFile "a3s-box.zip" # 计算 SHA256 @@ -125,7 +125,7 @@ NestedInstallerFiles: - RelativeFilePath: a3s-box-v0.8.0-windows-x86_64\lib\krun.dll Installers: - Architecture: x64 - InstallerUrl: https://github.com/AI45Lab/Box/releases/download/v0.8.0/a3s-box-v0.8.0-windows-x86_64.zip + InstallerUrl: https://github.com/A3S-Lab/Box/releases/download/v0.8.0/a3s-box-v0.8.0-windows-x86_64.zip InstallerSha256: Dependencies: WindowsFeatures: diff --git a/docs/WINGET_QUICKSTART.md b/docs/WINGET_QUICKSTART.md index 46e00c26..bf02425c 100644 --- a/docs/WINGET_QUICKSTART.md +++ b/docs/WINGET_QUICKSTART.md @@ -5,14 +5,14 @@ ### 1. 等待 GitHub Release 完成 首先确认 v0.8.0 release 已经完成,Windows 资产已上传: -- 访问: https://github.com/AI45Lab/Box/releases/tag/v0.8.0 +- 访问: https://github.com/A3S-Lab/Box/releases/tag/v0.8.0 - 确认存在: `a3s-box-v0.8.0-windows-x86_64.zip` ### 2. 计算 SHA256 ```powershell # 下载 Windows 发布资产 -$Url = "https://github.com/AI45Lab/Box/releases/download/v0.8.0/a3s-box-v0.8.0-windows-x86_64.zip" +$Url = "https://github.com/A3S-Lab/Box/releases/download/v0.8.0/a3s-box-v0.8.0-windows-x86_64.zip" Invoke-WebRequest -Uri $Url -OutFile "a3s-box-v0.8.0-windows-x86_64.zip" # 计算 SHA256 @@ -39,7 +39,7 @@ $env:GITHUB_TOKEN = "your_github_token_here" #### 方式 B: 使用 GitHub Actions -1. 访问: https://github.com/AI45Lab/Box/actions/workflows/publish-winget.yml +1. 访问: https://github.com/A3S-Lab/Box/actions/workflows/publish-winget.yml 2. 点击 "Run workflow" 3. 输入版本: `0.8.0` 4. 点击 "Run workflow" @@ -106,7 +106,7 @@ $env:GITHUB_TOKEN = "your_github_token_here" - Package: A3SLab.Box - Version: 0.8.0 - - Release: https://github.com/AI45Lab/Box/releases/tag/v0.8.0 + - Release: https://github.com/A3S-Lab/Box/releases/tag/v0.8.0 This is a new package submission for a3s-box, a Docker-like MicroVM runtime that runs natively on Windows through the Windows Hypervisor @@ -160,7 +160,7 @@ winget show A3SLab.Box ```powershell wingetcreate update A3SLab.Box ` -v 0.8.1 ` - -u https://github.com/AI45Lab/Box/releases/download/v0.8.1/a3s-box-v0.8.1-windows-x86_64.zip ` + -u https://github.com/A3S-Lab/Box/releases/download/v0.8.1/a3s-box-v0.8.1-windows-x86_64.zip ` -t YOUR_GITHUB_TOKEN ``` diff --git a/docs/ci-kvm-runner.md b/docs/ci-kvm-runner.md index 7e586ed9..3687bc62 100644 --- a/docs/ci-kvm-runner.md +++ b/docs/ci-kvm-runner.md @@ -40,6 +40,7 @@ variable*: | `KVM_CI` | `true` | **Required.** Activates the `integration-kvm` job. | | `KVM_CI_AGENT_IMAGE` | e.g. `docker.m.daocloud.io/library/alpine:latest` | Sandbox agent image for the CRI smoke (optional; sane default). | | `KVM_CI_REGISTRY_MIRRORS` | e.g. `registry.k8s.io=k8s.m.daocloud.io,gcr.io=gcr.m.daocloud.io` | Registry mirrors for restricted-egress hosts (optional). | +| `KVM_CI_FOREGROUND_MAX_P50_MS` | e.g. `3200` | Maximum cached foreground no-op p50 in milliseconds (optional; defaults to `3200`). Tighten only after calibrating the dedicated runner. | Once `KVM_CI=true` and a runner with the `kvm` label is online, every push to `main`, every PR, every `v*` tag, and manual `workflow_dispatch` runs the real @@ -51,13 +52,17 @@ microVM gate after the cheap `fmt`/`clippy`/`test` jobs pass. 2. Builds the real binaries (`unset A3S_DEPS_STUB`) — `a3s-box`, `a3s-box-cri`, `a3s-box-shim`, plus the static musl `a3s-box-guest-init`. 3. `core_smoke` — boots a real microVM and execs over virtio-fs. -4. `crictl_smoke` (`A3S_BOX_CRI_SMOKE=1`) — the full CRI pod/container lifecycle +4. **Foreground latency gate** (`bench/bench.sh foreground`) — warms the cached + agent image once, records 10 real KVM no-op runs, and fails when p50 exceeds + `KVM_CI_FOREGROUND_MAX_P50_MS`. Docker comparison is intentionally disabled + on this runner; the macOS/HVF ratio remains a separate hardware check. +5. `crictl_smoke` (`A3S_BOX_CRI_SMOKE=1`) — the full CRI pod/container lifecycle (`RunPodSandbox → CreateContainer → StartContainer → exec → Stop → Remove`) driven by real `crictl`. -5. **Leak assertion** (`bench/bench.sh leak`) — runs `CHURN` create/run/remove +6. **Leak assertion** (`bench/bench.sh leak`) — runs `CHURN` create/run/remove cycles and asserts orphan shims, overlay mounts, and box dirs all return to baseline. A resource-leak regression fails the gate. -6. **Race assertion** (`bench/bench.sh race`) — boots `RACE` detached boxes +7. **Race assertion** (`bench/bench.sh race`) — boots `RACE` detached boxes concurrently and asserts `boxes.json` still parses and every successful launch persisted. This is the only check that exercises the cross-process advisory lock (`flock` on `boxes.json.lock`) across separate processes — the diff --git a/docs/e2b-compatible-sdk-design.md b/docs/e2b-compatible-sdk-design.md new file mode 100644 index 00000000..50be868e --- /dev/null +++ b/docs/e2b-compatible-sdk-design.md @@ -0,0 +1,1313 @@ +# E2B Protocol Compatibility and SDK Design + +Status: **Production-tested protocol subset; full compatibility remains gated** + +Implementation evidence starts in [`compat/e2b/`](../compat/e2b/README.md). +The pinned contract manifest intentionally reports `full_compatibility=false`; +the release-level compatibility claim remains gated by every phase below. + +Scope: protocol compatibility, Python and TypeScript SDKs, and the service +boundary required to provide a remote code-execution environment on A3S Box. + +Target: the public E2B SDK contract as observed on 2026-07-14. Compatibility is +pinned by upstream commit and generated protocol descriptors, not by an +unversioned claim. + +## Current implementation evidence and remaining gates + +| Area | Implemented evidence | Remaining gate | +| --- | --- | --- | +| Pinned contract | Vendored control, envd, volume-content, Process, Filesystem, MCP, public-export, and package artifacts with generated digests | Keep the manifest pinned and regenerate it only through reviewed upstream updates | +| Lifecycle protocol | Owner-scoped create, connect, get, memory-preserving pause, connect/resume, v1/v2 running/paused list, timeout, monotonic refresh, kill, and current single/batch metric routes for runtime-envd Sandboxes; unchanged pinned Python sync/async, TypeScript, and Code Interpreter clients pass against both the Rust fixture server and the production service with real `crun` Sandbox executions; requested lifetime begins only after runtime and envd readiness, including startup recovery | Complete templates/builds, filesystem-only pause, network updates, historical metrics, pagination edge cases, and host-reboot recovery | +| Volumes | Owner-scoped create, connect/get, list, and delete use durable SQLite records, encrypted scope-bound tokens, startup reconciliation, and runtime-managed storage; the authenticated volume-content routes implement directory, file, path, and metadata operations with descriptor-relative path safety; all six production clients pass bidirectional Sandbox mounts, UID/GID mapping, public mount metadata, in-use deletion conflicts, and cleanup against real `crun` executions | Complete large-file, concurrent-mutation, service-crash, host-reboot, and negative-path breadth before treating Volume coverage as a standalone compatibility claim | +| Filesystem Snapshots | Owner-scoped capture, source-filtered list, restore, and delete use durable SQLite records, startup reconciliation, generation-fenced runtime operations, quiesced rootfs capture, and copy-on-write restore; all six production clients preserve captured content, Unix ownership/mode, resolved OCI defaults, source liveness, restored writability, in-use conflicts, and final cleanup | Complete named-reference and pagination edge cases, large-rootfs and concurrent-mutation behavior, service-crash and host-reboot recovery, and broader negative-path coverage; this surface captures filesystems, not process memory or device state | +| Durable control state | SQLite WAL migrations, strict record validation, compare-and-swap transitions, generation-fenced expiry claims, startup reconciliation, and periodic reaping are composed into the production service; an A3S OS smoke preserves a running record across process restart | Exercise host-reboot recovery end to end | +| Runtime lifecycle | The production compatibility process uses the canonical `LocalExecutionManager`; A3S OS smoke coverage and unchanged official clients create through HTTP, start through certified `crun`, pause in memory, resume through connect, prove the same process survives, replace timeout, kill, and verify box, runtime-state, and socket cleanup | Complete host-reboot recovery, filesystem-only pause, and the remaining official-client data-plane matrices | +| Sandbox logs | Generation-fenced v1/v2 control routes read bounded current and rotated runtime JSON logs, tolerate a live partial tail, stably order concurrent stdout/stderr entries by timestamp, and implement cursor, direction, level, search, and limit filters; the real-`crun` A3S OS gate validates both response schemas and forward/backward ordering | Exercise retention limits and rotation races under sustained concurrent output in the complete black-box matrix | +| Credentials and routing | ACL config wires salted PBKDF2-SHA256 account hashes, scope-bound AES-256-GCM sandbox tokens, independent HMAC validation, versioned key rotation, strict direct/shared parsing, durable-record-projected generation-fenced leases, wildcard TLS termination, and a generation/PID-fenced Sandbox network-namespace connector | Add certificate rotation and exercise every HTTP/2, Connect, WebSocket, and stream case in the complete matrix | +| envd HTTP | The host broker implements authenticated running/terminal health; runtime-envd templates initialize fail closed and production tests validate `/metrics`, `/envs`, metadata-preserving multipart upload, and octet-stream download through wildcard TLS routing | Complete multi-file, large-file, invalid-path/user, not-found, insufficient-space, and remaining envd edge semantics | +| Commands and SDK surface | The Process broker has generation-scoped synthetic IDs plus Start, JSON-framed Connect, List, SendInput, CloseStdin, SIGKILL, PTY Start/resize, and ordered event streams. Runtime-image official clients cover foreground/background commands, process listing, stdin close, wait, and one PTY resize flow against real `crun` | Complete binary Connect framing, `StreamInput`, SIGTERM and other signals, reconnect/backpressure/cancellation, durable handles, and the exhaustive PTY matrix | +| Filesystem | Runtime-image official clients cover directory creation, write/read, stat, recursive list, rename, exists, and recursive remove through pinned runtime envd; the envd HTTP path separately passes upload/download with metadata | Cover watches, multi-file and large-file behavior, signed URLs, ownership, quota errors, traversal negatives, and host-broker behavior | +| Code Interpreter | Pinned Python sync/async and TypeScript clients execute code and cover context create/list/restart/remove through the immutable runtime image | Cover rich MIME streams, every advertised language, errors, cancellation, callbacks, public routing, and MCP | +| Native SDK packages | Typed Python and TypeScript packages re-export the pinned official implementations, build as release artifacts, and can repeat the production runtime-image matrix after unchanged clients pass | Publish to PyPI/npm only after the full compatibility and release gates pass | + +The lifecycle control path and authenticated wildcard/shared TLS routes are +composed and exercised against real Sandboxes on A3S OS. The production gate +uses the host broker and a traffic-scoped service on Sandbox loopback port +`49999`; runtime-image mode routes running envd traffic to port `49983` inside +the exact fenced Sandbox while terminal health remains host-resolved after +kill. Both modes reject invalid and scope-swapped tokens, survive a +compatibility-service restart, fence workload traffic after kill, and verify +cleanup. + +The production gate runs the checksum-pinned official Python sync/async, +TypeScript, and Code Interpreter packages unchanged against the ACL-configured +service, then repeats the same matrix through the A3S Python sync/async and +TypeScript packages after removing every `E2B_*` connection variable. Both +paths cover lifecycle, health, Filesystem operations, foreground/background +Process operations, stdin, PTY, memory-preserving pause, paused-state listing, +connect-based resume, survival of the same background process, owner-scoped +Volume control/content, bidirectional Sandbox mounts, UID/GID mapping, in-use +deletion conflicts, filesystem Snapshot capture/list/restore/delete after +source termination, Python execution, interpreter contexts, restart recovery, +and cleanup. The same gate validates current control-plane metrics for every +official and A3S client, an empty historical range, v1 running-list behavior, +monotonic refresh, batch metrics, +generation-fenced v1/v2 runtime logs in both ordering directions, envd metrics, +the initialized environment, and metadata-preserving HTTP upload/download. +Current metrics are read through the generation-fenced runtime-envd connection; +`memCache` is reported as zero because the pinned envd metrics response has no +cache-usage field. Historical retention remains open. Remaining control, +filesystem-only pause, deeper Snapshot and Volume failure/recovery, +signed-file, public-port, streaming edge-case, interpreter, and MCP surfaces +are not covered. The recorder fixture continues to use an in-memory repository +and fake execution manager. +This is production evidence for a useful subset, not the full black-box +compatibility matrix, so `full_compatibility=false` remains mandatory. + +## Executive decision + +A3S Box should provide an E2B-compatible control plane and sandbox endpoint so +the official E2B Python and JavaScript SDKs can connect to A3S by changing only +connection configuration such as `E2B_API_URL`, `E2B_DOMAIN`, and credentials. + +A3S should also publish native Python and TypeScript packages with the same +public object model and behavior. Those packages are convenience clients, not +the proof of compatibility. The compatibility gate is an unmodified upstream +SDK running its contract suite against an A3S deployment. + +Delivery is protocol-first. A3S must implement the server contracts before it +implements native convenience SDKs. Forking an upstream SDK, replacing its +transport, adding an A3S-only constructor argument, or requiring application +source changes does not satisfy compatibility. + +`E2B-compatible` is a release-level claim, not a description of an endpoint. +A release may expose an explicitly named preview subset while it is being +built, but it must not claim full compatibility until every public operation +used by the pinned official clients passes the semantic conformance suite. A +response saying that an operation is unsupported does not count when the same +request succeeds on the pinned upstream contract. + +The compatibility service must submit backend-neutral A3S execution requests. +It must never invoke `crun`, libkrun, or a shim directly. Isolation selection, +feature rejection, capability probing, audit records, and cleanup remain owned +by A3S Box. + +## What “fully compatible” means + +The target is the complete public SDK protocol, including: + +1. Control-plane REST/OpenAPI behavior for sandbox lifecycle, listing, + pagination, metadata, metrics, timeout, network policy, snapshots, + templates, and volumes. +2. Per-sandbox envd HTTP behavior for health, metrics, environment, file upload, + and file download. +3. ConnectRPC/Protobuf behavior for commands, process attachment, stdin, + signals, PTY, filesystem metadata, directory operations, and watches. +4. Code Interpreter behavior for code contexts, streamed execution, rich MIME + results, stdout/stderr, errors, and execution counts. +5. Python sync and async APIs, TypeScript APIs, error classes, timeout units, + stream ordering, cancellation, and pagination semantics. +6. Routing, authentication headers, status codes, and response bodies expected + by an unmodified official client. +7. Volume-content upload, download, directory, and path operations from the + separate volume-content OpenAPI contract. +8. Public sandbox ports, traffic tokens, signed file URLs, and the MCP gateway + behavior reached through the generic sandbox routing contract. + +Compatibility does not include private vendor administration endpoints or +undocumented infrastructure internals unless a public SDK calls them. Public +template, snapshot, volume, and access-token operations are in scope. + +Every supported upstream version is recorded in a compatibility manifest: + +```json +{ + "e2b_git_commit": "423a1b73025ce871d9b9bfe338396c6b316be845", + "code_interpreter_git_commit": "5aeca43fe3fae2df260b1fb17c71fed5b5dac852", + "python_e2b_version": "2.32.0", + "typescript_e2b_version": "2.33.0", + "python_code_interpreter_version": "2.8.1", + "typescript_code_interpreter_version": "2.6.1", + "control_openapi_digest": "sha256:...", + "envd_openapi_digest": "sha256:...", + "volume_content_openapi_digest": "sha256:...", + "process_descriptor_digest": "sha256:...", + "filesystem_descriptor_digest": "sha256:...", + "mcp_schema_digest": "sha256:...", + "a3s_compat_version": "..." +} +``` + +The first manifest is generated during implementation from vendored source; +digests must not be copied manually from this design document. + +The manifest identifies a tested version tuple rather than promising +compatibility with all past or future package versions. Adding a version means +running the complete official-client matrix and publishing the result; a +semver range inferred from similar schemas is not sufficient. + +### Release gate + +A release may use the unqualified `E2B-compatible` label only when all of the +following are true for a published compatibility manifest: + +1. The pinned official Python sync, Python async, and TypeScript packages run + unchanged. Only their documented API URL, sandbox domain, and credential + configuration may differ. +2. Every public operation in the manifest has a black-box conformance result + covering its request, response, error, timeout, cancellation, and streaming + semantics. +3. Wildcard sandbox routing, signed file URLs, public ports, access tokens, and + reconnect behavior work through the production TLS edge rather than a + single-sandbox test shortcut. +4. Code Interpreter and MCP fixtures pass through the same generic sandbox + routing and process/filesystem services used in production. +5. No response in the upstream namespace requires an A3S-only field or exposes + an A3S-only error for behavior that succeeds against the pinned contract. + +The conformance report is published per template and isolation profile. A +shared-kernel template cannot inherit a passing MicroVM result. Memory- +preserving pause and resume now have matching observable behavior on the +Sandbox backend, and filesystem Snapshot capture/restore has matching behavior +for the tested subset. Filesystem-only pause and the rest of the pinned +lifecycle surface remain gates. The backend is therefore still reported as a +preview subset rather than fully compatible. + +## Compatibility architecture + +Compatibility is implemented as one versioned product surface with separate +components and ownership boundaries: + +```text +Official or A3S Python/TypeScript SDK + | + TLS edge and route parser + / \ + Control-plane API Sandbox data-plane gateway + | / | | \ + lifecycle store envd user port MCP interpreter + | \ | | / + +----------- ProcessSession + FilesystemSession + | + A3S ExecutionManager + / \ + MicroVM OCI sandbox +``` + +The edge owns public DNS, TLS, CORS, request limits, authentication, and route +normalization. The control plane owns public IDs, lifecycle state, templates, +volumes, tokens, leases, and reconciliation. The data-plane gateway owns the +wire protocols and translates them into backend-neutral process and filesystem +sessions. A backend never parses a public compatibility request. + +The durable lifecycle state machine is generation-fenced: + +```text +creating -> running -> pausing -> paused -> resuming + ^ | + +-----------------------------+ + +creating | running | pausing | paused | resuming -> killing -> killed +``` + +Each transition is idempotent under the exact rules of the pinned control +contract. Route leases and process handles include the sandbox generation so a +late request cannot target a recreated workload with the same external ID. + +## Protocol sources of truth + +| Contract | Pinned source | A3S owner | +| --- | --- | --- | +| Lifecycle, templates, snapshots, auth, and volumes | `spec/openapi.yml`, restricted to public SDK tags | Control plane | +| Volume content | `spec/openapi-volumecontent.yml` | Control plane and volume service | +| envd health, metrics, env, init, and file transfer | `spec/envd/envd.yaml` | Data-plane gateway | +| Process, command, stdin, signal, and PTY | `spec/envd/process/process.proto` | Process session service | +| Filesystem metadata, mutation, and watches | `spec/envd/filesystem/filesystem.proto` | Filesystem session service | +| MCP configuration | `spec/mcp-server.json` | MCP template component | +| Code Interpreter | Pinned official client requests, parsers, server routes, and models | Interpreter template component | + +The pinned Code Interpreter repository does not provide a standalone OpenAPI +document for its streaming service. Its contract fixture must therefore be +generated from both official clients and the pinned template server, including +raw NDJSON chunks and error responses; inventing an OpenAPI approximation is +not a compatibility source of truth. + +## Protocol layers + +### Control plane + +The compatibility control plane implements the public operations used by the +SDKs: + +| Area | Required operations | +| --- | --- | +| Sandbox lifecycle | create, connect/resume, get, list, kill, pause, timeout | +| Observability | health, logs, metrics, pagination | +| Network | get/update egress policy and routed ports | +| Persistence | create/list/delete snapshots | +| Templates | create/build/status/logs/list/get/delete, aliases and tags | +| Volumes | create/list/get/delete plus the separate volume-content API | +| Credentials | API keys and access tokens exposed by the public SDK | + +The service accepts `X-API-Key` and Bearer access tokens using the same +precedence and error behavior as the pinned protocol. A3S credentials are +stored as salted hashes. Raw API keys, access tokens, environment secrets, and +command input must never appear in logs or audit detail fields. + +Account API keys are one-way hashed. Sandbox-scoped envd and traffic tokens +must be returned again by create/connect flows, so they are stored encrypted at +rest under a versioned service key and separately hashed for constant-time +validation. Their ciphertext, plaintext, and hashes are excluded from normal +API objects, diagnostics, and audit payloads except where the pinned response +contract explicitly returns the plaintext token. + +### Sandbox data plane + +The client routes a request to a host derived from sandbox ID and port, such as: + +```text +-. +``` + +The A3S edge proxy terminates TLS, validates the sandbox route and traffic +token, and forwards the request to the sandbox broker. It recognizes the +compatibility headers used by current clients: + +```text +E2b-Sandbox-Id +E2b-Sandbox-Port +X-Access-Token +E2B-Traffic-Access-Token +``` + +`X-Access-Token` protects envd and signed file operations. +`E2B-Traffic-Access-Token` protects user-exposed services such as the code +interpreter. They have different scopes and lifetimes and must never be +collapsed into one credential. + +The proxy must support HTTP/1.1, HTTP/2, WebSocket, Connect JSON, Connect +binary, streaming responses and trailers, half-closed stdin streams, +backpressure, partial frames, and browser CORS preflight. A wildcard DNS record +and wildcard certificate are required for production. Development can use the +explicit API and sandbox URL overrides without changing SDK code. + +Both routing forms present in the pinned protocol are implemented: + +```text +https://sandbox. # shared endpoint plus route headers +https://-. # direct endpoint and arbitrary ports +``` + +Lifecycle responses use the configured public Sandbox authority. It equals the +routing domain on standard HTTPS deployments and may append a public TLS port, +for example `box.example.com:38443`. This lets unchanged clients construct +direct envd, Code Interpreter, MCP, signed-file, and user-service URLs without +any process-global Sandbox URL override. + +The pinned clients automatically select the shared endpoint only for an +upstream allowlist of domains. With a custom A3S domain they select the direct +form, which is also required by `getHost()`, Code Interpreter, MCP, signed file +URLs, and user services. Route parsing must validate the port and sandbox ID +before DNS-derived input reaches the internal router. + +`E2B_SANDBOX_URL` is a fixed URL, not a hostname template. It must not point to +a multi-sandbox shared endpoint in the production compatibility profile: +upload and download URLs produced by the pinned SDK do not carry the route +headers and would lose their sandbox identity. It remains useful for local +single-sandbox fixtures. Shared-endpoint behavior is tested directly with the +route headers, while the official-client production gate uses wildcard direct +routing. + +An official-client smoke test uses configuration only, for example: + +```text +E2B_API_URL=https://api.box.example.com +E2B_DOMAIN=box.example.com +E2B_API_KEY= +``` + +Compatibility API keys must use the lexical form accepted by the pinned +clients' default validation: `e2b_` followed by one or more lowercase +hexadecimal characters. Requiring source patches or a hidden validation +override fails the zero-code-change gate. Native A3S credentials may retain a +separate format outside this compatibility surface. + +### envd-compatible broker + +The envd-compatible path has two explicit modes. The host-side broker is backed +by existing A3S control protocols and provides generation-fenced health for +templates without an embedded envd. Production compatibility templates run the +pinned envd inside the Sandbox; the authenticated gateway connects to its +loopback port through the execution network namespace and strips edge +credentials before forwarding requests. + +The gateway authenticates a `49983` `GET /health` request against the durable +lifecycle record before disclosing state. For a running broker-mode record it +issues a generation-fenced lease, calls `ExecutionManager::inspect`, and +compares the returned execution ID, generation, and `Running` state with that +lease. Exact live evidence returns an empty `204`; runtime evidence that becomes +missing, stopped, or generation-stale returns `502`, while an unavailable +inspector returns `503`. For a killed record, a valid envd token receives the +terminal `502` expected by the official clients without issuing a live lease or +opening a connector; an invalid token remains `401`. + +Before create becomes visible, the runtime image receives a fail-closed +`POST /init` carrying the lifecycle ID, merged environment, timestamp, and +default user. The initialized runtime service implements the production-tested +Process, PTY, Filesystem, and Code Interpreter subset. A3S OS production tests +also validate the pinned `/metrics` schema, create-time environment, multipart +file upload with metadata, octet-stream download, invalid-token rejection, and +cleanup. Multi-file and large-file behavior, signed access, negative paths, and +remaining edge semantics stay explicit release gates. Volume control and +content use the separate durable service described below. +Workload traffic continues through the generation- and PID-fenced +network-namespace connector. + +It translates: + +```text +E2B control/data protocol + | + v +a3s-box-compat service + | + v +A3S ExecutionManager / control transport + | + +-- MicroVM backend + +-- OCI sandbox backend +``` + +The broker owns process IDs exposed to the client and maps them to +backend-specific execution handles. Numeric IDs are generation-fenced so a +restarted workload cannot accidentally receive input or a signal intended for +an earlier process. + +### Public ports and template services + +The router can expose any valid sandbox port through the direct hostname form, +not just envd's port. Access is authorized against the sandbox route and its +traffic policy before forwarding. HTTP and WebSocket upgrades must preserve +streaming and cancellation behavior. + +MCP support is delivered by a versioned template component on the port expected +by the pinned SDK. The generic SDK starts and configures that component through +normal commands and retrieves its token through the normal filesystem API, so +the compatibility layer must preserve those operations and the MCP schema. The +MCP process has no host runtime credentials. + +The first manifest pins envd on port `49983`, Code Interpreter on port `49999`, +and MCP on port `50005`, matching the selected clients. These are compatibility +constants for that manifest, not configurable per deployment. + +## A3S execution mapping + +An external sandbox record contains at least: + +```text +external_sandbox_id +box_id +template_id_and_version +sandbox_domain +requested_isolation +resolved_backend +isolation_class +execution_plan_digest +status_generation +created_at +expires_at +metadata +envd_protocol_version +envd_access_token_ciphertext_and_key_version +envd_access_token_hash +traffic_access_token_ciphertext_and_key_version +traffic_access_token_hash +traffic_policy +routing_state +``` + +The compatibility API resolves templates to an explicit A3S execution policy. +For example, a code-interpreter template can select shared-kernel sandbox +execution, while a confidential template selects MicroVM execution. This is a +deterministic template policy, not automatic backend fallback. + +The E2B request schema does not require an A3S-specific isolation field. This +keeps official clients wire-compatible. A3S-native clients may expose an +optional typed template policy helper, but the resolved choice is persisted and +returned through A3S diagnostics rather than injected into upstream response +objects. + +If a requested protocol operation is unavailable for the selected isolation +class, the server returns the matching protocol error and does not switch +backends. That configuration is then listed as partial rather than fully +compatible unless the pinned upstream contract rejects the same request. For +example, shared-kernel execution still cannot be certified for the full +lifecycle surface while filesystem-only pause lacks matching observable +semantics and the implemented filesystem Snapshot subset retains explicit +conformance gates. + +## Command and PTY compatibility + +The target process service contract includes: + +- list processes; +- start and stream a command; +- connect to an existing process; +- write stdin and close stdin; +- deliver signals; +- allocate and resize PTYs; +- return start, stdout, stderr, keepalive, and exit events in order; +- enforce request timeout independently from process timeout; +- preserve detached process handles after the initiating client disconnects. + +The implemented preview is a strict subset. The broker allocates synthetic +process IDs within one execution generation and currently supports Start, +JSON-framed Process Connect, List, SendInput, CloseStdin, SIGKILL, PTY +Start/resize, and ordered start, stdout, stderr, PTY, keepalive, and end events. +The production runtime-image gate covers foreground and background commands, +process listing, stdin input and close, wait, and PTY allocation/resize per +pinned official Python sync, Python async, and TypeScript client on a real +`crun` Sandbox. That evidence covers selected positive flows as the image's +default non-root user. + +This is not full Process compatibility. Client-streaming `StreamInput`, binary +Connect framing, SIGTERM and other signals, exhaustive PTY, reconnect, +backpressure, cancellation, detached-process, and failure-ordering matrices, +and durable process recovery across a compatibility-service restart remain +open. The internal +session layer must eventually provide independent stdin, stdout, stderr, +signal, wait, and PTY channels on every advertised backend; the compatibility +broker must continue to depend only on that backend-neutral interface. + +## Volume compatibility + +The compatibility control plane implements owner-scoped Volume create, +connect/get, list, and delete. Public IDs and encrypted scope-bound content +tokens are stored in SQLite separately from runtime volume names. Creating and +deleting records use explicit transitional states so startup reconciliation can +finish interrupted materialization or deletion without exposing another +owner's storage. Deleting a mounted Volume returns the pinned conflict behavior +and restores the active record; deletion succeeds after the Sandbox releases +the mount. + +The separate authenticated volume-content routes implement recursive directory +creation, streaming atomic file replacement, file reads, bounded-depth listing, +stat, metadata changes, and recursive removal. Unix operations stay relative to +opened directory descriptors, reject traversal and symlink escapes, reserve +internal upload names, and translate UID/GID values through the certified +Sandbox user-namespace mapping. Sandbox creation resolves public Volume names +to runtime-managed host paths without exposing those paths in the protocol. + +Official and A3S Python sync/async and TypeScript clients pass the same A3S OS +matrix: API writes are visible inside the mounted Sandbox, Sandbox writes are +visible through the content API, public listing retains mount name/path +metadata, in-use deletion fails, and final deletion removes the durable record +and data. Native A3S clients derive both control and content endpoints from +`A3S_BOX_ENDPOINT`; they do not read `E2B_API_URL` or +`E2B_VOLUME_API_URL`. Large-file, concurrent-mutation, service-crash, +host-reboot, and broader negative-path coverage remain release gates. + +## Filesystem Snapshot compatibility + +The compatibility control plane implements owner-scoped filesystem Snapshot +capture, source-filtered paginated listing, restore by Snapshot ID, and delete. +Snapshot records use explicit `creating`, `active`, and `deleting` states in +the durable SQLite repository. Generation-fenced runtime operations prevent a +capture from silently switching to another incarnation of the source Sandbox, +and startup reconciliation completes or cleans interrupted captures and +deletions without exposing another owner's Snapshot. + +Capture accepts running and memory-paused Sandbox executions on the certified +`crun` backend. A running source is quiesced for the rootfs copy and resumed +afterward; an already-paused source remains paused. Capture stores the resolved +OCI image defaults and the rootfs's container-visible Unix ownership and mode +metadata. Restore uses the Snapshot as a read-only lower layer with a private +writable upper, so restored Sandboxes do not mutate the Snapshot or one +another. Deletion returns a conflict while an active restored execution still +references that lower layer. + +Official Python sync/async and TypeScript clients, plus the corresponding A3S +packages configured only with `A3S_BOX_*`, pass the same production A3S OS +matrix. The matrix proves source-state restoration, capture survival after the +source is killed, file content and metadata fidelity, restored writability, +in-use deletion conflicts, final deletion, and cleanup. This is filesystem +state only: process memory and device state are not captured. Named-reference +and pagination edge cases, large-rootfs and concurrent-mutation behavior, +service-crash and host-reboot recovery, and broader negative paths remain +release gates. + +Snapshots created by current builds contain the resolved image configuration +needed to reconstruct the original entrypoint, command, environment, user, and +working directory. Records created by older builds without that configuration +remain listable, inspectable, and deletable, but restore fails closed before an +execution reservation is created. Re-pulling a mutable image tag is not a safe +substitute for the missing historical configuration. + +## Filesystem compatibility + +Runtime placement exposes the pinned envd Filesystem service inside the exact +generation-fenced Sandbox. The production official clients cover directory +creation, write/read, stat, depth-bounded list, rename, exists, and recursive +remove for user-relative paths. The production envd HTTP gate separately +covers one metadata-bearing multipart upload and byte-identical download. +Host-broker Filesystem behavior and the exhaustive negative/streaming matrix +are not implemented. + +The complete target contract must cover: + +- read and write one or multiple files; +- octet-stream and multipart upload modes; +- file metadata and content type behavior; +- stat, exists, list, make directory, move/rename, and recursive remove; +- directory watch streams and polling watcher handles; +- user-relative paths and ownership; +- signed upload/download URLs and expiration; +- stable errors for invalid path, invalid user, not found, and insufficient + space. + +Every implementation path must resolve files beneath the workload rootfs with +descriptor-relative operations. String-prefix checks are not sufficient. +Symlink traversal and rename races must be covered by negative tests. A host +broker must not expose the bundle, state directory, runtime sockets, or rootfs +lower layers. + +## Code Interpreter compatibility + +The immutable runtime template contains a versioned kernel service reached +through the standard sandbox port router. Pinned Python sync/async and +TypeScript clients now cover a scalar result with stdout plus context +create/list, stateful execution, restart isolation, remove, and final list. +Complete conformance still applies to: + +```text +GET /health +POST /execute +POST /contexts +GET /contexts +DELETE /contexts/{id} +POST /contexts/{id}/restart +``` + +`/execute` must return the pinned newline-delimited streaming format. The +adapter must preserve: + +- Python, JavaScript, R, Java, Bash, and explicitly advertised languages; +- persistent named contexts; +- rich MIME results including text, HTML, Markdown, SVG, PNG, JPEG, PDF, + LaTeX, JSON, JavaScript, tabular data, and chart data; +- main-result flags and execution count; +- stdout and stderr ordering; +- structured execution errors with name, value, and traceback; +- streaming callbacks and cancellation; +- context list, restart, and removal behavior. + +The kernel service is an image/template component. It does not receive host +runtime authority and cannot call the control-plane API with service +credentials. + +## Python SDK + +The native A3S Python distribution re-exports the pinned synchronous and +asynchronous objects and adds typed A3S connection configuration. It is built +as a release artifact but is not yet published to PyPI. A working source-tree +example is: + +```python +from a3s_box import A3SConnectionConfig, AsyncSandbox + +connection = A3SConnectionConfig.from_environment() +sandbox = await AsyncSandbox.create( + "code-interpreter-v1", + **connection.python_options(), +) +async with sandbox: + result = await sandbox.commands.run("python -V") + await sandbox.files.write("/tmp/input.txt", "hello") +``` + +`A3SConnectionConfig.from_environment()` requires `A3S_BOX_ENDPOINT`, accepts +`A3S_BOX_API_KEY`, and derives the Sandbox domain for conventional +`https://api.` deployments. `A3S_BOX_DOMAIN` is an explicit override. +It does not read or mutate `E2B_*` connection variables. Public types ship +`py.typed`; async operations use `async`/`await`, and resource-owning helpers +support `async with` cleanup without changing explicit `kill()` behavior. + +The independent compatibility proof still uses the published `e2b` and +`e2b-code-interpreter` wheels unchanged, configured with their own `E2B_*` +names but pointed at A3S Box. The A3S package must not add required parameters +or inject A3S fields into upstream-compatible response types. +Templates/builds, watches, signed files, and protocol surfaces outside the +production-tested Snapshot subset are not implied by re-exporting their client +objects. + +## TypeScript SDK + +The native A3S TypeScript distribution re-exports the pinned class and type +surface and adds typed A3S connection configuration. It is built as a release +artifact but is not yet published to npm. A working source-tree example is: + +```typescript +import { A3SConnectionConfig, Sandbox } from '@a3s-lab/box' + +const connection = A3SConnectionConfig.fromEnvironment(process.env) +const sandbox = await Sandbox.create('code-interpreter-v1', { + ...connection.typescriptOptions(), + timeoutMs: 60_000, +}) + +try { + const result = await sandbox.commands.run('node --version') + await sandbox.files.write('/tmp/input.txt', 'hello') +} finally { + await sandbox.kill() +} +``` + +`A3SConnectionConfig.fromEnvironment()` requires `A3S_BOX_ENDPOINT`, accepts +`A3S_BOX_API_KEY`, derives the conventional Sandbox domain, and accepts +`A3S_BOX_DOMAIN` as an override. It does not read `E2B_*` connection variables. +The package exposes the pinned `Sandbox`, Commands, command handles, PTY, +Filesystem, paginator, and Code Interpreter objects. Exposed client types for +unimplemented server surfaces do not constitute compatibility evidence. + +The independent compatibility proof uses the published `e2b` and +`@e2b/code-interpreter` packages unchanged and points them at A3S Box with the +official clients' `E2B_*` configuration names. Public export snapshots and +TypeScript compile fixtures prevent accidental source API drift. + +Generated wire clients come from the pinned OpenAPI and Protobuf sources. +Hand-written ergonomic classes are kept small and covered by cross-language +golden tests so Python and TypeScript do not drift. + +## Error compatibility + +The gateway maintains a table from A3S errors to the pinned HTTP, Connect, and +SDK-visible error contracts. It preserves: + +- HTTP status and response content type; +- structured error code and message fields; +- not-found versus already-stopped behavior; +- request timeout versus sandbox lifetime timeout; +- command non-zero exit as a command result, not a transport failure; +- cancellation and stream termination semantics; +- retryable versus terminal failures. + +Unknown internal errors are assigned a request ID and sanitized before leaving +the service. Internal paths, command environment, runtime stderr, OCI bundle +content, and credentials are never returned as generic error detail. + +## Versioning and source generation + +Compatibility sources are vendored at a reviewed upstream commit with license +and attribution. CI performs all of the following: + +1. Generate Rust server bindings, Python models, and TypeScript clients from the + pinned control, envd, volume-content, Protobuf, and MCP schemas. +2. Compare generated descriptors and public SDK snapshots to committed golden + files. +3. Detect upstream changes and produce a machine-readable compatibility diff. +4. Require an explicit compatibility-manifest update before claiming support + for a newer upstream version. +5. Keep old supported protocol versions until the documented deprecation + window ends. +6. Lock and checksum the exact official Python wheels and npm package tarballs + used by black-box tests. + +The service advertises its compatibility manifest through a diagnostic +endpoint outside the upstream namespace. Upstream response objects are not +modified with required A3S fields. + +## Configuration + +Product configuration uses A3S Agent Configuration Language (ACL), parsed by +the pinned `a3s-acl` implementation. The service accepts only a `.acl` file. +Token encryption and digest keys must be independent 32-byte hexadecimal values +referenced through `env("VARIABLE")`; literal key material is rejected. A +production control-service configuration resembles: + +```acl +e2b_compat { + api_listen = "127.0.0.1:3000" + api_public_url = "https://api.box.example.com" + sandbox_domain = "box.example.com" + sandbox_public_domain = "box.example.com" + database_path = "/var/lib/a3s-box/e2b/lifecycle.sqlite3" + runtime_home = "/var/lib/a3s" + runtime_state_path = "/var/lib/a3s-box/e2b/managed-executions.json" + + gateway { + listen = "0.0.0.0:443" + tls_certificate_path = "/etc/a3s-box/tls/sandbox-chain.pem" + tls_private_key_path = "/etc/a3s-box/tls/sandbox-key.pem" + max_connections = 4096 + handshake_timeout_ms = 5000 + connect_timeout_ms = 2000 + drain_timeout_seconds = 30 + } + + supervisor { + interval_seconds = 5 + batch_size = 100 + reconciliation_page_size = 100 + } + + account "primary" { + scheme = "api_key" + owner_id = "production-team" + client_id = "production-client" + hash = "pbkdf2-sha256$210000$$" + } + + token_key "2026-07" { + version = 1 + active = true + encryption_key = env("A3S_BOX_E2B_TOKEN_ENCRYPTION_KEY_V1") + digest_key = env("A3S_BOX_E2B_TOKEN_DIGEST_KEY_V1") + } + + template_policy "code-interpreter-v1" { + isolation = "sandbox" + image = "registry.example.com/a3s/code-interpreter:2026-07" + envd_version = "0.1.3" + + resources { + vcpus = 2 + memory_mb = 1024 + disk_mb = 4096 + } + + route { + port = 49999 + token_scope = "traffic" + } + } +} +``` + +`sandbox_domain` is the validated wildcard DNS suffix used by the gateway. +`sandbox_public_domain` defaults to the same value and may add one non-zero TCP +port when an external listener cannot use 443; its hostname must remain equal +to `sandbox_domain`. + +The envd port and envd token scope are added when omitted. Runtime paths, +credentials, key versions, template execution policy, resources, and routed +ports and TLS settings are validated before either listener opens. Startup +runs durable lifecycle reconciliation; a bounded supervisor then reaps expired +records until graceful shutdown. The control listener remains behind the +deployment TLS edge. The separate wildcard TLS listener accepts HTTP/1.1 and +HTTP/2, validates every direct or shared route and token before opening an +upstream connection, strips edge credentials, preserves streaming bodies and +trailers, bridges HTTP upgrades, and drains bounded connections on shutdown. + +## Repository boundaries + +The implementation should keep generated compatibility artifacts out of core +runtime modules: + +```text +compat/e2b/ + manifests/ # version tuples and generated digests + spec/ # vendored public schemas and attribution + fixtures/ # wire and public-export golden files +src/compat/ # Rust control/data-plane service crate +sdk/python/ # A3S Python package and official-client fixtures +sdk/typescript/ # A3S TypeScript package and official-client fixtures +templates/ + code-interpreter/ # versioned interpreter image component + mcp-gateway/ # versioned MCP image component +``` + +`src/compat` may depend on the backend-neutral runtime interfaces. Core and +runtime crates must not depend on generated public API server code. SDK +packages must not invoke `crun`, libkrun, local state files, or private runtime +sockets. + +## Phase 2 implementation architecture + +Phase 2 is a single-host control-plane preview. It proves the implemented +create/connect/get/list/timeout/refresh/current-metrics/kill path against a real +A3S OS runtime before introducing multi-host scheduling. The public protocol +and internal interfaces must not assume that the single-host limit is part of +the upstream contract. + +### Dependency direction + +The runtime now owns a canonical managed-execution store, a backend-neutral +`LocalExecutionManager`, and a production VM/Sandbox backend. CLI +`create`/`start`/`restart`/`run` and the Rust SDK lifecycle API use that +manager. The compatibility service must use the same manager directly rather +than spawning `a3s-box`, importing CLI modules, or editing `boxes.json`. Phase +2 completes this dependency direction: + +```text +a3s-box-core + typed execution request + caller record policy + resolved execution plan + ^ + | +a3s-box-runtime + canonical state store + ExecutionManager + production backend + ^ + | + +-------+------------------+ + | | +a3s-box CLI / Rust SDK a3s-box-compat + local adapters remote protocol adapter +``` + +The runtime lifecycle facade owns image resolution, rootfs preparation, +network and volume attachment, backend capability checks, shim launch, state +registration, and cleanup. The CLI and Rust SDK become callers of that facade. +This removes the current duplicate SDK state model instead of adding a third +model inside the compatibility service. + +The backend-neutral runtime interface is deliberately smaller than either the +CLI or the public compatibility API: + +```text +ExecutionManager + create(request, operation_id) -> ExecutionReservation + start(execution_id, generation) -> ExecutionLease + create_and_start(request, operation_id) -> ExecutionLease + inspect(execution_id) -> ExecutionStatus + pause(execution_id, generation, policy) -> ExecutionLease + resume(execution_id, generation) -> ExecutionLease + kill(execution_id, generation) -> KillOutcome + reconcile(operation_id) -> ReconcileOutcome +``` + +`create_and_start` is the default composition of `create` followed by `start`. +The durable `created` state lets CLI/SDK callers create without booting and +lets startup reconciliation distinguish a reservation from an in-flight +backend start. `operation_id` makes create retryable after a service crash. +`generation` prevents a delayed start, kill, or route request from reaching a +replacement execution. Runtime-specific handles, process IDs, socket paths, +OCI bundle paths, and shim command lines never cross this interface. + +`CreateExecutionRequest` keeps backend launch requirements in `BoxConfig` and +keeps caller-owned lifecycle and local resource metadata in a typed +`ExecutionRecordPolicy`. The policy includes the user-visible name, automatic +removal and restart behavior, health and log configuration, named-volume +identity, stop behavior, and host-facing inspection fields. It is part of the +durable creation intent rather than an encoded label. Consequently, retrying +one operation ID with policy drift fails as a conflict, while records written +before the policy field was added deserialize to explicit safe defaults. A +single runtime mapper projects the request and policy into `BoxRecord`; CLI, +SDK, and compatibility adapters must not construct a parallel record shape. + +### Compatibility service modules + +The existing contract generator remains in `src/compat`. Runtime service code +is added by concern rather than mixed into schema parsing: + +```text +src/compat/src/ + control/ + credential.rs # injected credential and token interfaces + model.rs # lifecycle records and public/internal state mapping + repository.rs # transactional persistence interface + service.rs # lifecycle, refresh, and current-metric use cases + sqlite/ # WAL repository and versioned migrations + supervisor.rs # expiry reaping and startup reconciliation + http/ + auth.rs # credential extraction and verification + error.rs # exact upstream error mapping + lifecycle.rs # lifecycle route handlers and DTO conversion + router.rs # route assembly and request limits + routing/ # production data-plane authorization boundary + policy.rs # persisted exact-port and token-scope policy + lease.rs # generation-fenced sandbox route projection + parser.rs # wildcard host and explicit-header validation + envd/ + mod.rs # host broker and generation-fenced health route + gateway/ + mod.rs # bounded TLS listener and graceful connection drain + proxy.rs # traffic proxy and envd broker dispatch + tls.rs # certificate and private-key loading + production/ + config.rs # closed ACL schema and startup validation + identity.rs # UUID-backed external and operation IDs + service.rs # control, broker, gateway, runtime, and supervisor wiring + template.rs # immutable validated template catalog + bin/ + a3s-box-e2b-fixture-server.rs # deterministic protocol fixture + a3s-box-e2b.rs # production composition root +``` + +No handler accesses the database or runtime directly. Handlers authenticate, +parse the pinned wire DTO, call the control service, and map its result. The +control service depends on repository, clock, token, and execution interfaces, +so lifecycle semantics can be tested without booting a VM or OCI sandbox. +The production composition root injects `LocalExecutionManager` directly +behind `ExecutionManager`; a compatibility-owned runtime wrapper is not added +unless protocol translation eventually requires one. + +### Durable lifecycle transaction + +The initial durable repository is SQLite in WAL mode through an asynchronous +driver. Its location is explicit in ACL and it owns versioned migrations. A +database transaction is never held across an image pull, sandbox boot, or shim +call. + +Create follows a recoverable sequence: + +```text +authenticate and resolve template policy + | + v +transaction: insert creating record + external ID, operation ID, generation, requested policy, + plan digest, encrypted tokens, expiry, metadata + | + v +ExecutionManager.create(operation_id) + persist a generation-fenced created reservation + | + v +ExecutionManager.start(execution_id, generation) + | + v +transaction: compare generation and publish running + route lease +``` + +If the service stops after the runtime call, startup reconciliation finds the +`creating` record and resolves the same `operation_id`; it does not start a +second sandbox. A failed create is moved to a terminal internal state and its +partial runtime resources are cleaned before the external ID can be reused. + +Kill first compares and advances the generation to `killing`, revokes route +leases, calls the idempotent runtime kill, and then records `killed`. Timeout +updates replace `expires_at` from the current clock. A reaper claims an expired +record with the same generation-fenced transition used by an API kill, so a +concurrent connect or timeout extension cannot kill the renewed sandbox. + +Connect never creates a missing sandbox. For a running sandbox it only extends +the TTL and returns HTTP 200. For a paused sandbox it performs the explicit +resume transition and returns HTTP 201. The Sandbox template policy does not +silently switch to MicroVM when a resume capability is unavailable. + +### Identity, credentials, and routing + +External sandbox IDs and A3S execution IDs are different identifiers. Only the +control repository owns their mapping. The runtime receives the external ID as +an untrusted label, not as a filesystem path or host process selector. + +Account API keys are stored as salted hashes. Envd and traffic tokens must be +returned by create/connect, so their ciphertext and hash are stored separately +with a key version. Authentication compares hashes in constant time and never +logs raw headers. The first server fixture uses an injected verifier; the +production binary refuses to start without a configured credential and token +encryption provider. + +The production credential provider stores account credentials as encoded +PBKDF2-SHA256 records with a per-credential random salt and a minimum work +factor. Compatibility API keys retain the pinned `e2b_[0-9a-f]+` lexical form; +Bearer and Supabase credentials use the same hashed-record boundary without +sharing plaintext material. Sandbox envd and traffic tokens are encrypted with +AES-256-GCM and authenticated separately with a scope- and version-bound HMAC. +The active key version issues new tokens while retained older versions remain +decryptable during rotation. Removing an old version makes its records fail +closed, and swapping an envd token into the traffic scope fails both decryption +and constant-time digest validation. + +Each published route lease contains the external sandbox ID, internal +execution ID, generation, port scope, expiry, and token scope. The wildcard +host parser is a pure validated component. It accepts neither arbitrary +hostnames nor a sandbox ID recovered by string splitting after routing has +begun. + +Route policy is persisted inside the canonical lifecycle record. A lease is an +immutable projection of a currently running record rather than a second mutable +database row, so timeout replacement, pause, kill, or recreation advances the +record generation and immediately fences every prior lease. Resolution also +checks the execution generation, expiry, exact routed port, and the separately +scoped envd or traffic HMAC. Both `-.` and the shared +host plus `E2b-Sandbox-Id`/`E2b-Sandbox-Port` form use the same parser; duplicate +or conflicting headers and domain-suffix confusion fail closed. SQLite restart +coverage proves that the policy and generation remain authoritative after the +service process is recreated. + +### Incremental merge gates + +Phase 2 is delivered as small, immediately merged changes: + +1. **Complete:** add lifecycle domain types, transition tests, repository and + execution interfaces, and deterministic clock/token fakes. No network + listener. +2. **Complete:** add the owner-scoped HTTP lifecycle router and run the + checked-in official Python sync, Python async, TypeScript, and Code + Interpreter fixtures against the Rust service with a fake execution + manager. +3. **Complete:** add SQLite WAL migrations, strict compare-and-swap repository + operations, atomic generation-fenced expiry claims, restart recovery, + startup reconciliation, and corruption/crash/concurrency tests. +4. **Complete:** extract canonical A3S state and the runtime + `ExecutionManager`; add the production backend and prove its real Sandbox + lifecycle; switch CLI create to the same reservation path; switch CLI + start/restart/run and the Rust SDK to the same implementation with behavior + parity tests. +5. **Complete:** production account credentials, sandbox token providers, + generation-fenced route leases, validated wildcard/shared parsing, and the + ACL-configured service binary. +6. **Complete:** add wildcard TLS termination, bounded HTTP/1.1 and HTTP/2 + reverse proxying, CORS preflight, credential stripping, upgrade bridging, + and a Linux connector that enters the generation-fenced `crun` network + namespace on a disposable OS thread. Pull the merge commit on an A3S OS + server and prove direct/shared routing, restart recovery, scope denial, and + stale-route fencing against a real `--isolation sandbox` execution. +7. **Complete for the production-tested subset:** unmodified official clients + pass lifecycle and health through the production listeners, then official + and A3S Python sync/async and TypeScript packages pass Filesystem, + foreground/background Process, stdin, PTY, memory-preserving pause/resume, + same-process survival, owner-scoped Volume control/content and bidirectional + mounts, filesystem Snapshot capture/list/restore/delete, Python execution, + and context lifecycle plus current metrics against real Sandboxes. The + enclosing smoke passes v1 listing, paused-state listing, monotonic refresh, + and batch metrics. Complete the remaining pinned control, envd, + filesystem-only pause, deeper Snapshot and Volume failure/recovery, + signed-file, public-port, streaming edge-case, interpreter, and MCP matrices + without broadening this subset into a full compatibility claim. + +The runtime foundation of slice 4 is complete. The persisted execution record +is the canonical schema shared by the CLI and Rust SDK, preventing either +client from dropping fields it does not model. Runtime-owned strict and +recovery-compatible reads, a cross-process advisory lock, durable atomic +writes, and synchronous read-modify-write transactions protect that state. The +managed-execution store reserves creation operations atomically, returns an +existing record only when the full creation intent matches, persists +transitional lifecycle claims, rejects stale state or generation comparisons, +and advances the generation exactly once when pause or resume completes or a +restart moves from old-runtime teardown to new-runtime startup. +Backend calls remain outside the state lock. + +`LocalExecutionManager` implements the backend-neutral lifecycle contract over +that store and an injectable runtime backend. `create` persists a stable +`created` reservation without backend side effects, `start` fences the caller +by generation and persists a `starting` claim before launch, and +`create_and_start` composes those operations for the compatibility service. +It keeps pause policy with the corresponding transitional record, performs +state-file work on Tokio blocking workers, and resolves ambiguous backend +errors from runtime observations before publishing a result. Startup +reconciliation can therefore distinguish an unstarted reservation from a +runtime that became ready before its durable `running` publication. + +Explicit restart persists `restart_stopping` before terminating an active old +runtime. Only confirmed terminal backend evidence and resource release permit +the atomic transition to `restart_starting`, which increments the generation +once. The restart operation ID, source generation, and source state survive a +manager crash. A retry can therefore finish a lost kill response, start a +generation that was advanced before the backend call, or replay a completed +lease without starting another runtime. Start failure is recorded at the new +generation and requires a new operation ID for any later restart. Graceful-stop +timeout is part of the restart intent, so a retry cannot silently change it. +Named-volume and network ownership is released and rebound once, while +execution-owned anonymous volumes remain available to the replacement +generation. Retained terminal stops preserve those anonymous volumes for a +later restart; auto-remove terminal kills remove them. + +The production VM/Sandbox backend is also complete for this slice. It owns live +runtime handles, reconstructs MicroVM processes with PID identity fencing, +reconstructs running and paused Sandbox executions from validated durable +`crun` evidence, implements idempotent memory-preserving `crun pause` and +`crun resume`, rejects filesystem-only pause without falling back to MicroVM, +and owns terminal cleanup. The opt-in A3S OS smoke harness has proven that +Sandbox `create` persists a `created` reservation without allocating a Box +directory, runtime root, or sockets; manager reconstruction reconciles the same +unstarted reservation; and explicit `start` launches through `crun`. It also +proves pause rollback, same-process survival after resume, kill, and terminal +cleanup. Deterministic image-pull failure injection proves that a failed start +does not create those runtime resources. + +CLI `create` now converts its validated arguments into `BoxConfig` and +`ExecutionRecordPolicy`, then calls `LocalExecutionManager::create`. It no +longer pre-allocates the Box directory, log directory, or socket directory. +Caller parity coverage verifies both the legacy inspection fields and the full +managed request, including config-only values such as DNS and persistent +filesystem policy. Named-volume bookkeeping remains attached only after the +durable reservation succeeds and rolls the reservation back on failure. + +CLI `run` now submits the complete caller policy to the same manager, starts +under generation fencing, and reloads the canonical record before foreground +or detached handling. It resolves image health and stop defaults before +reservation, reuses the cache during backend start, and delegates network, +volume, rootfs, stop, and auto-remove ownership to the managed backend. Caller +parity tests cover isolation, DNS, environment, security, limits, TEE/sidecar, +logs, health, stop policy, shared memory, persistence, and resource metadata. + +The Rust SDK now injects the same backend-neutral manager used by the CLI and +exposes typed create, start, create-and-start, inspect, pause, resume, restart, +kill, and reconciliation operations. Caller-parity coverage compares the full +serialized `CreateExecutionRequest`, including `BoxConfig` and +`ExecutionRecordPolicy`, at the injected manager boundary. An opt-in A3S OS +smoke test proves staged create, start, create-and-start, inspect, kill, and +runtime cleanup through the real Sandbox backend without invoking the CLI. + +Each slice must pass its focused tests and repository CI before merge. The +durable repository, production execution manager, credentials, routing, and +lifecycle HTTP router are now composed in one ACL-configured process. The real +create/connect/list/timeout/refresh/metrics/kill matrix passes through that +production control listener and real Sandbox executions. Returned +official-client Sandbox objects traverse the production TLS listener for +running and post-kill health. +The official and A3S client paths also pass the production Filesystem, Process, +stdin, PTY, Volume control/content/mount, filesystem Snapshot, Python execution, +and context subset described above. The complete compatibility gate remains +closed until every remaining control, envd, Snapshot and Volume +failure/recovery, signed-file, public-port, streaming edge-case, interpreter, +and MCP matrix passes. Fixture, direct-runtime, and production-client results +are complementary evidence, not proof of the missing behavior. + +Slice 2 evidence includes exact recorder drift checks plus live requests from +the pinned, unmodified clients to the Rust router. The live gate was also run +on an A3S OS host before merge. It covers authentication, owner isolation, +create, connect, get, v1/v2 listing, timeout replacement, monotonic refresh, +current single/batch metrics, kill, not-found mapping, and Code Interpreter +creation. It does not change the manifest's `full_compatibility=false` value. + +The production lifecycle gate reuses the artifact checksums from +`upstream.lock.json` and runs the published Python sync, Python async, +TypeScript, and Code Interpreter packages without source changes. On A3S OS it +has passed create, reconnect, filtered list, timeout replacement, monotonic +refresh, current metrics with historical-range filtering, batch metrics, kill, +not-found mapping, Code Interpreter lifecycle creation, running and post-kill +`is_running`/`isRunning` over authenticated wildcard TLS, Filesystem operations, +foreground/background commands, list, stdin send/close, wait, PTY +create/resize/input/wait, owner-scoped Volume create/connect/list/content/delete, +bidirectional Sandbox mounts, UID/GID mapping, in-use deletion conflicts, +filesystem Snapshot capture/list/restore/delete, source-state preservation, +OCI-default and Unix-metadata fidelity, restored writability, Python execution, +context create/list/run/restart/remove, envd metrics/environment/HTTP +upload/download, and cleanup for every real `crun` execution. The gate repeats +the client matrix through the A3S packages after removing every `E2B_*` +connection variable and supplying only `A3S_BOX_*`. It does not cover the +remaining protocol surfaces listed in the current evidence table. + +The Slice 3 persistence batch uses a bundled SQLite build through a dedicated +asynchronous connection thread. Versioned migrations create a STRICT table in +WAL mode. The serialized lifecycle record is the single source of truth; +indexed owner, operation, generation, state, creation, and expiry fields are +generated by SQLite from that record. Startup refuses unknown migration +histories, and every read revalidates identifier, generation, credential, and +cross-field lifecycle invariants before returning a record. + +The maintenance half of Slice 3 atomically advances expired records to +`pausing` or `killing` inside the repository transaction. This makes timeout +replacement and reaping mutually exclusive at the persisted generation. The +supervisor retries generation-fenced pause, resume, and kill work after a +service crash and uses the runtime operation ID to recover a create that became +ready before its `running` publication committed. A second migration indexes +chronological expiry through SQLite's date representation so optional RFC3339 +fractional seconds cannot cause a record to be skipped. + +## Delivery phases and gates + +### Phase 1: contract fixture (complete) + +- Vendor the pinned public OpenAPI and Protobuf descriptors. +- Vendor the volume-content and MCP schemas as well. +- Generate a compatibility manifest and a machine-readable endpoint, method, + field, header, public-export, and error inventory. +- Build black-box fixtures from the official Python and JavaScript clients. + +Gate: CI can detect any field, status, header, or method drift before server +implementation begins. + +### Phase 2: lifecycle and routing (in progress) + +- Implemented authentication, create/connect/get/v1-v2-list/kill/timeout, + memory-preserving pause/connect-resume, monotonic refresh, current + single/batch metrics, generation-fenced v1/v2 structured logs, filtered + running/paused listing, durable mappings, wildcard routing, and traffic + tokens. +- Every create routes through A3S execution resolution and persists its plan. +- Requested lifetime begins only after runtime and envd readiness, including + startup recovery. Historical metrics, full pagination edge cases, host-reboot + recovery, and certificate rotation remain open. + +Gate: unmodified official SDKs create, pause, resume, reconnect to, list, +refresh, read current metrics from, time out, and kill an A3S sandbox while an +already-running process survives the pause cycle. + +### Phase 3: commands, files, and PTY (in progress) + +- Complete the ConnectRPC Process service; foreground/background commands, + stdin close, listing, wait, and one PTY resize flow are production-gated, but + the exhaustive stream, signal, reconnect, cancellation, and PTY matrix is + not. +- Extend runtime Filesystem coverage beyond the gated single-file HTTP transfer + through multi-file and large-file behavior, watches, signed URLs, ownership, + quota errors, and traversal negatives; implement the required broker + behavior. +- Add durable process handles and recovery across service restart. + +Gate: upstream command/filesystem contract suites pass in Python sync, Python +async, and TypeScript clients on both A3S backends where supported. + +### Phase 4: code interpreter + +- Harden and publish the versioned interpreter template and kernel service. +- Extend the validated scalar/context flow through NDJSON streaming, rich + results, callbacks, errors, cancellation, and every advertised language. +- Publish the versioned MCP template and verify its standard port, token, and + streaming behavior through the generic SDK. + +Gate: upstream code-interpreter and MCP client suites pass without source +patches. + +### Phase 5: complete public surface + +- Owner-scoped Volume control/content, durable recovery, and Sandbox mounts are + implemented and pass the six-client A3S OS matrix; deeper failure, recovery, + large-file, concurrent-mutation, and negative-path breadth remains open. +- Owner-scoped filesystem Snapshot capture/list/restore/delete is implemented + and passes the six-client A3S OS matrix; named-reference, pagination, + large-rootfs, concurrent-mutation, crash/reboot recovery, and negative-path + breadth remains open. +- Implement templates/builds, filesystem-only pause, historical metrics, + network policy, routed ports, and remaining public helpers. +- Run compatibility across every supported SDK/version tuple. + +Gate: the complete public SDK inventory has an observed passing test or an +observed upstream-equivalent rejection for the same request. No method is +marked compatible based only on matching its name, JSON shape, or an +A3S-specific unsupported response. + +## Validation matrix + +Compatibility evidence must include: + +- official SDK packages configured only through supported endpoint/domain and + credential options; +- default client-side API-key validation with an issued compatibility key; +- Python sync, Python async, TypeScript Node.js, and supported edge transports; +- direct wildcard-host routing through official clients and shared-endpoint + routing through explicit header-level fixtures; +- exact request methods, paths, headers, query serialization, and body fields; +- success and error status codes and response bodies; +- HTTP/1.1, HTTP/2, Connect content types and trailers, WebSocket upgrades, and + browser CORS behavior; +- stream event order, partial UTF-8, binary stdin, backpressure, cancellation, + disconnect, and reconnect; +- timeout boundary tests using the protocol's documented units; +- process exit, signal, PTY resize, and detached handle behavior; +- file path traversal, symlink race, metadata, large file, and watcher behavior; +- code-context persistence, rich MIME output, traceback, and concurrent cells; +- arbitrary public ports, traffic-token denial, signed URL expiry, and MCP + streaming; +- sandbox crash, service restart, host reboot, and stale-route reconciliation; +- both MicroVM and shared-kernel isolation with resolved backend evidence. + +Large compatibility, image, networking, and performance suites run on A3S OS +servers after pulling the tested Git revision. Developer laptops run schema +generation, formatters, and pure contract tests only. + +## Upstream references + +- [E2B repository](https://github.com/e2b-dev/e2b) +- [E2B control-plane OpenAPI](https://github.com/e2b-dev/e2b/blob/main/spec/openapi.yml) +- [envd HTTP OpenAPI](https://github.com/e2b-dev/e2b/blob/main/spec/envd/envd.yaml) +- [envd process protocol](https://github.com/e2b-dev/e2b/blob/main/spec/envd/process/process.proto) +- [envd filesystem protocol](https://github.com/e2b-dev/e2b/blob/main/spec/envd/filesystem/filesystem.proto) +- [volume-content OpenAPI](https://github.com/e2b-dev/e2b/blob/main/spec/openapi-volumecontent.yml) +- [MCP configuration schema](https://github.com/e2b-dev/e2b/blob/main/spec/mcp-server.json) +- [E2B code interpreter](https://github.com/e2b-dev/code-interpreter) diff --git a/docs/host-integration.md b/docs/host-integration.md index 97364c5a..d637c742 100644 --- a/docs/host-integration.md +++ b/docs/host-integration.md @@ -10,7 +10,7 @@ root. | --- | --- | --- | | Stub baseline | macOS or Linux with Rust, C compiler, and protoc | `scripts/host-integration-smoke.sh` | | Core MicroVM smoke | macOS Apple Silicon/HVF or Linux KVM, libkrun, Linux guest init, runnable image | `scripts/host-integration-smoke.sh --core` | -| Host command matrix | Same as core smoke; optional registry credentials for push coverage | `scripts/host-integration-smoke.sh --host` | +| Host command and warm-pool smoke | Same as core smoke; optional registry credentials for push coverage | `scripts/host-integration-smoke.sh --host` | | Linux Dockerfile `RUN` | Linux, root, chroot-capable filesystem, local Alpine OCI archive | `sudo -E scripts/host-integration-smoke.sh --linux-run --no-pure` | | CRI smoke | macOS or Linux MicroVM host, `crictl`, CRI images | `scripts/host-integration-smoke.sh --cri` | | Host soak | Same as the selected host-backed suites; enough time to expose leaks and lost updates | `scripts/host-integration-smoke.sh --no-pure --core --host --soak` | @@ -98,15 +98,163 @@ sudo -E env A3S_BOX_TEST_ALPINE_TAR=/path/to/alpine-oci.tar \ scripts/host-integration-smoke.sh --linux-run --no-pure ``` -macOS does not run Dockerfile `RUN` by default. The unsafe host execution path -is only for local experiments and requires `A3S_BOX_UNSAFE_HOST_RUN=1`; it is -not part of the product smoke matrix. +macOS does not run Dockerfile `RUN` on the host. The first supported local path +runs BuildKit inside an A3S Linux VM and loads the resulting OCI archive back +into the A3S image store: + +```bash +a3s-box build --builder=buildkit-vm \ + --platform linux/arm64 \ + -f docker/Dockerfile.web \ + -t a3s/web:v1 \ + . +``` + +On Apple Silicon, `linux/amd64` builds run through the BuildKit Linux builder +path and may use emulation, so expect them to be slower than native +`linux/arm64`. For release builds, push directly from the BuildKit VM: + +```bash +a3s-box build --builder=buildkit-vm \ + --platform linux/arm64 \ + --push --plain-http \ + -f docker/Dockerfile.web \ + -t 10.0.0.2:5000/a3s/web:v1 \ + . +``` + +BuildKit VM push uses the same credential lookup as `a3s-box push`: the A3S +credential store, Docker config or helpers, then `REGISTRY_USERNAME` / +`REGISTRY_PASSWORD`. Only the target registry auth is written to a temporary +Docker config and mounted into the BuildKit VM. + +The built-in build engine also has an isolated VM path for Dockerfile `RUN`: +start a warm-pool daemon, then pass `--run-pool` (or set +`A3S_BOX_BUILD_RUN_POOL_SOCKET`). The build stage rootfs is mounted into a +leased pool VM and each shell/exec-form `RUN` executes through the guest exec +server with the current Dockerfile `WORKDIR`, `ENV`, and `USER`: + +```bash +a3s-box pool start --image alpine:latest --size 1 --socket /tmp/a3s-build-pool.sock +a3s-box build --builder=host --run-pool --run-pool-socket /tmp/a3s-build-pool.sock \ + -t a3s/web:v1 . + +# Or let the build command start the helper daemon explicitly. +a3s-box build --builder=host --run-pool-autostart \ + --run-pool-image alpine:latest \ + -t a3s/web:v1 . +``` + +`RUN --mount=type=cache` is available on this path as a persistent overlay: +writes under the cache target are visible to matching `RUN` commands keyed by +`id=` (or by `target=` when `id` is omitted) but are restored before layer +diffing, so cache contents are not committed to the image. Docker/BuildKit's +default omitted `sharing=shared` and explicit `sharing=shared` are accepted, as +is `sharing=locked`; because the warm-pool overlay hydrates and publishes cache +directories around each RUN, access to the same cache key is serialized across +builds to avoid writeback races. Successful RUNs publish cache writes; failed +RUNs restore the rootfs without publishing partial cache contents. New cache +directories can be seeded from `from=,source=`; an existing +cache is not re-seeded. Cache-root `mode=`, `uid=`, and `gid=` are applied when +present. `sharing=private` remains unsupported. +Set +`A3S_BOX_BUILD_RUN_CACHE_DIR` to override the default +`~/.a3s/buildcache/run-cache` location. + +The build rootfs volume is part of the pool key. Because that path is unique to +one build stage and is destroyed after the stage completes, the daemon fills +volume-bound build leases on demand (`min_idle=0`) instead of pre-warming a full +idle pool for every temporary stage rootfs. + +`RUN --mount=type=bind` is also available for build-context sources, previous +build stages, and external images on the warm-pool path. Omitted `source=` +mounts the context root, relative `target=` paths resolve from the current +Dockerfile `WORKDIR`, `.dockerignore` is honored for context sources, stage/image +sources ignore `.dockerignore`, and writes under the bind target are discarded +before layer diffing. + +`RUN --mount=type=tmpfs` creates an empty temporary target for the duration of a +RUN, restores any original target contents afterwards, and discards tmpfs writes +before layer diffing. Relative `target=` paths resolve from `WORKDIR`. The +Docker/BuildKit `size=` option is rejected until the warm-pool overlay can +enforce it honestly. + +`RUN --network=default` and `RUN --security=sandbox` are accepted as Docker's +default no-op values. Non-default per-RUN network/security modes are rejected +until the warm-pool exec path can enforce them. + +The unsafe host execution path is only for local experiments and requires +`A3S_BOX_UNSAFE_HOST_RUN=1`; it is not part of the product smoke matrix. + +## Large Workspace Verification + +For package-manager-heavy monorepo checks, prefer an explicit cache profile +instead of a raw host mount: + +```bash +a3s-box run --rm --timeout 120 --cpus 4 --memory 8g \ + --package-cache pnpm \ + --virtiofs-cache=always \ + -v "$PWD:/workspace" \ + -w /workspace \ + --tmpfs /workspace/node_modules:size=4g \ + node:24-bookworm -- \ + sh -lc 'corepack enable && corepack prepare pnpm@11.10.0 --activate && pnpm --filter @a3s-lab/web build' +``` + +`--package-cache pnpm` keeps the pnpm store, Corepack home, pnpm home, and npm +cache in the named `a3s-cache-pnpm` volume across `--rm` runs. +For npm-only checks, use `--package-cache npm` to keep the npm cache in +`a3s-cache-npm`. +`--tmpfs .../node_modules` prevents large dependency trees from being written +through the host workspace mount. `--virtiofs-cache=always` is intended for +release verification jobs where the host checkout is stable for the duration of +the run; omit it or use `none` when host-side edits must be visible immediately. + +For repeated short checks, run a warm-pool daemon with the same image, resource +shape, and workspace mount, then either pass `--pool` explicitly or export +`A3S_BOX_RUN_POOL_SOCKET` so compatible foreground `run --rm` commands use the +daemon automatically: + +```bash +a3s-box pool start --image node:24-bookworm --size 2 --max 4 \ + --socket /tmp/a3s-node-pool.sock + +export A3S_BOX_RUN_POOL_SOCKET=/tmp/a3s-node-pool.sock +a3s-box run --rm --cpus 4 --memory 8g \ + --package-cache pnpm \ + -v "$PWD:/workspace" \ + -w /workspace \ + node:24-bookworm -- \ + sh -lc 'corepack enable && pnpm --version' + +a3s-box pool stop --socket /tmp/a3s-node-pool.sock +``` + +For an explicit one-command local loop, `a3s-box run --pool-autostart --rm ...` +starts a daemon on `--pool-socket` if none is already running. Foreground +`--timeout` is passed through to the warm-pool exec request. + +`pool status` reports idle sandboxes, active checked-out sandboxes, and active +leases for each pool key. During `build --run-pool`, a nonzero leased count means +a Dockerfile stage currently holds a helper VM. `a3s-box info` performs the same +best-effort daemon probe against the configured run/build pool sockets and the +default socket, then prints aggregate max/idle/active/leased counts when a daemon +is reachable. `pool start --lease-ttl ` is the abandoned-lease +guardrail for daemon-backed build leases; it reclaims only idle leases, never a +lease with an exec currently running. The default is `1h`; use `0` to disable it +for long manual debugging sessions. + +For cold package stores, registry downloads and project-level supply-chain +policy checks can still dominate the first run. Prime the named cache volume +before a release window when the monorepo depends on thousands of packages. ## Host command matrix -The host matrix extends the core smoke with VM lifecycle commands, Compose, -copy, stats, snapshots, network operations, image tagging/saving, local build, -and optional registry push coverage. +The host matrix extends the core smoke with VM lifecycle commands, canonical +`compose.acl` discovery and teardown, copy, stats, snapshots, network +operations, image tagging/saving, local build, and optional registry push +coverage. ```bash cd crates/box diff --git a/docs/host-sandbox-backend-design.md b/docs/host-sandbox-backend-design.md new file mode 100644 index 00000000..1e8f0100 --- /dev/null +++ b/docs/host-sandbox-backend-design.md @@ -0,0 +1,836 @@ +# Host Sandbox Backend Design + +Status: **Implementation in progress** + +Scope: architecture, implemented OCI runtime foundation, and remaining +lifecycle/security/performance gates + +The certified `crun` launch path, protected OCI bundle construction, managed +create/start/kill lifecycle, exec, health, named volumes, shared memory, and +durable two-phase managed restart are implemented. This document remains the +source for unfinished hardening, lifecycle parity, and a3s-bench gates; it is +not a claim that the complete validation matrix has passed. + +Target platform: Linux without `/dev/kvm` + +## Executive decision + +A3S Box should support Linux hosts without `/dev/kvm` through a second, +first-class execution backend. The backend should compile an A3S execution plan +into an OCI bundle and launch it with a pinned `crun` release. + +The public API must describe the requested isolation posture, not the mechanism +used to implement it. The only new CLI form is: + +```text +--isolation sandbox +``` + +Omitting `--isolation` preserves the existing MicroVM behavior. No explicit +selector is added for the default backend. `sandbox` is always an explicit +caller choice and is never selected because `/dev/kvm` is missing. The MVP has +no automatic backend selection or backend fallback. The resolved backend and +effective controls must be persisted before the workload starts. + +This is not a replacement for the MicroVM backend. A host sandbox shares the +Linux kernel with the workload and therefore cannot provide a VM boundary +against kernel exploits. Workloads that need TEE, attestation, VM snapshots, +device assignment, or a hardware boundary remain MicroVM-only. + +The intended product posture is a low-isolation sandbox for agent tools, +benchmark workloads, and development automation where filesystem, process, +network, syscall, and resource containment are useful, but a hostile +kernel-level adversary is outside the threat model. + +## Design principles + +1. Policies express filesystem, network, resource, and isolation intent rather + than raw platform flags. +2. `probe` and `doctor` report real host capabilities before policy execution. +3. Strict postures fail closed when required controls are unavailable. +4. Posture selection, denials, and enforcement evidence are auditable. +5. The envelope records the policy digest, resolved posture, selected backend, + and enforcement evidence. +6. Shared-kernel isolation is documented as weaker than a MicroVM for hostile + tenants. +7. Privileged operations, process supervision, and Landlock setup are separated + into small companion binaries. + +A3S Box needs create/start/exec, restart, cgroup updates, persistent rootfs +state, volumes, PTY, health checks, and crash reconciliation. An OCI runtime +already implements most of that Linux container lifecycle correctly, so the +host-sandbox backend should compile policy into OCI rather than construct an +ad-hoc process wrapper command line. + +`crun` is the initial production backend because it supports OCI bundles, +rootless user mappings, namespaces, capabilities, seccomp, cgroup v2, and +passing pre-opened file descriptors into a container. Only one tested version +should be accepted in the first release. The resolver must not silently select +an arbitrary host `crun` or `runc` binary. + +## Goals + +- Run the normal A3S Box process lifecycle on Linux hosts without `/dev/kvm`. +- Preserve the existing CLI, SDK, state, exec, PTY, log, and health protocols + wherever the security model permits it. +- Make isolation selection deterministic, inspectable, and fail closed. +- Keep VM-specific concepts out of the public execution contract. +- Produce a package that does not build, link, or ship libkrun when only the + host-sandbox backend is selected. +- Enforce user, mount, PID, IPC, UTS, and network isolation; seccomp; + capabilities; `no_new_privs`; and cgroup v2 limits. +- Preserve image UID/GID metadata instead of flattening the image to one host + user. +- Survive launch failures, shim termination, and host reboot without leaked + mounts, cgroups, sockets, bundles, or processes. + +## Non-goals for the first release + +- Claiming that a shared-kernel sandbox is equivalent to a MicroVM. +- TEE, attestation, sealed storage, or confidential-computing workflows. +- Snapshot-fork, KSM, warm-VM pools, or live migration. +- `--privileged`, arbitrary device or GPU passthrough, or host PID namespace. +- Named bridge networking, TSI, inbound port publishing, or sidecar/vsock + services. +- CRI RuntimeClass support before the standalone lifecycle is stable. +- Pause/unpause before cgroup v2 freezer semantics are implemented and tested. +- Automatic backend selection or fallback. +- gVisor/runsc or any user-space-kernel backend. +- Running a nested OCI runtime inside a fully privileged Docker container as the + production security boundary. + +## Threat model + +The host-sandbox backend is intended to contain accidental damage and malicious +user-space workloads that do not possess a working Linux kernel exploit. The +trusted computing base includes: + +- the host Linux kernel; +- the A3S Box runtime and sandbox shim; +- the execution-plan and OCI-bundle compilers; +- the pinned `crun` binary and its required libraries; +- the host rootfs and content stores; +- any explicitly exposed host path or network service. + +The backend must protect host processes, filesystem paths, runtime sockets, +devices, cgroups, and network namespaces from the workload. It does not protect +the host from kernel vulnerabilities, hardware side channels, a hostile host +administrator, or data deliberately mounted into the sandbox. + +MicroVM must remain the default for untrusted multi-tenant workloads. The +host-sandbox mode deliberately trades isolation strength for lower overhead and +broader Linux compatibility. + +## Public isolation contract + +### Isolation selection + +| CLI input | Resolution | +| --- | --- | +| option omitted | Select krun only. Missing KVM/HVF/WHPX or a required VM feature is an error. | +| `--isolation sandbox` | Select the certified OCI host-sandbox backend only. The caller has explicitly accepted a shared kernel. | + +The effective default is `microvm`, preserving the current security posture, +but it is an internal and persisted value rather than a new CLI spelling. The +public value `sandbox` stays concise, while state and audit output always +expose `isolation_class=shared-kernel`. + +The equivalent ACL setting is explicit only for the sandbox backend: + +```acl +runtime { + isolation = "sandbox" +} +``` + +Omitting `runtime.isolation` selects MicroVM. Configuration parsers reject any +other explicit value so a typo cannot weaken or silently change isolation. + +### Isolation class + +Backend identity and isolation strength are separate fields: + +| Backend | Isolation class | +| --- | --- | +| krun | `hardware-vm` | +| crun | `shared-kernel` | + +This prevents a backend name from hiding a security-boundary change. + +### Requirement extraction + +Before choosing a backend, `RequirementExtractor` converts all user-facing +options into explicit requirements. Examples include: + +- hardware isolation; +- TEE, attestation, or sealed storage; +- snapshot/fork or warm-pool semantics; +- devices, GPU, or privileged mode; +- network intent and published ports; +- filesystem mounts and ownership mappings; +- exec, PTY, health, restart, and persistence lifecycle requirements; +- CPU, memory, PID, and other resource guarantees; +- requested seccomp and capability posture. + +Backend selection must not inspect scattered CLI flags directly. A requirement +is either enforced or rejected; it is never ignored. + +## Architecture + +```text +CLI / SDK / CRI / a3s-bench + | + ExecutionRequest + | + +---------+------------------+ + | CapabilityProbe | + | RequirementExtractor | + | ExecutionPolicy | + +---------+------------------+ + | + BackendResolver + (pure and deterministic) + | + ResolvedExecutionPlan + + policy digest + audit record + | + persist resolution + | + +---------+-------------------------+ + | | +KrunBackend OciSandboxBackend + | | +a3s-box-krun-shim a3s-box-sandbox-shim + | | +libkrun + guest-init pinned crun + guest-init +vsock control inherited Unix listener FDs +``` + +### Core model + +`ExecutionRequest` is backend neutral and contains process, rootfs, mount, +network, resource, security, lifecycle, and isolation intent. + +`CapabilitySnapshot` is the result of active host probes. It includes the probe +version and enough evidence to explain why a control is available or missing. + +`ResolvedExecutionPlan` is immutable after resolution and contains at least: + +```text +request_digest +requested_isolation +resolved_backend +isolation_class +process_plan +rootfs_plan +mount_plan +network_plan +resource_plan +security_plan +resolved_controls +unenforced_controls +capability_snapshot_digest +backend_artifact_digest +``` + +The plan is compiled into the existing VM-specific `InstanceSpec` for krun or +an OCI `config.json` and bundle for crun. VM concepts such as virtio-fs tags, +vsock ports, guest kernel paths, and krun snapshot sockets remain inside the +krun compiler. + +### Backend interface + +The neutral execution interface owns lifecycle operations rather than VMM +operations: + +```text +prepare(plan) -> PreparedExecution +start(prepared) -> ExecutionHandle +exec(handle, process) +signal(handle, signal) +wait(handle) +stats(handle) +stop(handle, timeout) +delete(handle) +``` + +`ExecutionHandle` is a versioned tagged enum. Common state includes the box ID, +shim PID/pidfd, control endpoint, state directory, and plan digest. Backend +variants store either krun state or OCI container/bundle state. + +The existing `VmmProvider` remains available behind `KrunBackend` during the +migration. Existing third-party implementations are not broken in the first +phase. `VmManager` can remain as a compatibility facade while new code moves to +an `ExecutionManager`. + +## Capability probing and resolution + +The current platform code treats every Unix host as krun-capable. That must be +replaced by active probes. Kernel version checks and file-existence checks alone +are insufficient. + +### Probe inputs + +The Linux probe should test, in a disposable child and clean up after itself: + +- `/dev/kvm` open and a minimal KVM capability query; +- exact krun shim and libkrun artifact availability; +- exact `crun` path, version, digest, and required feature set; +- creation of user, mount, PID, IPC, UTS, and network namespaces; +- complete subordinate UID/GID mapping through `newuidmap`/`newgidmap` when + rootless; +- cgroup v2 controller availability and actual write delegation; +- seccomp filter installation with `no_new_privs`; +- Landlock ABI and a minimal ruleset when required; +- overlayfs, rootless overlayfs, or the selected copy fallback; +- idmapped mount support when the rootfs plan requires it; +- Unix listener FD preservation through the certified crun build; +- required network helper availability for each network intent. + +`a3s-box probe --json` should return raw capability evidence. `a3s-box doctor` +should evaluate the requested isolation mode and return actionable errors. A +successful `doctor` result is not cached forever; the launch path performs the +security-critical probes again or validates a short-lived, versioned snapshot. + +### Resolver rules + +`BackendResolver` is a pure function of the request, extracted requirements, +execution policy, and capability snapshot. Its output is either one complete +plan or one machine-readable error. It must never partially mutate box state. + +Recommended denial classes are: + +```text +BACKEND_UNAVAILABLE +UNSUPPORTED_REQUIREMENT +REQUIRED_CONTROL_UNAVAILABLE +BACKEND_ARTIFACT_MISMATCH +``` + +The resolver runs before rootfs preparation or any long-lived side effect. Its +decision is persisted before backend preparation begins. + +## OCI host-sandbox backend + +### Process layout + +```text +a3s-box CLI or service + | + +-- a3s-box-sandbox-shim + | + +-- crun create/start + | + +-- guest-init (container PID 1) + | + +-- user workload +``` + +The shim owns the OCI container lifecycle, control listeners, stdio pipes, +ready handshake, and durable cleanup metadata. The workload must not be able to +replace the shim or mutate its bundle after validation. + +### Structured log lifecycle + +Each running Sandbox generation owns a separate packaged log worker alongside +`crun run`. The runtime opens independent raw console files for container +stdout and stderr, and the worker tails both into Docker-compatible +`logs/container.json` records without losing the `stdout` or `stderr` stream +field. Runtime and init diagnostics use their dedicated log and are never +projected as workload output. + +The worker becomes ready only after both console readers are open. It watches +the exact `crun run` PID and Linux process start time recorded for that +generation, so PID reuse cannot end or extend another generation's logging. +Once that writer is gone or is an unreaped zombie, its output descriptors are +closed and the worker drains both files through final EOF, including a trailing +partial line. The runtime record persists the worker PID and start time so an +explicit stop, kill, detached natural-exit reconciliation, or crash recovery can +wait for the same generation to finish before deleting its artifacts. + +Auto-remove archival happens only after that drain completes. Consequently, +`a3s-box logs ` reads complete structured output from +`removed-logs`, even after the box directory and crun state have been removed. +Failure to prove that the worker finished is a cleanup error: state is retained +for recovery instead of silently archiving an incomplete log. + +### OCI bundle compiler + +The compiler writes a protected, per-box bundle from the resolved plan. It must +generate, rather than accept unchecked user JSON: + +- process args, environment, workdir, user, rlimits, and terminal mode; +- root path and read-only setting; +- user, mount, PID, IPC, UTS, cgroup, and network namespaces; +- full UID/GID mappings; +- validated bind mounts, proc, devpts, tmpfs, and minimal `/dev` nodes; +- capability bounding/permitted/effective/ambient sets; +- mandatory `noNewPrivileges`; +- a host-sandbox-specific seccomp profile; +- cgroup v2 path and resource settings; +- masked and read-only kernel paths; +- A3S annotations containing the plan and artifact digests. + +The bundle directory is owned by the shim/service and is not mounted writable +inside the container. The generated config is hashed after writing and checked +again immediately before `crun create`. + +### guest-init bootstrap modes + +guest-init currently assumes that it is booting inside a MicroVM. It should +gain two independent internal settings: + +```text +BootstrapMode: MicroVm | HostSandbox +ControlTransport: Vsock | InheritedUnixFd +``` + +In `HostSandbox` mode, the OCI runtime has already created the final rootfs and +mounted proc, devpts, tmpfs, workspace, and user volumes. guest-init therefore +must skip: + +- virtio-fs discovery and mounts; +- rootfs pivoting; +- guest-global cgroup hierarchy setup; +- TEE, vsock sidecars, and VM network initialization. + +It remains PID 1 and continues to supervise the main process, reap children, +apply the requested workload UID/GID, handle signals, and provide the existing +exec, PTY, health, and copy protocols. + +Bootstrap settings should be supplied through a sealed memfd or protected +read-only descriptor, not ordinary user-controlled environment variables. + +### Control transport + +The sandbox shim creates the host-visible Unix listeners before starting crun +and passes the open descriptors with crun's `--preserve-fds` support. guest-init +constructs `ExecListener` and `PtyListener` from those descriptors, sets +`FD_CLOEXEC`, and never exposes them to the workload process. + +This approach preserves the existing host socket paths and wire protocols while +avoiding a bind mount of the A3S control directory into the sandbox. If the +certified crun build cannot pass listeners reliably, that build fails the +capability probe; a less secure socket-directory mount is not a silent fallback. + +Init diagnostics should use a dedicated inherited log descriptor. They must not +be mixed with the workload's stdout or stderr. + +## Rootfs and ownership + +The current `RootfsProvider` remains the source of the prepared rootfs view, but +ownership mapping becomes part of `RootfsPlan` rather than a backend afterthought. + +Requirements: + +- Determine the UID/GID range needed by the image before materialization. +- Require a mapping that covers all preserved image owners. +- Prefer idmapped mounts when supported and validated. +- Otherwise extract or replay ownership inside the correct user namespace. +- Never silently convert every image owner to the invoking host UID. +- Reject a launch when ownership cannot be represented safely. +- Keep overlay lower, upper, work, and merged paths in the durable cleanup + ledger. + +The same content store can serve both backends, but the materialized view and +ownership strategy may differ. The resolved plan records the selected strategy. + +## Network intent + +The public model should express intent rather than TSI, passt, or a particular +helper: + +```text +default | none | egress | host +``` + +Initial resolution: + +| Intent | MicroVM | Host sandbox MVP | +| --- | --- | --- | +| `default` | Existing TSI behavior | Isolated network namespace with loopback only | +| `none` | No guest egress | Isolated network namespace with loopback only | +| `egress` | Existing supported VM egress | Deferred until a pinned pasta/slirp design is implemented | +| `host` | Existing host-equivalent behavior where supported | Explicitly share the host network namespace; warn and audit | + +`host` must never imply host filesystem or runtime-socket access. Port +publishing and named bridge networks remain unsupported for the host-sandbox +MVP. A later egress proxy should be represented as a compiler result, not a new +public isolation backend. + +## Mandatory security controls + +The host-sandbox backend is available only when all mandatory controls can be +applied. It must not use the weaker seccomp deny list that currently runs inside +the VM as its host security boundary. + +### Namespaces and identity + +- A user namespace is mandatory, including for a root-run production service. +- Mount, PID, IPC, and UTS namespaces are mandatory. +- A network namespace is mandatory unless the caller explicitly requests + `host` networking. +- Container root must not map to host root. +- Complete UID/GID mapping is mandatory for multi-owner images. + +### Privileges and syscalls + +- `no_new_privs` is mandatory. +- Start from an empty or minimal capability set; additions are allowlisted. +- Reject `--privileged`. +- Reject `seccomp=unconfined` and arbitrary custom profiles in the MVP. +- Compile and pin an OCI seccomp profile for guest-init plus the workload. +- Treat Landlock as defense in depth. Record its ABI and coverage; never use it + to compensate for a missing mandatory namespace or seccomp control. + +### Resources + +- Use cgroup v2 only. +- Attach guest-init to the final cgroup before it starts the workload. +- Enforce a baseline PID limit even when the caller did not specify one. +- Reject each requested memory, CPU, PID, or cpuset guarantee that cannot be + applied; do not log-and-continue. +- Record the exact controller files and values that were written. + +### Filesystem + +- Validate mount sources against policy before bundle generation. +- Reject symlink traversal and revalidate source identity before launch. +- Mask or omit sensitive proc/sys paths and mount sysfs read-only only when + required. +- Do not expose host `/run`, container runtime sockets, A3S control sockets, + arbitrary devices, D-Bus sockets, or the Docker/Podman/containerd/OrbStack + sockets by default. +- Reject mounts of `/`, `/proc`, `/sys`, `/dev`, runtime state directories, and + other protected paths unless a future explicit high-risk policy defines them. + +## State, audit, and lifecycle + +### Durable state + +`BoxConfig` records the request. `BoxRecord` records the resolution and runtime +evidence. At minimum it needs: + +```text +requested_isolation +resolved_backend +isolation_class +execution_policy_digest +resolved_controls +unenforced_controls +capability_snapshot +backend_artifact_digest +backend_state +``` + +Old records deserialize with `requested_isolation=microvm`. `inspect` and +structured logs display both requested and resolved values. + +### Lifecycle states + +```text +requested -> resolved -> preparing -> created -> running + | | + v v + failed stopped/dead + | + v + deleted +``` + +Every side effect is appended to a durable resource ledger before the next side +effect starts. Failure unwinds the ledger in reverse order. Entries cover: + +- bundle and state directories; +- rootfs overlay mounts and temporary mounts; +- cgroup paths; +- control sockets and preserved descriptors; +- network namespace/helper processes; +- crun container ID and init PID/pidfd; +- named and anonymous volume attachments. + +Use pidfds where supported rather than trusting a persisted numeric PID. On +restart, reconciliation compares the plan digest, runtime state, pidfd or +process start identity, cgroup membership, mounts, and crun state before marking +a box alive. Cleanup operations are idempotent. + +### Audit envelope + +Each launch emits a stable structured record containing: + +- request and policy digests; +- requested isolation and resolved backend; +- isolation class and explicit shared-kernel acknowledgement; +- capability probe version and evidence digest; +- every required control and its enforcement evidence; +- every optional control that was unavailable; +- pinned runtime version and artifact digest; +- lifecycle outcome and cleanup outcome. + +An audit or diagnostic mode must not silently weaken enforcement. If a future +seccomp learning mode permits syscalls for observation, it must be a separate, +explicitly unsafe execution posture and cannot satisfy production acceptance. + +## Feature compatibility + +| Feature | krun MicroVM | Host-sandbox launch target | +| --- | --- | --- | +| run, foreground, detach | Supported | MVP | +| exec and non-TTY streams | Supported | MVP | +| PTY, shell, attach | Supported | MVP | +| logs and exit code | Supported | Split structured stdout/stderr, final drain, detached recovery, and auto-remove archival implemented | +| stop, kill, wait, restart | Supported | MVP | +| health checks | Supported | MVP | +| numeric user/workdir/env | Supported | MVP | +| bind mounts, named volumes, tmpfs | Supported | MVP with path policy | +| read-only rootfs | Supported | MVP | +| memory, CPU, PID limits | Guest cgroup | MVP through OCI cgroup v2 | +| default network | TSI | None/loopback | +| explicit host network | Platform-dependent | MVP with warning/audit | +| isolated outbound egress | Supported modes | Post-MVP pasta/slirp or proxy | +| published ports, named bridge | Supported modes | Deferred | +| commit and diff | Supported | Post-MVP parity gate | +| pause/unpause | Supported behavior | Deferred until freezer support | +| TEE/attestation/sealing | MicroVM-only | Rejected | +| snapshot-fork/warm VM pool | MicroVM-only | Rejected | +| device/GPU/privileged | Restricted/roadmap | Rejected | +| CRI RuntimeClass | Existing roadmap | Deferred | + +Unsupported combinations fail before rootfs preparation and state mutation. + +## Packaging and deployment + +Runtime pieces should remain independently distributable: + +```text +a3s-box common CLI/control plane; no libkrun linkage +a3s-box-krun-shim optional libkrun-linked MicroVM shim +a3s-box-sandbox-shim OCI lifecycle shim; no libkrun linkage +crun pinned, verified host-sandbox artifact +guest-init shared protocol implementation with two modes +``` + +Recommended release profiles are `full` and `sandbox`. CI verifies that +the sandbox archive and image contain no libkrun library or krun shim and +that the common CLI and sandbox shim have no dynamic libkrun dependency. + +Docker can provide reproducible BuildKit builds and transport release artifacts +to A3S OS. The host runtime itself should execute at the host service layer with +the specific namespace, cgroup, and mount authority it needs. Requiring +`--privileged`, mounting the entire host filesystem, or exposing the Docker +socket to a nested runtime is not an acceptable production design. + +For server validation, the A3S OS host should clone the repository once and use +`git fetch`/`git pull` for each revision. Source trees and large build outputs do +not need to be uploaded from a developer laptop. + +## a3s-bench integration + +The current a3s-bench Box integration performs preflight only. It should not be +used as proof that the new backend executes workloads until a real Box runner is +implemented. + +The future runner should submit the same `ExecutionRequest` used by the CLI and +must not invoke crun directly. A benchmark result records: + +```text +requested_isolation +resolved_backend +isolation_class +execution_policy_digest +runtime_version +``` + +Backend correctness is first validated directly through A3S Box. Bench +integration becomes a later acceptance layer after run/exec/stop and cleanup +are reliable. + +## Remote SDK compatibility boundary + +The E2B-compatible control and data planes described in +[`e2b-compatible-sdk-design.md`](e2b-compatible-sdk-design.md) are consumers of +the backend-neutral `ExecutionManager`; they are not part of the OCI backend. +They must never call `crun` or the sandbox shim directly. + +The host-sandbox MVP can support lifecycle, commands, PTY, files, and Code +Interpreter incrementally, but it is not certified for the complete remote SDK +surface while required observable semantics such as memory-preserving +pause/resume remain deferred. Protocol compatibility and backend certification +are recorded separately so a matching HTTP shape cannot conceal a missing +runtime guarantee. + +## Delivery phases and gates + +### Phase 0: architecture and threat model + +- Approve this document, public isolation terminology, and security boundary. +- Produce the requirement/feature matrix and threat-model review. +- Select and record the certified crun version and artifact verification policy. + +Gate: security and runtime owners agree on what `sandbox` does and does not +promise. No production behavior changes. + +### Phase 1: neutral plan, probe, resolver, and audit + +- Add neutral execution types and the pure resolver. +- Add active `probe` and `doctor` output. +- Persist requested and resolved isolation fields. +- Adapt the existing `VmmProvider` through `KrunBackend`. + +Gate: all resolver combinations are unit tested and existing requests still +resolve to krun by default. + +### Phase 2: guest-init separation + +- Separate bootstrap mode from control transport. +- Add sealed bootstrap descriptor parsing. +- Add inherited Unix listener support for exec and PTY. +- Keep all existing wire protocols unchanged. + +Gate: guest-init transport tests run without a VM, and the MicroVM path has no +behavior regression. + +### Phase 3: minimal OCI backend + +- Add sandbox shim and protected OCI bundle compiler. +- Implement create/start, readiness, exec, logs, stop, wait, and delete. +- Add full UID/GID mapping and basic rootfs providers. + +Gate: a no-KVM Linux host passes run/detach/exec/PTY/logs/stop/rm with no leaked +resources. + +### Phase 4: security and failure hardening + +- Complete seccomp, capability, cgroup, Landlock, mount-path, and network gates. +- Add resource ledger, pidfd tracking, crash recovery, and reboot cleanup. +- Add negative escape and resource-exhaustion tests. + +Gate: mandatory controls are proven by negative tests, not just configuration +inspection. + +### Phase 5: lifecycle parity and a3s-bench + +- Add health/restart, volumes/tmpfs, read-only rootfs, persistence, commit/diff, + and concurrency coverage in the agreed order. +- Implement the a3s-bench Box execution adapter. +- Run compatibility and performance suites on the A3S OS server. + +Gate: the documented compatibility matrix matches observed behavior and every +benchmark result identifies the actual isolation backend. + +## Validation matrix + +### Functional + +- run, foreground, detach, exit-code propagation, and auto-remove; +- exec, PTY, shell, attach, logs/follow, signals, stop, restart, and health; +- bind mounts, named/anonymous volumes, tmpfs, numeric users, read-only rootfs; +- memory OOM, CPU quota/weight, PID limit/fork bomb, and cpuset when supported; +- state reconciliation after shim SIGKILL and host reboot; +- concurrent start/stop/delete and PID-reuse scenarios. + +### Security negatives + +- read/write attempts against unmounted host files and protected paths; +- visibility of host PIDs, IPC objects, cgroups, devices, and network interfaces; +- access to Docker, containerd, Podman, OrbStack, D-Bus, and A3S control sockets; +- namespace creation, mount, keyring, ptrace, BPF, perf, io_uring, and other + syscall cases covered by the selected seccomp threat model; +- symlink swaps and mount-source time-of-check/time-of-use attempts; +- capability escalation, setuid binaries, and nested runtime attempts; +- inability to escape resource limits under exec and restart. + +### Cleanup + +- failure after each preparation step; +- crun create/start failure; +- guest-init readiness timeout; +- shim SIGTERM and SIGKILL; +- workload fork storm and OOM; +- host reboot followed by reconciliation; +- verification that no process, mount, cgroup, socket, bundle, overlay, or + anonymous volume remains unexpectedly. + +### Performance + +Measure each phase independently: + +```text +probe + resolve +rootfs materialization +OCI bundle compilation +crun create/start +guest-init ready +first exec +stop/delete cleanup +``` + +Report cold-start p50/p95/p99, first-exec latency, steady RSS, CPU overhead, +filesystem throughput, network throughput, and high-concurrency behavior. The +initial overhead gate should compare A3S Box host-sandbox orchestration with the +same pinned crun and identical rootfs, excluding image download. Absolute +targets should be set from an A3S OS production-host baseline rather than a +developer laptop. + +### Environments + +- no-KVM Linux CI runner for deterministic unit and integration coverage; +- A3S OS production-like server for privileged kernel controls, cleanup, soak, + and performance validation; +- existing KVM/HVF hosts for MicroVM regression coverage; +- developer laptops only for lightweight formatting and pure tests. + +## Alternatives considered + +### Bubblewrap as the production backend + +Bubblewrap is useful for a proof of concept and demonstrates the Linux +namespace and mount mechanisms directly. Its own documentation describes it as +a mechanism for constructing sandboxes, not a complete policy or lifecycle +runtime. A3S Box would have to reimplement OCI ownership mapping, cgroups, +capabilities, seccomp, exec lifecycle, state reconciliation, and cleanup. It is +therefore not the initial production backend. + +### gVisor systrap first + +Systrap does not require KVM and provides a user-space kernel boundary. It is a +different, stronger-isolation product with syscall compatibility and performance +trade-offs. It is outside the low-isolation host-sandbox scope. + +### QEMU TCG + +TCG retains a VM boundary without KVM but has substantially different +performance and device plumbing. It is appropriate for CI, cross-architecture +compatibility, or diagnostics, not the low-isolation production execution path. + +### Direct host process or chroot + +A chroot is not a security boundary and does not provide the required process, +network, syscall, identity, or resource isolation. This option is rejected. + +### Select any installed OCI runtime + +Different runtime versions have different rootless, seccomp, cgroup, idmapped +mount, and FD-passing behavior. Silent runtime selection makes security and +reproduction unverifiable. The first release accepts only an explicitly +certified artifact. + +## Evidence and references + +- [Bubblewrap security model](https://github.com/containers/bubblewrap/blob/main/README.md#sandbox-security) +- [crun command and cgroup reference](https://github.com/containers/crun/blob/main/crun.1.md) +- [OCI Runtime Specification](https://github.com/opencontainers/runtime-spec) +- [gVisor platform guide](https://gvisor.dev/docs/user_guide/platforms/) + +Relevant current A3S Box extension points: + +- `src/core/src/vmm.rs`: VM-specific `InstanceSpec` and `VmmProvider`. +- `src/core/src/platform.rs`: static platform capability reporting. +- `src/runtime/src/vm/mod.rs`: unconditional krun shim selection. +- `src/runtime/src/rootfs/provider.rs`: reusable rootfs provider boundary. +- `src/guest/init/src/main.rs`: VM-specific bootstrap and mounts. +- `src/guest/init/src/exec_server.rs`: vsock-only exec listener. +- `src/guest/init/src/pty_server.rs`: vsock-only PTY listener. +- `src/cli/src/state/mod.rs`: persisted box state requiring resolution fields. +- [A3S-Lab/Bench README](https://github.com/A3S-Lab/Bench/blob/main/README.md): + current a3s-bench Box provider preflight boundary. diff --git a/docs/p2-deferred-main-spawn-design.md b/docs/p2-deferred-main-spawn-design.md index 4fa859e4..858427df 100644 --- a/docs/p2-deferred-main-spawn-design.md +++ b/docs/p2-deferred-main-spawn-design.md @@ -34,10 +34,11 @@ coverage. Not yet wired: a typed pool API (`Request::SpawnMain`) beyond the CLI. The pool MVP (PR #18) runs a command in a warm VM via the **exec stream**, so its output comes back over the exec protocol, **not** the json-file `logs`. P2 gives a pooled sandbox **full `box` semantics** — the command becomes the VM's real -**container main**, so its exit code flows through the normal `/upper/.a3s_exit_code` -path and its stdout/stderr land in `/logs/container.json` exactly like a -normal `box run`. The VM still skips cold boot (it was pre-warmed), but now behaves -like a first-class box. +**container main**, so its exit code flows through the writable rootfs's +`.a3s_exit_code` file and its stdout/stderr land in `/logs/container.json` +exactly like a normal `box run`. The host resolves that file through the active +overlay, copy, or APFS-backed rootfs layout. The VM still skips cold boot (it was +pre-warmed), but now behaves like a first-class box. ## 2. Verdict & the two crux realizations @@ -92,13 +93,13 @@ companion. spawn closes the fork/registration race) → `set_container_pid(pid)` **while still MANAGED** → drop the guard. The `is_managed` branch covers the pre-publish window; the `pid == container_pid` branch then reaps it, persists `/.a3s_exit_code` - (overlay upper), and `process::exit(code)` halts the VM. The handler replies + (in the active writable rootfs), and `process::exit(code)` halts the VM. The handler replies `spawn-main-ack` only **after** a successful spawn+publish (so a fork failure is reported, not lost). - **Pool integration (#18)** — add `Request::SpawnMain` to `pool.rs`; the daemon sends spawn-main instead of `vm.exec_command`, waits for VM exit (the existing - teardown owns lifecycle), and reads exit code from `/upper/.a3s_exit_code` - and logs from `/logs/container.json`. `Request::Run` stays for back-compat. + teardown owns lifecycle), and reads the provider-specific persisted exit code + plus logs from `/logs/container.json`. `Request::Run` stays for back-compat. ## 4. Risk-ranked blockers (with mitigations) diff --git a/docs/production-cluster-tests.md b/docs/production-cluster-tests.md index edce131a..e2e6caae 100644 --- a/docs/production-cluster-tests.md +++ b/docs/production-cluster-tests.md @@ -95,7 +95,7 @@ with `--from-dir` so the exact local artifacts are installed everywhere. ```bash sudo deploy/scripts/install-runtimeclass.sh \ - --version v2.6.0 \ + --version v3.0.0 \ --from-dir /opt/a3s-box-artifacts \ --warmup-image docker.m.daocloud.io/library/busybox:latest ``` @@ -200,6 +200,28 @@ baseline/after counts. ## Phase 4: Soak Profiles +### macOS single-host fault soak + +The production cluster profiles below require Linux/KVM and Kubernetes. A +separate runner exercises the local CLI runtime on Apple Silicon/HVF without +claiming Linux, CRI, or RuntimeClass coverage: + +```bash +ulimit -n 8192 +caffeinate -dimsu scripts/macos-fault-soak.sh \ + --duration 259200 \ + --sample-interval 300 \ + --fault-interval 900 \ + --output "target/a3s-box-macos-fault-soak/$(date -u +%Y%m%dT%H%M%SZ)" +``` + +Run `--preflight-only` first. The runner rejects unsupported hosts, low file +descriptor limits, less than 100 GiB of free disk, or disk usage above 80 +percent. It uses an evidence-local `A3S_HOME`, targets faults only at its unique +box-name prefix, alternates shim and CLI process termination, asserts that state +remains readable, and requires final shim and box-directory counts to return to +zero. Its result proves only the macOS/HVF single-host product surface. + Use three profiles. Do not skip the shorter profiles; they are the guardrails that keep a 72-hour run from wasting a production window. diff --git a/docs/productization-plan.md b/docs/productization-plan.md index e171eb5a..1922516c 100644 --- a/docs/productization-plan.md +++ b/docs/productization-plan.md @@ -94,6 +94,27 @@ Current notes: inline overrides. CLI `--env` continues to override `--env-file`, Compose `environment` overrides `env_file`, and guest-init receives merged container variables as `BOX_EXEC_ENV_*` instead of dropping them into PID 1 only. +- Compose scalar interpolation now supports unset-versus-empty default and + replacement operators. The project `.env` is loaded first, the invoking + shell overrides it, and expansion completes before typed port validation. +- Detached health checks now run in one generation-fenced child worker per box + instead of a Tokio task owned by the short-lived creating CLI. Compose and + `start`/`restart` use the same worker; the long-running monitor skips a box + while its worker lock is held, preventing duplicate probes. +- `commit` now captures tar headers inside the Linux guest instead of deriving + uid/gid/mode from the macOS virtio-fs backing tree. Persistent boxes also + save a terminal metadata manifest before shutdown. Rootless OCI extraction + carries layer ownership through a protected manifest, applies whiteout + semantics to it, and guest-init replays image then terminal metadata before + mounting procfs, workspaces, or user volumes. The real HVF commit/re-run test + verifies root-owned and `123:456` files plus `0755`, `0750`, `0644`, `0600`, + and `0711` modes. +- macOS virtio-fs no longer manually closes the raw descriptor owned by the + DAX mapping `File`. The previous double-close raced with descriptor reuse and + could make GNU tar report `Cannot close: Bad file descriptor` for unrelated + files in a mounted source tree. Real HVF coverage repeatedly archives a + 2,048-file read-only mount, and the original Node 24/GNU tar repository-copy + workflow has passed against `/Users/roylin/code/os`. - Foreground and interactive `run` cleanup now persists captured exit codes in the box record before marking it stopped, and `wait` prints that recorded code instead of always reporting success for stopped boxes. @@ -161,6 +182,23 @@ Current notes: - Compose labels are persisted alongside A3S project/service labels, and image healthcheck/stop-signal defaults are applied to Compose services when the service does not override or disable them. +- Compose applications now use `compose.acl` as the canonical discovered + project file, parsed through `a3s-acl` with a closed schema, direct + `${...}` interpolation, and `env("NAME")` lookup. Explicit Compose YAML + remains an intentionally bounded compatibility input. `compose up` now + converges services by an effective-config digest and service selections + include only their dependency closure. Project-scoped start/stop/restart, + remove, signal, pause, wait, exec, top, port, copy, image, pull, project, and + volume operations are now exposed; lifecycle, process, copy, and pull + operations delegate to the corresponding single-box paths while project + views stay read-only. Pure regression coverage protects Unicode ACL parsing, + strict service scoping, exact network cleanup, deduplicated volume cleanup, + and partial-start directory teardown. On 2026-07-18, the canonical ACL smoke + passed on macOS arm64/HVF with `docker.io/library/alpine:latest`, covering + unchanged convergence, pull/project views, exec/top/port/copy, + stop/start/restart, pause/unpause, kill/wait/remove, `down -v`, and final + Box/socket cleanup. Linux KVM and the complete host matrix remain release + gates. - Boot failure cleanup now stops any shim that was spawned before readiness, stops bridge-network backends, unmounts rootfs providers before removing box directories, and removes only the anonymous OCI volumes created by that boot @@ -280,13 +318,18 @@ Current notes: - Build documentation now describes an explicit Dockerfile subset instead of claiming full Dockerfile parity. The supported subset is `FROM` (including - `scratch`), shell-form `RUN`, shell-form `COPY`/`ADD`, `WORKDIR`, `ENV`, + `scratch`), shell/exec-form `RUN`, shell-form `COPY`/`ADD`, `WORKDIR`, `ENV`, `ENTRYPOINT`, `CMD`, `EXPOSE`, `LABEL`, `USER`, `ARG`, `SHELL`, `STOPSIGNAL`, `HEALTHCHECK`, `ONBUILD` metadata triggers, and `VOLUME`. -- Unsupported Dockerfile flags and deprecated instructions now fail with - contextual errors rather than being ignored or approximated. Examples: - `RUN` exec form, `COPY`/`ADD` JSON form, `COPY --chown`, `ADD --chown`, and - `MAINTAINER` are rejected. +- Unsupported Dockerfile flags now fail with contextual errors rather than + being ignored or approximated. Examples: `COPY`/`ADD` JSON form and + unsupported `RUN --mount` variants are rejected; warm-pool `RUN` supports + context/stage/image `type=bind`, target-only `type=tmpfs`, and persistent + `type=cache` mounts, including stage/image cache seeding, with explicit + limitations. `RUN --network=default` and `RUN --security=sandbox` are accepted + as no-op Docker defaults, while non-default per-RUN network/security modes + still fail explicitly. Deprecated `MAINTAINER` is accepted as a maintainer + label. - ONBUILD triggers inherited from a base image only run when they map to metadata-only instructions. Triggers that require build execution context, such as `RUN` or `COPY`, fail explicitly until full trigger execution is @@ -296,17 +339,35 @@ Current notes: silently producing a single-platform or wrong-OS image. The default output platform is Linux with the host architecture, not the host OS. - Dockerfile `RUN` no longer has any silent skip path on unsupported hosts. - Linux uses isolated `chroot`; macOS fails by default unless the user explicitly - opts into unsafe host execution with `A3S_BOX_UNSAFE_HOST_RUN=1`. + Linux uses isolated `chroot`; the built-in engine also has an isolated + warm-pool VM lease path via `--run-pool`, which mounts each mutable build + stage rootfs into a leased helper VM and executes shell/exec-form `RUN` + through the guest exec server. macOS auto `RUN` builds still use + BuildKit-in-A3S-VM by default unless `--run-pool` is selected. The BuildKit + VM backend imports OCI output back into the A3S image store by default, with + `--push` / `--plain-http` for direct registry release output and targeted + credential injection into the BuildKit VM. On Apple Silicon, `linux/amd64` + builds are routed through BuildKit's Linux builder path and may use emulation, + so native `linux/arm64` remains faster. The unsafe host execution path still + requires `A3S_BOX_UNSAFE_HOST_RUN=1`. - Linux `RUN` now has explicit preflight diagnostics for the chroot path: non-root builders fail before execution with root-capable builder guidance, configured shells must be absolute and present in the rootfs, and the build workdir is created before chroot execution so `RUN` honors `WORKDIR`. +- Large workspace verification now has a first-class run profile: use + `--package-cache pnpm` (or `--package-cache npm` for npm-only jobs), + `--tmpfs /node_modules`, and per-run `--virtiofs-cache=always` + when the host checkout is stable during release verification. macOS/APFS + rootfs copies prefer recursive `copyfile(3)` cloning before falling back to + byte copies, reducing cached-image startup cost on short-lived build + containers. - CLI build smoke coverage now includes a pure `FROM scratch` build that verifies `COPY`, image metadata, history, save/exported layer contents, and local image - removal without registry or VM access. An ignored Linux-only smoke harness - also covers `RUN` through the chroot path when a local Alpine OCI tar and root - privileges are available. + removal without registry or VM access. Ignored host smoke coverage now also + includes warm-pool `pool run`, `run --pool`, environment auto-routing, and + Dockerfile `RUN` through the warm-pool lease path. A separate ignored + Linux-only smoke harness covers `RUN` through the chroot path when a local + Alpine OCI tar and root privileges are available. - The real core lifecycle smoke harness can now preload an OCI image archive into its isolated `A3S_HOME` via `A3S_BOX_SMOKE_IMAGE_TAR` or `A3S_BOX_TEST_ALPINE_TAR`, so HVF/KVM validation can run offline with the same @@ -530,6 +591,17 @@ Current notes: container rootfs snapshot and surfaced through `ContainerStatus`, while writable, SELinux relabel, non-private propagation, and device mounts fail explicitly until real runtime mount plumbing is added. +- macOS box root filesystems now live on per-box case-sensitive APFS sparse + images before OCI layers are extracted. The provider remounts persistent + generations on restart and detaches/removes ephemeral images during teardown; + a real HVF regression verifies `/bin/sh` and `/BIN/SH` are distinct and that + writable `Foo`/`foo` files retain distinct contents and inodes across restart. +- BuildKit-in-VM writes its full `buildctl` invocation to an owner-private + script in the already-mounted output directory and gives guest init only the + fixed `/bin/sh /out/a3s-buildkit-build.sh` argv. This avoids truncation or + loss in the libkrun `BOX_EXEC_ARG_*` transport. Real HVF coverage verifies + multiple `--build-arg` values, including spaces, and BuildKit failures retain + bounded progress/stderr plus the helper command status. ### Gate 5: Portable Networking @@ -551,10 +623,19 @@ Current notes: macOS netproxy, so unsupported UDP/host-IP/range syntax cannot be silently treated as a TCP listener or forwarded to libkrun. - `a3s-box info` now reports the host platform, VM backend, control channel, - bridge-network backend, published-port support, and TEE availability. The - diagnostics make the macOS bridge-mode boundary explicit: netproxy supports - peer networking and published TCP ports, while outbound NAT remains - unsupported; Linux passt reports peer networking with outbound NAT. + bridge-network backend, published-port support, TEE availability, package + cache state, virtio-fs cache fallback, and reachable warm-pool daemon + aggregate counts. The diagnostics make the macOS bridge-mode boundary + explicit: netproxy supports peer networking and published TCP ports, while + outbound NAT remains unsupported; Linux passt reports peer networking with + outbound NAT. +- The warm-pool daemon now has an abandoned-lease guardrail: + `pool start --lease-ttl ` reclaims idle internal leases whose client + disappeared before release, while leaving active lease execs alone. The + default is `1h`; `0` disables lease reclamation. +- Volume-bound build leases are treated as short-lived stage helpers and are + filled on demand (`min_idle=0`) instead of pre-warming a whole pool for each + unique stage rootfs mount. - The shim now routes macOS bridge-mode published ports through the netproxy path only, avoiding duplicate TSI port-map registration when a box combines `--network` with `-p`. @@ -577,6 +658,13 @@ Current notes: allocation and peer `/etc/hosts` discovery across two macOS HVF boxes, plus pre-start `network connect`/`disconnect` persistence, force-removal state cleanup, and active hot-plug rejection. +- macOS netproxy bridge peers now join a per-network Unix-datagram Ethernet + switch keyed by destination MAC. Unicast frames go directly to the matching + peer, broadcast/multicast frames are flooded while still reaching the local + gateway, and unknown unicast follows normal switch flooding. The switch path + is a short per-UID digest under `/private/tmp`, avoiding the macOS Unix-socket + limit for long A3S homes/network names. Real HVF coverage fetches an HTTP body + between two boxes both by peer name and by assigned IP. ### Gate 6: Confidential Computing @@ -616,18 +704,21 @@ Current notes: 1. Run `scripts/host-integration-smoke.sh --core --host` on both macOS HVF and Linux KVM hosts with the same offline Alpine OCI archive, then record the - exact host/image/test metadata in the release notes. + exact host/image/test metadata in the release notes. The `--host` suite now + includes warm-pool command smoke and Dockerfile `RUN` over the warm-pool + lease path. 2. Run `sudo -E scripts/host-integration-smoke.sh --linux-run --no-pure` on a root-capable Linux host with a local Alpine OCI tar. The Linux chroot path now has root/shell/workdir preflight checks, but still needs real Linux execution validation in this branch. -3. Run and harden the opt-in kubelet/crictl CRI smoke suite on a host with +3. After the warm-pool build smoke passes on both HVF and KVM, decide whether + macOS `build --builder=auto` should keep defaulting to BuildKit-in-A3S-VM for + `RUN` or promote the warm-pool lease path when a daemon/socket is configured. + Keep `A3S_BOX_UNSAFE_HOST_RUN=1` as an explicit experiment-only escape hatch. +4. Run and harden the opt-in kubelet/crictl CRI smoke suite on a host with `crictl`, image availability, and microVM support. Pure unit coverage now verifies one-container and multi-container CRI lifecycle paths through a fake ready VM and exec server, and `src/cri/tests/crictl_smoke.rs` provides the real CRI socket harness. -4. Replace macOS host-side Dockerfile `RUN` execution with an isolated execution - path. It now fails by default and requires `A3S_BOX_UNSAFE_HOST_RUN=1` for - explicit unsafe local experiments. 5. Add a Windows/WHPX command support matrix and make unsupported Windows commands hidden or explicitly documented. diff --git a/justfile b/justfile index ec1b202e..b0956f8c 100644 --- a/justfile +++ b/justfile @@ -19,6 +19,7 @@ build: # Build release release: cd src && cargo build --workspace --release + just build-guest release just sign-shim release # Sign the shim binary with Hypervisor.framework entitlement (macOS) @@ -31,15 +32,45 @@ sign-shim profile="debug": sign-shim profile="debug": @echo "✓ No signing needed on Linux" -# Build guest binaries (cross-compile for Linux aarch64 musl) -build-guest profile="release": - cd src && cargo build -p a3s-box-guest-init --target aarch64-unknown-linux-musl --{{profile}} - @if [ "{{profile}}" = "release" ]; then \ - aarch64-linux-musl-strip src/target/aarch64-unknown-linux-musl/release/a3s-box-guest-init; \ - aarch64-linux-musl-strip src/target/aarch64-unknown-linux-musl/release/a3s-box-nsexec; \ +# Build the static Linux guest-init used as PID 1 inside boxes. +build-guest profile="release" target="": + #!/usr/bin/env bash + set -euo pipefail + target="{{target}}" + if [ -z "$target" ]; then + case "$(uname -m)" in + arm64|aarch64) target="aarch64-unknown-linux-musl" ;; + x86_64|amd64) target="x86_64-unknown-linux-musl" ;; + *) + echo "Unsupported guest-init host architecture: $(uname -m)" >&2 + echo "Pass an explicit target, e.g. just build-guest release x86_64-unknown-linux-musl" >&2 + exit 1 + ;; + esac fi - @echo "Guest binaries built at src/target/aarch64-unknown-linux-musl/{{profile}}/" - @ls -lh src/target/aarch64-unknown-linux-musl/{{profile}}/a3s-box-guest-init src/target/aarch64-unknown-linux-musl/{{profile}}/a3s-box-nsexec 2>/dev/null || true + + case "{{profile}}" in + release) profile_flag="--release"; profile_dir="release" ;; + debug) profile_flag=""; profile_dir="debug" ;; + *) profile_flag="--profile {{profile}}"; profile_dir="{{profile}}" ;; + esac + + cd src + cargo build -p a3s-box-guest-init --target "$target" $profile_flag + + if [ "{{profile}}" = "release" ]; then + case "$target" in + aarch64-unknown-linux-musl) strip_tool="aarch64-linux-musl-strip" ;; + x86_64-unknown-linux-musl) strip_tool="x86_64-linux-musl-strip" ;; + *) strip_tool="" ;; + esac + if [ -n "$strip_tool" ] && command -v "$strip_tool" >/dev/null 2>&1; then + "$strip_tool" "target/$target/$profile_dir/a3s-box-guest-init" + fi + fi + + echo "Guest init built at src/target/$target/$profile_dir/a3s-box-guest-init" + ls -lh "target/$target/$profile_dir/a3s-box-guest-init" # ============================================================================ # Test (unified command with progress display) @@ -189,6 +220,18 @@ test-core: test-skills: cd src && cargo test -p a3s-box-runtime --lib -- skill +# ============================================================================ +# Benchmarks +# ============================================================================ + +# Run benchmark harness (requires KVM for VM-backed benchmarks) +bench target="all": + bench/bench.sh {{target}} + +# Run pnpm install benchmark against a project or the reduced fixture +bench-pnpm project="bench/fixtures/pnpm": + PNPM_PROJECT="{{project}}" bench/bench.sh pnpm + # Test a3s-box-runtime (check only, requires libkrun for actual tests) test-runtime: cd src && A3S_DEPS_STUB=1 cargo check -p a3s-box-runtime -p a3s-box-shim @@ -806,4 +849,3 @@ version: echo " a3s-box-core: $(grep '^version' src/core/Cargo.toml | head -1 | sed 's/.*\"\(.*\)\".*/\1/')" echo " a3s-box-runtime: $(grep '^version' src/runtime/Cargo.toml | head -1 | sed 's/.*\"\(.*\)\".*/\1/')" echo "" - diff --git a/scripts/e2b-production-smoke.sh b/scripts/e2b-production-smoke.sh new file mode 100755 index 00000000..165ef58e --- /dev/null +++ b/scripts/e2b-production-smoke.sh @@ -0,0 +1,928 @@ +#!/usr/bin/env bash +# Destructive lifecycle smoke for the ACL-configured production service. +set -euo pipefail + +API_KEY="e2b_a1b2c3" +CREDENTIAL_HASH='pbkdf2-sha256$100000$03030303030303030303030303030303$6ea6a4ae29bedfcdff6890292ff1410b45211268631889c254502776af12ff4d' +TOKEN_ENCRYPTION="$(printf '07%.0s' {1..32})" +TOKEN_DIGEST="$(printf '08%.0s' {1..32})" +PORT="${A3S_BOX_E2B_SMOKE_PORT:-38081}" +GATEWAY_PORT="${A3S_BOX_E2B_GATEWAY_SMOKE_PORT:-38443}" +GATEWAY_ADDRESS="${A3S_BOX_E2B_GATEWAY_SMOKE_ADDRESS:-127.0.0.1}" +SANDBOX_DOMAIN="${A3S_BOX_E2B_SANDBOX_DOMAIN:-localhost.localdomain}" +SANDBOX_PUBLIC_DOMAIN="$SANDBOX_DOMAIN" +if [[ "$GATEWAY_PORT" != "443" ]]; then + SANDBOX_PUBLIC_DOMAIN="$SANDBOX_DOMAIN:$GATEWAY_PORT" +fi +IMAGE="${A3S_BOX_SMOKE_IMAGE:-alpine:3.20}" +RUNTIME_IMAGE="${A3S_BOX_E2B_RUNTIME_IMAGE:-}" +EXPECTED_TRAFFIC_BODY="sandbox-data-plane" +if [[ -n "$RUNTIME_IMAGE" ]]; then + EXPECTED_TRAFFIC_BODY='"OK"' +fi +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +OFFICIAL_CLIENT_RUNNER="${A3S_BOX_E2B_OFFICIAL_CLIENT_RUNNER:-$SCRIPT_DIR/../compat/e2b/fixtures/official-clients/run_production.py}" +# A dependency-free HTTP/1.1 responder used with BusyBox nc -e. Alpine's +# BusyBox build does not guarantee the optional httpd applet. +HTTP_RESPONDER_B64='IyEvYmluL3NoCndoaWxlIElGUz0gcmVhZCAtciBsaW5lOyBkbwogIFsgIiRsaW5lIiA9ICIkKHByaW50ZiAnXHInKSIgXSAmJiBicmVhawpkb25lCmJvZHk9J3NhbmRib3gtZGF0YS1wbGFuZScKcHJpbnRmICdIVFRQLzEuMSAyMDAgT0tcclxuQ29udGVudC1MZW5ndGg6ICVzXHJcbkNvbm5lY3Rpb246IGNsb3NlXHJcbkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpblxyXG5cclxuJXMnICIkeyNib2R5fSIgIiRib2R5Igo=' +BASH_COMPAT_WRAPPER_B64='IyEvYmluL3NoCmV4ZWMgL2Jpbi9zaCAiJEAiCg==' + +fail() { + printf 'E2B production smoke failed: %s\n' "$*" >&2 + if [[ -n "${LOG:-}" && -f "$LOG" ]]; then + tail -100 "$LOG" >&2 || true + fi + exit 1 +} + +[[ "${A3S_BOX_E2B_SMOKE:-}" == "1" ]] || + fail 'set A3S_BOX_E2B_SMOKE=1 to acknowledge the destructive smoke test' +[[ -n "${A3S_HOME:-}" && -d "$A3S_HOME" ]] || + fail 'A3S_HOME must identify a prepared dedicated runtime home' +[[ "$(basename "$A3S_HOME")" == *e2b-service-smoke* ]] || + fail 'A3S_HOME must have e2b-service-smoke in its final path component' +[[ -x "${A3S_BOX_E2B_BIN:-}" ]] || fail 'A3S_BOX_E2B_BIN must be executable' +[[ -x "${A3S_BOX_CRUN_PATH:-}" ]] || fail 'A3S_BOX_CRUN_PATH must be executable' +[[ "$(realpath "$A3S_BOX_CRUN_PATH")" == "$(realpath "$A3S_HOME/bin/crun")" ]] || + fail 'A3S_BOX_CRUN_PATH must equal A3S_HOME/bin/crun' +[[ -x "$A3S_HOME/bin/a3s-box-guest-init" ]] || fail 'guest init is missing' +[[ -x "$A3S_HOME/bin/a3s-box-shim" ]] || fail 'shim is missing' +[[ "$PORT" =~ ^[0-9]+$ && "$PORT" -gt 0 && "$PORT" -le 65535 ]] || + fail 'A3S_BOX_E2B_SMOKE_PORT must be a valid TCP port' +[[ "$GATEWAY_PORT" =~ ^[0-9]+$ && "$GATEWAY_PORT" -gt 0 && "$GATEWAY_PORT" -le 65535 ]] || + fail 'A3S_BOX_E2B_GATEWAY_SMOKE_PORT must be a valid TCP port' +[[ "$GATEWAY_PORT" != "$PORT" ]] || fail 'control and gateway ports must differ' +if [[ "$GATEWAY_ADDRESS" == *:* ]]; then + GATEWAY_LISTEN="[$GATEWAY_ADDRESS]:$GATEWAY_PORT" + GATEWAY_RESOLVE="[$GATEWAY_ADDRESS]" +else + GATEWAY_LISTEN="$GATEWAY_ADDRESS:$GATEWAY_PORT" + GATEWAY_RESOLVE="$GATEWAY_ADDRESS" +fi +if ! python3 - "$GATEWAY_ADDRESS" <<'PY' +import ipaddress +import sys + +address = ipaddress.ip_address(sys.argv[1]) +if not address.is_loopback: + raise SystemExit(1) +PY +then + fail 'A3S_BOX_E2B_GATEWAY_SMOKE_ADDRESS must be a loopback IP address' +fi +[[ "$SANDBOX_DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || + fail 'A3S_BOX_E2B_SANDBOX_DOMAIN must be a DNS name' +DNS_PREFLIGHT_HOST="a3s-e2b-preflight.$SANDBOX_DOMAIN" +if ! python3 - "$DNS_PREFLIGHT_HOST" "$GATEWAY_ADDRESS" <<'PY' +import ipaddress +import socket +import sys + +hostname = sys.argv[1] +expected = ipaddress.ip_address(sys.argv[2]) +try: + addresses = { + ipaddress.ip_address(item[4][0]) + for item in socket.getaddrinfo(hostname, None) + } +except OSError as error: + raise SystemExit(f"{hostname} did not resolve: {error}") from error +if expected not in addresses: + rendered = ", ".join(sorted(str(address) for address in addresses)) + raise SystemExit( + f"{hostname} resolved to [{rendered}], not gateway {expected}" + ) +PY +then + fail 'Sandbox wildcard DNS does not resolve to the configured loopback gateway' +fi +command -v openssl >/dev/null || fail 'openssl is required for the TLS gateway smoke' +umask 077 +STATE_DIR="$A3S_HOME/e2b-compat-smoke" +CONFIG="$STATE_DIR/service.acl" +LOG="$STATE_DIR/service.log" +TLS_CERT="$STATE_DIR/gateway-cert.pem" +TLS_KEY="$STATE_DIR/gateway-key.pem" +BASE_URL="http://127.0.0.1:$PORT" +SERVICE_PID="" +SANDBOX_ID="" +RESTORED_SANDBOX_ID="" +SNAPSHOT_ID="" +ENVD_TOKEN="" +TRAFFIC_TOKEN="" + +stop_service() { + if [[ -n "$SERVICE_PID" ]] && kill -0 "$SERVICE_PID" 2>/dev/null; then + kill -TERM "$SERVICE_PID" + wait "$SERVICE_PID" || true + fi + SERVICE_PID="" +} + +wait_ready() { + local attempts=0 + while (( attempts < 100 )); do + if curl --silent --output /dev/null \ + --header "X-API-Key: $API_KEY" "$BASE_URL/v2/sandboxes"; then + return 0 + fi + if [[ -n "$SERVICE_PID" ]] && ! kill -0 "$SERVICE_PID" 2>/dev/null; then + printf '%s\n' 'service exited before becoming ready' >&2 + tail -100 "$LOG" >&2 || true + return 1 + fi + attempts=$((attempts + 1)) + sleep 0.1 + done + printf '%s\n' 'service readiness timed out' >&2 + tail -100 "$LOG" >&2 || true + return 1 +} + +start_service() { + : >"$LOG" + env \ + A3S_HOME="$A3S_HOME" \ + A3S_BOX_CRUN_PATH="$A3S_BOX_CRUN_PATH" \ + TOKEN_ENCRYPTION="$TOKEN_ENCRYPTION" \ + TOKEN_DIGEST="$TOKEN_DIGEST" \ + RUST_LOG="${RUST_LOG:-a3s_box_compat=info}" \ + "$A3S_BOX_E2B_BIN" --config "$CONFIG" >"$LOG" 2>&1 & + SERVICE_PID=$! + wait_ready +} + +status_request() { + local method="$1" + local path="$2" + local output="$3" + local body="${4:-}" + local arguments=( + --silent --show-error --output "$output" --write-out '%{http_code}' + --request "$method" --header "X-API-Key: $API_KEY" + ) + if [[ -n "$body" ]]; then + arguments+=(--header 'Content-Type: application/json' --data "$body") + fi + curl "${arguments[@]}" "$BASE_URL$path" +} + +gateway_request() { + local host="$1" + local output="$2" + local token_header="$3" + local token="$4" + local path="$5" + shift 5 + curl --silent --show-error --output "$output" --write-out '%{http_code}' \ + --noproxy '*' \ + --cacert "$TLS_CERT" \ + --resolve "$host:$GATEWAY_PORT:$GATEWAY_RESOLVE" \ + --header "$token_header: $token" \ + "$@" "https://$host:$GATEWAY_PORT$path" +} + +gateway_status() { + local host="$1" + local output="$2" + local token_header="$3" + local token="$4" + shift 4 + gateway_request "$host" "$output" "$token_header" "$token" /health "$@" +} + +wait_gateway_ready() { + local host="$1" + local attempts=0 + local status="" + while (( attempts < 100 )); do + status="$(gateway_status "$host" "$STATE_DIR/gateway-body.txt" X-Access-Token "$ENVD_TOKEN" || true)" + if [[ "$status" == "204" ]]; then + return 0 + fi + if [[ -n "$SERVICE_PID" ]] && ! kill -0 "$SERVICE_PID" 2>/dev/null; then + tail -100 "$LOG" >&2 || true + return 1 + fi + attempts=$((attempts + 1)) + sleep 0.1 + done + printf 'gateway readiness timed out with HTTP %s\n' "$status" >&2 + tail -100 "$LOG" >&2 || true + return 1 +} + +json_field() { + python3 - "$1" "$2" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + value = json.load(source) +for component in sys.argv[2].split("."): + value = value[component] +print(value) +PY +} + +preserve_failure_diagnostics() { + local diagnostics="$STATE_DIR/failure-diagnostics" + local records="$STATE_DIR/managed-executions.json" + mkdir -p "$diagnostics" + [[ -f "$records" ]] || return 0 + + while IFS=$'\t' read -r execution_id pid pid_start_time; do + [[ -n "$execution_id" && "$execution_id" != *[!a-zA-Z0-9._-]* ]] || continue + local execution_diagnostics="$diagnostics/$execution_id" + local box_dir="$A3S_HOME/boxes/$execution_id" + local runtime_root="$A3S_HOME/run/crun/$execution_id" + mkdir -p "$execution_diagnostics" + printf 'pid=%s\npid_start_time=%s\n' "$pid" "$pid_start_time" \ + >"$execution_diagnostics/process-identity.txt" + if [[ "$pid" =~ ^[0-9]+$ && -r "/proc/$pid/stat" ]]; then + cp "/proc/$pid/stat" "$execution_diagnostics/proc-stat.txt" || true + readlink "/proc/$pid/ns/net" \ + >"$execution_diagnostics/network-namespace.txt" 2>&1 || true + fi + if [[ -d "$box_dir/logs" ]]; then + cp -a "$box_dir/logs" "$execution_diagnostics/logs" || true + fi + for relative_path in \ + sandbox/runtime.json \ + sandbox/bundle/config.json \ + sandbox/bundle/execution-plan.json \ + sandbox/bundle/capabilities.json; do + if [[ -f "$box_dir/$relative_path" ]]; then + mkdir -p "$execution_diagnostics/$(dirname "$relative_path")" + cp "$box_dir/$relative_path" \ + "$execution_diagnostics/$relative_path" || true + fi + done + # crun materializes a missing --root directory even for an absent + # container. Keep failure diagnostics side-effect free when startup did + # not reach the runtime. + if [[ -e "$runtime_root" ]]; then + "$A3S_BOX_CRUN_PATH" --root "$runtime_root" state "$execution_id" \ + >"$execution_diagnostics/crun-state.json" \ + 2>"$execution_diagnostics/crun-state.stderr" || true + else + printf 'runtime root was absent; crun state probe skipped\n' \ + >"$execution_diagnostics/crun-state.stderr" + fi + done < <(python3 - "$records" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + records = json.load(source) +for record in records: + print( + record.get("id", ""), + record.get("pid") or "", + record.get("pid_start_time") or "", + sep="\t", + ) +PY +) +} + +cleanup() { + local exit_code=$? + trap - EXIT INT TERM + set +e + if [[ "$exit_code" -ne 0 && "${A3S_BOX_E2B_KEEP_STATE_ON_FAILURE:-}" == "1" ]]; then + preserve_failure_diagnostics + fi + if [[ -n "$SANDBOX_ID" || -n "$RESTORED_SANDBOX_ID" || -n "$SNAPSHOT_ID" ]]; then + if [[ -z "$SERVICE_PID" ]] || ! kill -0 "$SERVICE_PID" 2>/dev/null; then + start_service >/dev/null 2>&1 + fi + fi + if [[ -n "$RESTORED_SANDBOX_ID" ]]; then + status_request DELETE "/sandboxes/$RESTORED_SANDBOX_ID" /dev/null >/dev/null 2>&1 + fi + if [[ -n "$SANDBOX_ID" ]]; then + status_request DELETE "/sandboxes/$SANDBOX_ID" /dev/null >/dev/null 2>&1 + fi + if [[ -n "$SNAPSHOT_ID" ]]; then + status_request DELETE "/templates/$SNAPSHOT_ID" /dev/null >/dev/null 2>&1 + fi + stop_service + if [[ "$exit_code" -ne 0 && "${A3S_BOX_E2B_KEEP_STATE_ON_FAILURE:-}" == "1" ]]; then + printf 'Preserved failed smoke state at %s\n' "$STATE_DIR" >&2 + else + rm -rf "$STATE_DIR" + fi + exit "$exit_code" +} +trap cleanup EXIT INT TERM + +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR" +openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 1 \ + -subj "/CN=*.$SANDBOX_DOMAIN" \ + -addext "subjectAltName=DNS:*.$SANDBOX_DOMAIN,DNS:sandbox.$SANDBOX_DOMAIN" \ + -keyout "$TLS_KEY" -out "$TLS_CERT" >/dev/null 2>&1 +openssl verify -CAfile "$TLS_CERT" -verify_hostname "$DNS_PREFLIGHT_HOST" \ + "$TLS_CERT" >/dev/null || + fail 'generated wildcard TLS certificate does not cover Sandbox routes' +cat >"$CONFIG" <>"$CONFIG" <>"$CONFIG" <>"$CONFIG" </dev/null 2>&1 || true; printf '%s' '$BASH_COMPAT_WRAPPER_B64' | /bin/busybox base64 -d > /bin/bash && chmod 755 /bin/bash && mkdir -p /tmp/e2b-smoke && printf '%s' '$HTTP_RESPONDER_B64' | /bin/busybox base64 -d > /tmp/e2b-smoke/respond && chmod 755 /tmp/e2b-smoke/respond && exec /bin/busybox nc -lk -p 49999 -e /tmp/e2b-smoke/respond"] + + resources { + vcpus = 2 + memory_mb = 512 + disk_mb = 1024 + } + + route { + port = 49999 + token_scope = "traffic" + } +EOF + fi + + printf ' }\n' >>"$CONFIG" +} + +append_template_policy fixture-template +append_template_policy code-interpreter-v1 +printf '}\n' >>"$CONFIG" + +start_service + +CREATE_RESPONSE="$STATE_DIR/create.json" +CREATE_STATUS="$(status_request POST /sandboxes "$CREATE_RESPONSE" \ + '{"templateID":"fixture-template","timeout":60,"metadata":{"test":"production-service"},"envVars":{"SMOKE":"true"},"secure":true,"allow_internet_access":false}')" +[[ "$CREATE_STATUS" == "201" ]] || fail "create returned HTTP $CREATE_STATUS" +SANDBOX_ID="$(json_field "$CREATE_RESPONSE" sandboxID)" +[[ "$SANDBOX_ID" == sandbox-* ]] || fail 'create returned an invalid sandbox ID' +[[ "$(json_field "$CREATE_RESPONSE" domain)" == "$SANDBOX_PUBLIC_DOMAIN" ]] || + fail 'create returned the wrong sandbox domain' +ENVD_TOKEN="$(json_field "$CREATE_RESPONSE" envdAccessToken)" +TRAFFIC_TOKEN="$(json_field "$CREATE_RESPONSE" trafficAccessToken)" +[[ -n "$ENVD_TOKEN" ]] || + fail 'create omitted the envd access token' +[[ -n "$TRAFFIC_TOKEN" ]] || + fail 'create omitted the traffic access token' + +V1_LIST_RESPONSE="$STATE_DIR/list-v1.json" +[[ "$(status_request GET '/sandboxes?metadata=test%3Dproduction-service' "$V1_LIST_RESPONSE")" == "200" ]] || + fail 'v1 sandbox list did not return HTTP 200' +if ! python3 - "$V1_LIST_RESPONSE" "$SANDBOX_ID" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + sandboxes = json.load(source) +if not any(sandbox.get("sandboxID") == sys.argv[2] for sandbox in sandboxes): + raise SystemExit("created Sandbox was absent") +PY +then + fail 'v1 sandbox list omitted the created Sandbox' +fi + +REFRESH_BEFORE_RESPONSE="$STATE_DIR/refresh-before.json" +[[ "$(status_request GET "/sandboxes/$SANDBOX_ID" "$REFRESH_BEFORE_RESPONSE")" == "200" ]] || + fail 'sandbox detail before refresh did not return HTTP 200' +REFRESH_BEFORE_END_AT="$(json_field "$REFRESH_BEFORE_RESPONSE" endAt)" +[[ "$(status_request POST "/sandboxes/$SANDBOX_ID/refreshes" /dev/null '{"duration":55}')" == "204" ]] || + fail 'sandbox refresh did not return HTTP 204' +[[ "$(status_request POST "/sandboxes/$SANDBOX_ID/refreshes" /dev/null '{}')" == "204" ]] || + fail 'sandbox refresh with an empty object did not return HTTP 204' +[[ "$(status_request POST "/sandboxes/$SANDBOX_ID/refreshes" /dev/null)" == "204" ]] || + fail 'sandbox refresh without a request body did not return HTTP 204' +REFRESH_UNCHANGED_RESPONSE="$STATE_DIR/refresh-unchanged.json" +[[ "$(status_request GET "/sandboxes/$SANDBOX_ID" "$REFRESH_UNCHANGED_RESPONSE")" == "200" ]] || + fail 'sandbox detail after short refresh did not return HTTP 200' +[[ "$(json_field "$REFRESH_UNCHANGED_RESPONSE" endAt)" == "$REFRESH_BEFORE_END_AT" ]] || + fail 'sandbox refresh shortened the existing timeout' +[[ "$(status_request POST "/sandboxes/$SANDBOX_ID/refreshes" /dev/null '{"duration":3600}')" == "204" ]] || + fail 'sandbox refresh extension did not return HTTP 204' +REFRESH_EXTENDED_RESPONSE="$STATE_DIR/refresh-extended.json" +[[ "$(status_request GET "/sandboxes/$SANDBOX_ID" "$REFRESH_EXTENDED_RESPONSE")" == "200" ]] || + fail 'sandbox detail after extended refresh did not return HTTP 200' +if ! python3 - "$REFRESH_BEFORE_RESPONSE" "$REFRESH_EXTENDED_RESPONSE" <<'PY' +import datetime +import json +import sys + + +def end_at(path: str) -> datetime.datetime: + with open(path, encoding="utf-8") as source: + value = json.load(source)["endAt"] + return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + + +if end_at(sys.argv[2]) <= end_at(sys.argv[1]): + raise SystemExit("refresh did not extend the sandbox timeout") +PY +then + fail 'sandbox refresh did not extend the existing timeout' +fi + +DIRECT_HOST="49983-$SANDBOX_ID.$SANDBOX_DOMAIN" +wait_gateway_ready "$DIRECT_HOST" || fail 'TLS direct route did not become ready' +[[ ! -s "$STATE_DIR/gateway-body.txt" ]] || + fail 'envd health returned an unexpected response body' +[[ "$(gateway_status "$DIRECT_HOST" /dev/null X-Access-Token wrong-token)" == "401" ]] || + fail 'TLS gateway accepted an invalid envd token' +[[ "$(gateway_status "$DIRECT_HOST" /dev/null E2B-Traffic-Access-Token "$TRAFFIC_TOKEN")" == "401" ]] || + fail 'TLS gateway accepted a traffic token for the envd scope' +[[ "$(gateway_status "sandbox.$SANDBOX_DOMAIN" "$STATE_DIR/shared-body.txt" X-Access-Token "$ENVD_TOKEN" \ + --header "E2b-Sandbox-Id: $SANDBOX_ID" --header 'E2b-Sandbox-Port: 49983')" == "204" ]] || + fail 'TLS shared envd route did not return HTTP 204' +[[ ! -s "$STATE_DIR/shared-body.txt" ]] || + fail 'TLS shared envd health returned an unexpected response body' + +CONTROL_LOGS_V1="$STATE_DIR/control-logs-v1.json" +CONTROL_LOGS_V2="$STATE_DIR/control-logs-v2.json" +[[ "$(status_request GET "/sandboxes/$SANDBOX_ID/logs?start=0&limit=1000" "$CONTROL_LOGS_V1")" == "200" ]] || + fail 'control-plane v1 logs did not return HTTP 200' +[[ "$(status_request GET "/v2/sandboxes/$SANDBOX_ID/logs?direction=backward&limit=1000" "$CONTROL_LOGS_V2")" == "200" ]] || + fail 'control-plane v2 logs did not return HTTP 200' +if ! python3 - "$CONTROL_LOGS_V1" "$CONTROL_LOGS_V2" <<'PY' +import datetime +import json +import sys + + +def timestamp(value: str) -> datetime.datetime: + return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + + +with open(sys.argv[1], encoding="utf-8") as source: + legacy = json.load(source) +with open(sys.argv[2], encoding="utf-8") as source: + current = json.load(source) + +legacy_lines = legacy.get("logs") +legacy_entries = legacy.get("logEntries") +current_entries = current.get("logs") +if not legacy_lines or not legacy_entries or not current_entries: + raise SystemExit("runtime logs were empty") + +for item in legacy_lines: + timestamp(item["timestamp"]) + line = json.loads(item["line"]) + if line.get("logger") != "a3s-box-runtime" or line.get("stream") not in {"stdout", "stderr"}: + raise SystemExit(f"invalid legacy log line: {line!r}") + +for item in [*legacy_entries, *current_entries]: + timestamp(item["timestamp"]) + if item.get("level") not in {"debug", "info", "warn", "error"}: + raise SystemExit(f"invalid log level: {item!r}") + if not isinstance(item.get("message"), str): + raise SystemExit(f"invalid log message: {item!r}") + if item.get("fields", {}).get("stream") not in {"stdout", "stderr"}: + raise SystemExit(f"invalid structured log fields: {item!r}") + +legacy_times = [timestamp(item["timestamp"]) for item in legacy_entries] +current_times = [timestamp(item["timestamp"]) for item in current_entries] +if legacy_times != sorted(legacy_times): + raise SystemExit("v1 logs were not ordered forward") +if current_times != sorted(current_times, reverse=True): + raise SystemExit("v2 backward logs were not ordered backward") +PY +then + fail 'control-plane runtime logs violated the pinned schemas or ordering' +fi + +if [[ -n "$RUNTIME_IMAGE" ]]; then + METRICS_RESPONSE="$STATE_DIR/envd-metrics.json" + [[ "$(gateway_request "$DIRECT_HOST" "$METRICS_RESPONSE" X-Access-Token "$ENVD_TOKEN" /metrics)" == "200" ]] || + fail 'runtime envd metrics did not return HTTP 200' + if ! python3 - "$METRICS_RESPONSE" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + metrics = json.load(source) +integer_fields = ("ts", "cpu_count", "mem_total", "mem_used", "disk_used", "disk_total") +for field in integer_fields: + if type(metrics.get(field)) is not int or metrics[field] < 0: + raise SystemExit(f"invalid non-negative integer metric {field!r}: {metrics.get(field)!r}") +if type(metrics.get("cpu_used_pct")) not in (int, float) or metrics["cpu_used_pct"] < 0: + raise SystemExit(f"invalid cpu_used_pct: {metrics.get('cpu_used_pct')!r}") +if type(metrics.get("cpu_count")) is not int or metrics["cpu_count"] < 1: + raise SystemExit(f"invalid cpu_count: {metrics.get('cpu_count')!r}") +PY + then + fail 'runtime envd metrics violated the pinned schema' + fi + + ENVS_RESPONSE="$STATE_DIR/envd-envs.json" + [[ "$(gateway_request "$DIRECT_HOST" "$ENVS_RESPONSE" X-Access-Token "$ENVD_TOKEN" /envs)" == "200" ]] || + fail 'runtime envd environment did not return HTTP 200' + if ! python3 - "$ENVS_RESPONSE" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + environment = json.load(source) +if environment.get("SMOKE") != "true": + raise SystemExit(f"unexpected SMOKE value: {environment.get('SMOKE')!r}") +PY + then + fail 'runtime envd environment omitted the create-time variable' + fi + + # `/tmp` is an OCI tmpfs mount and is intentionally outside a filesystem + # Snapshot. Exercise envd transfer and Snapshot persistence on the writable + # rootfs instead. + ENVD_FILE_PATH='/home/user/a3s-box-envd-http-smoke.txt' + ENVD_FILE_QUERY='/files?path=%2Fhome%2Fuser%2Fa3s-box-envd-http-smoke.txt&username=user' + ENVD_FILE_SOURCE="$STATE_DIR/envd-upload.txt" + ENVD_UPLOAD_RESPONSE="$STATE_DIR/envd-upload.json" + ENVD_DOWNLOAD_RESPONSE="$STATE_DIR/envd-download.txt" + printf '%s' 'A3S Box runtime envd HTTP transfer' >"$ENVD_FILE_SOURCE" + [[ "$(gateway_request "$DIRECT_HOST" "$ENVD_UPLOAD_RESPONSE" X-Access-Token "$ENVD_TOKEN" \ + "$ENVD_FILE_QUERY" --request POST \ + --header 'X-Metadata-A3S-Smoke: envd-http' \ + --form "file=@$ENVD_FILE_SOURCE;filename=a3s-box-envd-http-smoke.txt")" == "200" ]] || + fail 'runtime envd file upload did not return HTTP 200' + if ! python3 - "$ENVD_UPLOAD_RESPONSE" "$ENVD_FILE_PATH" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + entries = json.load(source) +if not isinstance(entries, list) or len(entries) != 1: + raise SystemExit(f"unexpected upload response: {entries!r}") +entry = entries[0] +if entry.get("path") != sys.argv[2] or entry.get("name") != "a3s-box-envd-http-smoke.txt": + raise SystemExit(f"unexpected uploaded entry: {entry!r}") +if entry.get("type") != "file": + raise SystemExit(f"unexpected uploaded entry type: {entry!r}") +if entry.get("metadata", {}).get("a3s-smoke") != "envd-http": + raise SystemExit(f"uploaded metadata was not preserved: {entry!r}") +PY + then + fail 'runtime envd file upload violated the pinned schema' + fi + [[ "$(gateway_request "$DIRECT_HOST" "$ENVD_DOWNLOAD_RESPONSE" X-Access-Token "$ENVD_TOKEN" \ + "$ENVD_FILE_QUERY")" == "200" ]] || + fail 'runtime envd file download did not return HTTP 200' + cmp --silent "$ENVD_FILE_SOURCE" "$ENVD_DOWNLOAD_RESPONSE" || + fail 'runtime envd file download differed from the uploaded content' + [[ "$(gateway_request "$DIRECT_HOST" /dev/null X-Access-Token wrong-token /metrics)" == "401" ]] || + fail 'runtime envd metrics accepted an invalid token' + + CONTROL_METRICS_RESPONSE="$STATE_DIR/control-metrics.json" + [[ "$(status_request GET "/sandboxes/metrics?sandbox_ids=$SANDBOX_ID" "$CONTROL_METRICS_RESPONSE")" == "200" ]] || + fail 'control-plane batch metrics did not return HTTP 200' + if ! python3 - "$CONTROL_METRICS_RESPONSE" "$SANDBOX_ID" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + response = json.load(source) +metric = response.get("sandboxes", {}).get(sys.argv[2]) +required = ( + "timestamp", + "timestampUnix", + "cpuCount", + "cpuUsedPct", + "memUsed", + "memTotal", + "memCache", + "diskUsed", + "diskTotal", +) +if not isinstance(metric, dict) or any(field not in metric for field in required): + raise SystemExit(f"invalid batch metric: {metric!r}") +PY + then + fail 'control-plane batch metrics violated the pinned schema' + fi +fi + +TRAFFIC_HOST="49999-$SANDBOX_ID.$SANDBOX_DOMAIN" +[[ "$(gateway_status "$TRAFFIC_HOST" "$STATE_DIR/traffic-body.txt" E2B-Traffic-Access-Token "$TRAFFIC_TOKEN")" == "200" ]] || + fail 'TLS traffic route did not return HTTP 200' +[[ "$(cat "$STATE_DIR/traffic-body.txt")" == "$EXPECTED_TRAFFIC_BODY" ]] || + fail 'TLS traffic route returned the wrong Sandbox response body' +[[ "$(gateway_status "$TRAFFIC_HOST" /dev/null E2B-Traffic-Access-Token "$ENVD_TOKEN")" == "401" ]] || + fail 'TLS traffic route accepted an envd token' + +DETAIL_RESPONSE="$STATE_DIR/detail.json" +[[ "$(status_request GET "/sandboxes/$SANDBOX_ID" "$DETAIL_RESPONSE")" == "200" ]] || + fail 'get did not return HTTP 200' +[[ "$(json_field "$DETAIL_RESPONSE" state)" == "running" ]] || + fail 'new sandbox is not running' + +SNAPSHOT_RESPONSE="$STATE_DIR/snapshot-create.json" +[[ "$(status_request POST "/sandboxes/$SANDBOX_ID/snapshots" "$SNAPSHOT_RESPONSE" '{}')" == "201" ]] || + fail 'filesystem Snapshot creation did not return HTTP 201' +SNAPSHOT_ID="$(json_field "$SNAPSHOT_RESPONSE" snapshotID)" +[[ "$SNAPSHOT_ID" == snap-*:default ]] || + fail 'filesystem Snapshot creation returned an invalid reference' +if ! python3 - "$SNAPSHOT_RESPONSE" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + snapshot = json.load(source) +if snapshot.get("names") != []: + raise SystemExit(f"unnamed Snapshot returned names: {snapshot!r}") +PY +then + fail 'unnamed filesystem Snapshot violated the pinned response schema' +fi +SNAPSHOT_LIST_RESPONSE="$STATE_DIR/snapshot-list.json" +[[ "$(status_request GET "/snapshots?sandboxID=$SANDBOX_ID&limit=1" "$SNAPSHOT_LIST_RESPONSE")" == "200" ]] || + fail 'filesystem Snapshot list did not return HTTP 200' +if ! python3 - "$SNAPSHOT_LIST_RESPONSE" "$SNAPSHOT_ID" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + snapshots = json.load(source) +if [item.get("snapshotID") for item in snapshots] != [sys.argv[2]]: + raise SystemExit(f"created Snapshot was absent from its source list: {snapshots!r}") +PY +then + fail 'filesystem Snapshot list omitted the created Snapshot' +fi +[[ "$(status_request GET "/sandboxes/$SANDBOX_ID" "$DETAIL_RESPONSE")" == "200" ]] || + fail 'source Sandbox was unavailable after Snapshot creation' +[[ "$(json_field "$DETAIL_RESPONSE" state)" == "running" ]] || + fail 'Snapshot creation did not restore the source running state' + +stop_service +start_service + +[[ "$(status_request GET "/sandboxes/$SANDBOX_ID" "$DETAIL_RESPONSE")" == "200" ]] || + fail 'sandbox was unavailable after service restart' +[[ "$(json_field "$DETAIL_RESPONSE" state)" == "running" ]] || + fail 'startup reconciliation did not preserve the running sandbox' +wait_gateway_ready "$DIRECT_HOST" || fail 'TLS route was unavailable after service restart' +[[ "$(status_request GET "/snapshots?sandboxID=$SANDBOX_ID&limit=1" "$SNAPSHOT_LIST_RESPONSE")" == "200" ]] || + fail 'filesystem Snapshot list failed after service restart' +if ! python3 - "$SNAPSHOT_LIST_RESPONSE" "$SNAPSHOT_ID" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + snapshots = json.load(source) +if not any(item.get("snapshotID") == sys.argv[2] for item in snapshots): + raise SystemExit("persisted Snapshot was absent after service restart") +PY +then + fail 'filesystem Snapshot did not survive service restart reconciliation' +fi + +CONNECT_RESPONSE="$STATE_DIR/connect.json" +[[ "$(status_request POST "/sandboxes/$SANDBOX_ID/connect" "$CONNECT_RESPONSE" '{"timeout":45}')" == "200" ]] || + fail 'connect did not return HTTP 200' +[[ "$(json_field "$CONNECT_RESPONSE" sandboxID)" == "$SANDBOX_ID" ]] || + fail 'connect returned a different sandbox ID' + +[[ "$(status_request POST "/sandboxes/$SANDBOX_ID/timeout" /dev/null '{"timeout":30}')" == "204" ]] || + fail 'timeout replacement did not return HTTP 204' +[[ "$(status_request DELETE "/sandboxes/$SANDBOX_ID" /dev/null)" == "204" ]] || + fail 'kill did not return HTTP 204' +[[ "$(gateway_status "$DIRECT_HOST" /dev/null X-Access-Token "$ENVD_TOKEN")" == "502" ]] || + fail 'authenticated envd health did not report the killed sandbox as stopped' +[[ "$(gateway_status "$DIRECT_HOST" /dev/null X-Access-Token wrong-token)" == "401" ]] || + fail 'terminal envd health accepted an invalid token' +[[ "$(gateway_status "$TRAFFIC_HOST" /dev/null E2B-Traffic-Access-Token "$TRAFFIC_TOKEN")" == "404" ]] || + fail 'stale TLS traffic route remained available after kill' + +EXECUTION_ID="$(python3 - "$STATE_DIR/managed-executions.json" "$SANDBOX_ID" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + records = json.load(source) +matches = [ + record for record in records + if record.get("managed_execution", {}).get("request", {}).get("external_sandbox_id") == sys.argv[2] +] +if len(matches) != 1: + raise SystemExit("managed execution record is missing or ambiguous") +if matches[0].get("status") != "stopped": + raise SystemExit("managed execution did not persist stopped state") +print(matches[0]["id"]) +PY +)" +[[ ! -e "$A3S_HOME/boxes/$EXECUTION_ID" ]] || fail 'box directory leaked after kill' +[[ ! -e "$A3S_HOME/run/crun/$EXECUTION_ID" ]] || fail 'crun state leaked after kill' +[[ ! -e "/tmp/a3s-box-sockets/$EXECUTION_ID" ]] || fail 'runtime socket directory leaked after kill' + +RESTORE_BODY="$(python3 - "$SNAPSHOT_ID" <<'PY' +import json +import sys + +print(json.dumps({"templateID": sys.argv[1], "timeout": 60})) +PY +)" +RESTORE_RESPONSE="$STATE_DIR/snapshot-restore.json" +[[ "$(status_request POST /sandboxes "$RESTORE_RESPONSE" "$RESTORE_BODY")" == "201" ]] || + fail 'filesystem Snapshot restore did not return HTTP 201' +RESTORED_SANDBOX_ID="$(json_field "$RESTORE_RESPONSE" sandboxID)" +[[ "$RESTORED_SANDBOX_ID" == sandbox-* ]] || + fail 'filesystem Snapshot restore returned an invalid Sandbox ID' +ENVD_TOKEN="$(json_field "$RESTORE_RESPONSE" envdAccessToken)" +TRAFFIC_TOKEN="$(json_field "$RESTORE_RESPONSE" trafficAccessToken)" +RESTORED_DIRECT_HOST="49983-$RESTORED_SANDBOX_ID.$SANDBOX_DOMAIN" +RESTORED_TRAFFIC_HOST="49999-$RESTORED_SANDBOX_ID.$SANDBOX_DOMAIN" +wait_gateway_ready "$RESTORED_DIRECT_HOST" || + fail 'restored Sandbox envd route did not become ready' +[[ "$(gateway_status "$RESTORED_TRAFFIC_HOST" "$STATE_DIR/restored-traffic-body.txt" E2B-Traffic-Access-Token "$TRAFFIC_TOKEN")" == "200" ]] || + fail 'restored Sandbox traffic route did not return HTTP 200' +[[ "$(cat "$STATE_DIR/restored-traffic-body.txt")" == "$EXPECTED_TRAFFIC_BODY" ]] || + fail 'restored Sandbox traffic route returned the wrong response body' +if [[ -n "$RUNTIME_IMAGE" ]]; then + RESTORED_FILE="$STATE_DIR/restored-envd-download.txt" + [[ "$(gateway_request "$RESTORED_DIRECT_HOST" "$RESTORED_FILE" X-Access-Token "$ENVD_TOKEN" "$ENVD_FILE_QUERY")" == "200" ]] || + fail 'restored Sandbox did not expose the captured filesystem file' + cmp --silent "$ENVD_FILE_SOURCE" "$RESTORED_FILE" || + fail 'restored Sandbox filesystem content differed from the Snapshot source' +fi +[[ "$(status_request DELETE "/templates/$SNAPSHOT_ID" /dev/null)" == "409" ]] || + fail 'active restored Sandbox did not protect its filesystem Snapshot' +[[ "$(status_request DELETE "/sandboxes/$RESTORED_SANDBOX_ID" /dev/null)" == "204" ]] || + fail 'restored Sandbox kill did not return HTTP 204' + +RESTORED_EXECUTION_ID="$(python3 - "$STATE_DIR/managed-executions.json" "$RESTORED_SANDBOX_ID" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + records = json.load(source) +matches = [ + record for record in records + if record.get("managed_execution", {}).get("request", {}).get("external_sandbox_id") == sys.argv[2] +] +if len(matches) != 1 or matches[0].get("status") != "stopped": + raise SystemExit("restored managed execution did not persist stopped state") +print(matches[0]["id"]) +PY +)" +[[ ! -e "$A3S_HOME/boxes/$RESTORED_EXECUTION_ID" ]] || + fail 'restored box directory leaked after kill' +[[ ! -e "$A3S_HOME/run/crun/$RESTORED_EXECUTION_ID" ]] || + fail 'restored crun state leaked after kill' +[[ ! -e "/tmp/a3s-box-sockets/$RESTORED_EXECUTION_ID" ]] || + fail 'restored runtime socket directory leaked after kill' +RESTORED_SANDBOX_ID="" + +[[ "$(status_request DELETE "/templates/$SNAPSHOT_ID" /dev/null)" == "204" ]] || + fail 'detached filesystem Snapshot deletion did not return HTTP 204' +[[ "$(status_request DELETE "/templates/$SNAPSHOT_ID" /dev/null)" == "404" ]] || + fail 'repeated filesystem Snapshot deletion did not return HTTP 404' +[[ "$(status_request GET "/snapshots?sandboxID=$SANDBOX_ID" "$SNAPSHOT_LIST_RESPONSE")" == "200" ]] || + fail 'filesystem Snapshot list failed after deletion' +if ! python3 - "$SNAPSHOT_LIST_RESPONSE" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + snapshots = json.load(source) +if snapshots: + raise SystemExit(f"deleted Snapshot remained listed: {snapshots!r}") +PY +then + fail 'deleted filesystem Snapshot remained visible' +fi +if [[ -d "$A3S_HOME/snapshots" ]] && + find "$A3S_HOME/snapshots" -name metadata.json -print -quit | grep -q .; then + fail 'filesystem Snapshot content leaked after deletion' +fi +SNAPSHOT_ID="" +SANDBOX_ID="" + +if [[ "${A3S_BOX_E2B_OFFICIAL_CLIENTS:-}" == "1" ]]; then + [[ -f "$OFFICIAL_CLIENT_RUNNER" ]] || + fail "official-client runner is missing: $OFFICIAL_CLIENT_RUNNER" + OFFICIAL_CLIENT_ARGS=( + --api-url "$BASE_URL" + --domain "$SANDBOX_DOMAIN" + --template fixture-template + ) + if [[ -n "${A3S_BOX_E2B_PIP_BOOTSTRAP_WHEEL:-}" ]]; then + OFFICIAL_CLIENT_ARGS+=( + --pip-bootstrap-wheel "$A3S_BOX_E2B_PIP_BOOTSTRAP_WHEEL" + ) + fi + if [[ -n "${A3S_BOX_E2B_ARTIFACT_CACHE:-}" ]]; then + OFFICIAL_CLIENT_ARGS+=(--artifact-cache "$A3S_BOX_E2B_ARTIFACT_CACHE") + fi + if [[ "${A3S_BOX_E2B_NATIVE_SDKS:-}" == "1" ]]; then + OFFICIAL_CLIENT_ARGS+=(--native-sdks) + fi + SMOKE_NO_PROXY="${NO_PROXY:+$NO_PROXY,}$SANDBOX_DOMAIN,.$SANDBOX_DOMAIN,127.0.0.1,localhost" + E2B_API_KEY="$API_KEY" \ + SSL_CERT_FILE="$TLS_CERT" \ + NODE_EXTRA_CA_CERTS="$TLS_CERT" \ + NO_PROXY="$SMOKE_NO_PROXY" \ + no_proxy="$SMOKE_NO_PROXY" \ + "${A3S_BOX_E2B_OFFICIAL_PYTHON:-python3}" \ + "$OFFICIAL_CLIENT_RUNNER" "${OFFICIAL_CLIENT_ARGS[@]}" + + python3 - "$STATE_DIR/managed-executions.json" "$A3S_HOME" <<'PY' +import json +import pathlib +import sys + +records_path = pathlib.Path(sys.argv[1]) +home = pathlib.Path(sys.argv[2]) +with records_path.open(encoding="utf-8") as source: + records = json.load(source) +for record in records: + execution_id = record["id"] + if record.get("status") != "stopped": + raise SystemExit(f"managed execution {execution_id} is not stopped") + for path in ( + home / "boxes" / execution_id, + home / "run" / "crun" / execution_id, + pathlib.Path("/tmp/a3s-box-sockets") / execution_id, + ): + if path.exists(): + raise SystemExit(f"runtime resource leaked after official clients: {path}") +PY +fi + +stop_service +printf 'E2B production smoke passed: lifecycle, filesystem Snapshot restart/restore/delete, Sandbox logs, envd HTTP, TLS traffic proxy, credentials, official clients when enabled, and cleanup\n' diff --git a/scripts/host-integration-smoke.sh b/scripts/host-integration-smoke.sh index 34d8547a..f031f8e6 100755 --- a/scripts/host-integration-smoke.sh +++ b/scripts/host-integration-smoke.sh @@ -44,7 +44,7 @@ Options: --pure Run stub-backed fmt, clippy, lib tests, and integration compile checks (default). --no-pure Skip the stub-backed baseline checks. --core Run the ignored real MicroVM core_smoke suite. - --host Run ignored host_smoke VM, Compose, and optional registry suites. + --host Run ignored host_smoke VM, warm-pool, Compose, and optional registry suites. --linux-run Run the Linux-only Dockerfile RUN chroot smoke. --cri Run the ignored crictl CRI smoke with A3S_BOX_CRI_SMOKE=1. --all Run --core, --host, --linux-run, and --cri after the pure checks. @@ -255,6 +255,15 @@ host_arch() { stub_dir="" +cleanup_stub_libkrun() { + if [ -n "$stub_dir" ] && [ -d "$stub_dir" ]; then + rm -rf -- "$stub_dir" + fi + stub_dir="" +} + +trap 'cleanup_stub_libkrun' EXIT + ensure_stub_libkrun() { if [ -n "$stub_dir" ]; then return @@ -451,8 +460,12 @@ run_host_suite() { build_real_binaries log "Running host VM command matrix" run_real cargo test -p a3s-box-cli --test host_smoke test_real_vm_command_matrix -- --ignored --nocapture --test-threads=1 + log "Running warm-pool command smoke" + run_real cargo test -p a3s-box-cli --test host_smoke test_real_pool_warm_run -- --ignored --nocapture --test-threads=1 + log "Running warm-pool Dockerfile RUN smoke" + run_real cargo test -p a3s-box-cli --test host_smoke test_real_build_run_pool_smoke -- --ignored --nocapture --test-threads=1 log "Running host Compose smoke" - run_real cargo test -p a3s-box-cli --test host_smoke test_real_compose_smoke -- --ignored --nocapture --test-threads=1 + run_real cargo test -p a3s-box-cli --test host_smoke test_real_compose_acl_smoke -- --ignored --nocapture --test-threads=1 if [ -n "${A3S_BOX_PUSH_TEST_REF:-}" ]; then log "Running registry push smoke" @@ -734,6 +747,7 @@ run_soak_verifier() { handle_soak_exit() { local rc="$1" + cleanup_stub_libkrun if [ "$rc" -eq 0 ] || [ "$SOAK_FAILURE_TRAP_ARMED" -eq 0 ]; then return fi diff --git a/scripts/macos-fault-soak.sh b/scripts/macos-fault-soak.sh new file mode 100755 index 00000000..44652451 --- /dev/null +++ b/scripts/macos-fault-soak.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# macOS/HVF fault-injection soak with isolated state and machine-readable evidence. +set -euo pipefail + +A3S_BOX="${A3S_BOX:-a3s-box}" +IMAGE="${IMAGE:-alpine:latest}" +DURATION_SECS=259200 +SAMPLE_INTERVAL_SECS=300 +FAULT_INTERVAL_SECS=900 +MIN_FREE_GIB=100 +MAX_DISK_PERCENT=80 +MIN_OPEN_FILES=4096 +OUTPUT="" +PREFLIGHT_ONLY=0 +KEEP_HOME=0 + +usage() { + cat <<'EOF' +Usage: scripts/macos-fault-soak.sh [options] + +Options: + --duration SECS Run duration (default: 259200 / 72 hours) + --sample-interval SECS Resource sampling interval (default: 300) + --fault-interval SECS Fault injection interval (default: 900) + --output DIR Evidence directory + --image IMAGE OCI image (default: alpine:latest) + --min-free-gib N Admission threshold (default: 100) + --max-disk-percent N Stop threshold (default: 80) + --min-open-files N Required file descriptor limit (default: 4096) + --preflight-only Check admission without creating workloads + --keep-home Preserve the isolated A3S_HOME after the run + -h, --help Show this help + +The runner never uses ~/.a3s. It creates an isolated A3S_HOME below the evidence +directory, injects faults only into boxes whose names begin with its unique run +prefix, and records resource samples, operation results, recovery assertions, +and a final summary. +EOF +} + +die() { echo "ERROR: $*" >&2; exit 1; } +is_uint() { [[ "$1" =~ ^[0-9]+$ ]]; } + +while [ "$#" -gt 0 ]; do + case "$1" in + --duration) DURATION_SECS="${2:?missing duration}"; shift 2 ;; + --sample-interval) SAMPLE_INTERVAL_SECS="${2:?missing interval}"; shift 2 ;; + --fault-interval) FAULT_INTERVAL_SECS="${2:?missing interval}"; shift 2 ;; + --output) OUTPUT="${2:?missing output}"; shift 2 ;; + --image) IMAGE="${2:?missing image}"; shift 2 ;; + --min-free-gib) MIN_FREE_GIB="${2:?missing threshold}"; shift 2 ;; + --max-disk-percent) MAX_DISK_PERCENT="${2:?missing threshold}"; shift 2 ;; + --min-open-files) MIN_OPEN_FILES="${2:?missing threshold}"; shift 2 ;; + --preflight-only) PREFLIGHT_ONLY=1; shift ;; + --keep-home) KEEP_HOME=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +for value in "$DURATION_SECS" "$SAMPLE_INTERVAL_SECS" "$FAULT_INTERVAL_SECS" \ + "$MIN_FREE_GIB" "$MAX_DISK_PERCENT" "$MIN_OPEN_FILES"; do + is_uint "$value" || die "numeric options must be non-negative integers" +done +[ "$DURATION_SECS" -gt 0 ] || die "duration must be positive" +[ "$SAMPLE_INTERVAL_SECS" -gt 0 ] || die "sample interval must be positive" +[ "$FAULT_INTERVAL_SECS" -gt 0 ] || die "fault interval must be positive" + +[ "$(uname -s)" = Darwin ] || die "this runner requires macOS" +[ "$(uname -m)" = arm64 ] || die "this runner requires Apple Silicon" +command -v "$A3S_BOX" >/dev/null 2>&1 || die "a3s-box not found: $A3S_BOX" +command -v jq >/dev/null 2>&1 || die "jq is required" + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" +OUTPUT="${OUTPUT:-$(pwd)/target/a3s-box-macos-fault-soak/$RUN_ID}" +mkdir -p "$OUTPUT" +OUTPUT="$(cd "$OUTPUT" && pwd)" +export A3S_HOME="$OUTPUT/a3s-home" +PREFIX="fault-soak-$RUN_ID" +SAMPLES="$OUTPUT/resource-samples.tsv" +OPERATIONS="$OUTPUT/operations.tsv" +SUMMARY="$OUTPUT/summary.txt" +START_EPOCH="$(date +%s)" +FAILURES=0 +EXPECTED_FAULTS=0 +OPERATIONS_TOTAL=0 +LAST_SAMPLE_EPOCH=0 +LAST_FAULT_EPOCH=0 + +disk_percent() { df -Pk "$OUTPUT" | awk 'NR==2 {gsub(/%/, "", $5); print $5}'; } +free_gib() { df -Pk "$OUTPUT" | awk 'NR==2 {printf "%d", $4 / 1024 / 1024}'; } +shim_count() { { pgrep -f "$A3S_HOME/boxes/" 2>/dev/null || true; } | wc -l | tr -d ' '; } +box_dir_count() { find "$A3S_HOME/boxes" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' '; } +socket_dir_count() { + { find "${TMPDIR:-/tmp}/a3s-box-sockets" -mindepth 1 -maxdepth 1 -type d 2>/dev/null || true; } | + while IFS= read -r dir; do + pgrep -f "$dir" >/dev/null 2>&1 && echo "$dir" + done | wc -l | tr -d ' ' +} +home_bytes() { du -sk "$A3S_HOME" 2>/dev/null | awk '{print $1 * 1024}' || echo 0; } + +write_sample() { + local phase="$1" now + now="$(date +%s)" + if [ ! -f "$SAMPLES" ]; then + printf 'timestamp\tepoch\tphase\tshims\tbox_dirs\tsocket_dirs\ta3s_home_bytes\tdisk_percent\tfree_gib\n' >"$SAMPLES" + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$now" "$phase" "$(shim_count)" \ + "$(box_dir_count)" "$(socket_dir_count)" "$(home_bytes)" \ + "$(disk_percent)" "$(free_gib)" >>"$SAMPLES" + LAST_SAMPLE_EPOCH="$now" +} + +record_operation() { + local kind="$1" result="$2" detail="$3" + if [ ! -f "$OPERATIONS" ]; then + printf 'timestamp\tkind\tresult\tdetail\n' >"$OPERATIONS" + fi + printf '%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$kind" "$result" \ + "$(printf '%s' "$detail" | tr '\t\r\n' ' ')" >>"$OPERATIONS" + OPERATIONS_TOTAL=$((OPERATIONS_TOTAL + 1)) + [ "$result" = pass ] || FAILURES=$((FAILURES + 1)) +} + +cleanup_boxes() { + local name + while IFS= read -r name; do + case "$name" in + "$PREFIX"-*) "$A3S_BOX" rm -f "$name" >/dev/null 2>&1 || true ;; + esac + done < <("$A3S_BOX" ps -a --format '{{.Names}}' 2>/dev/null || true) +} + +finish() { + local rc="$1" end duration final_shims final_dirs result + set +e + cleanup_boxes + sleep 2 + write_sample final + final_shims="$(shim_count)" + final_dirs="$(box_dir_count)" + [ "$final_shims" -eq 0 ] || FAILURES=$((FAILURES + 1)) + [ "$final_dirs" -eq 0 ] || FAILURES=$((FAILURES + 1)) + end="$(date +%s)"; duration=$((end - START_EPOCH)) + result=pass + [ "$rc" -eq 0 ] && [ "$FAILURES" -eq 0 ] || result=fail + { + echo "result=$result" + echo "duration_secs=$duration" + echo "operations=$OPERATIONS_TOTAL" + echo "expected_faults=$EXPECTED_FAULTS" + echo "failures=$FAILURES" + echo "final_shims=$final_shims" + echo "final_box_dirs=$final_dirs" + echo "evidence_dir=$OUTPUT" + } >"$SUMMARY" + if [ "$KEEP_HOME" -eq 0 ] && [ "$final_shims" -eq 0 ]; then rm -rf "$A3S_HOME"; fi + echo "macOS fault soak: $result (evidence: $OUTPUT)" + [ "$result" = pass ] +} +trap 'finish "$?"' EXIT INT TERM + +OPEN_FILES="$(ulimit -n)" +CURRENT_FREE_GIB="$(free_gib)" +CURRENT_DISK_PERCENT="$(disk_percent)" +{ + echo "run_id=$RUN_ID" + echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "git_sha=$(git rev-parse HEAD 2>/dev/null || true)" + echo "a3s_box=$($A3S_BOX version 2>&1 | head -1)" + echo "image=$IMAGE" + echo "duration_secs=$DURATION_SECS" + echo "sample_interval_secs=$SAMPLE_INTERVAL_SECS" + echo "fault_interval_secs=$FAULT_INTERVAL_SECS" + echo "open_files=$OPEN_FILES" + echo "free_gib=$CURRENT_FREE_GIB" + echo "disk_percent=$CURRENT_DISK_PERCENT" + uname -a + sw_vers +} >"$OUTPUT/metadata.txt" + +[ "$OPEN_FILES" -ge "$MIN_OPEN_FILES" ] || die "open-file limit $OPEN_FILES is below $MIN_OPEN_FILES" +[ "$CURRENT_FREE_GIB" -ge "$MIN_FREE_GIB" ] || die "free disk ${CURRENT_FREE_GIB} GiB is below ${MIN_FREE_GIB} GiB" +[ "$CURRENT_DISK_PERCENT" -le "$MAX_DISK_PERCENT" ] || die "disk usage ${CURRENT_DISK_PERCENT}% exceeds ${MAX_DISK_PERCENT}%" +[ "$(sysctl -n kern.hv_support 2>/dev/null || echo 0)" = 1 ] || die "Hypervisor.framework is unavailable" + +if [ "$PREFLIGHT_ONLY" -eq 1 ]; then + trap - EXIT INT TERM + echo "preflight=pass" >"$SUMMARY" + echo "macOS fault-soak preflight passed: $OUTPUT" + exit 0 +fi + +mkdir -p "$A3S_HOME" +write_sample start +"$A3S_BOX" pull "$IMAGE" >"$OUTPUT/pull.log" 2>&1 + +run_normal_operation() { + if "$A3S_BOX" run --rm "$IMAGE" -- sh -c 'test "$(printf recovery)" = recovery' >/dev/null 2>&1; then + record_operation lifecycle pass run-rm + else + record_operation lifecycle fail run-rm + fi +} + +inject_shim_kill() { + local name="$PREFIX-shim-$OPERATIONS_TOTAL" inspect id pid + if ! "$A3S_BOX" run -d --name "$name" "$IMAGE" -- sleep 300 >/dev/null 2>&1; then + record_operation shim-kill fail launch; return + fi + inspect="$($A3S_BOX inspect "$name" 2>/dev/null || true)" + id="$(printf '%s' "$inspect" | jq -r 'if type=="array" then .[0] else . end | .Id // .ID // .id // empty' 2>/dev/null)" + pid="$(pgrep -f "\"box_id\":\"$id\"" 2>/dev/null | head -1 || true)" + if [ -z "$id" ] || [ -z "$pid" ]; then + "$A3S_BOX" rm -f "$name" >/dev/null 2>&1 || true + record_operation shim-kill fail "shim-not-found id=$id"; return + fi + kill -9 "$pid" 2>/dev/null || true + EXPECTED_FAULTS=$((EXPECTED_FAULTS + 1)) + sleep 2 + "$A3S_BOX" rm -f "$name" >/dev/null 2>&1 || true + if "$A3S_BOX" ps -a >/dev/null 2>&1 && ! kill -0 "$pid" 2>/dev/null; then + record_operation shim-kill pass "pid=$pid" + else + record_operation shim-kill fail "recovery-failed pid=$pid" + fi +} + +inject_cli_kill() { + local name="$PREFIX-cli-$OPERATIONS_TOTAL" pid + "$A3S_BOX" run --name "$name" "$IMAGE" -- sleep 300 >/dev/null 2>&1 & + pid=$! + sleep 1 + kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + EXPECTED_FAULTS=$((EXPECTED_FAULTS + 1)) + "$A3S_BOX" rm -f "$name" >/dev/null 2>&1 || true + if "$A3S_BOX" ps -a >/dev/null 2>&1; then + record_operation cli-kill pass "pid=$pid" + else + record_operation cli-kill fail "state-unreadable pid=$pid" + fi +} + +END_EPOCH=$((START_EPOCH + DURATION_SECS)) +FAULT_KIND=0 +while [ "$(date +%s)" -lt "$END_EPOCH" ]; do + now="$(date +%s)" + if [ $((now - LAST_SAMPLE_EPOCH)) -ge "$SAMPLE_INTERVAL_SECS" ]; then + write_sample periodic + [ "$(disk_percent)" -le "$MAX_DISK_PERCENT" ] || die "disk stop condition fired" + fi + run_normal_operation + now="$(date +%s)" + if [ $((now - LAST_FAULT_EPOCH)) -ge "$FAULT_INTERVAL_SECS" ]; then + if [ "$FAULT_KIND" -eq 0 ]; then inject_shim_kill; FAULT_KIND=1; else inject_cli_kill; FAULT_KIND=0; fi + LAST_FAULT_EPOCH="$now" + write_sample post-fault + fi +done diff --git a/scripts/submit-to-winget.ps1 b/scripts/submit-to-winget.ps1 index 6c0ea08a..d54014e7 100644 --- a/scripts/submit-to-winget.ps1 +++ b/scripts/submit-to-winget.ps1 @@ -25,7 +25,7 @@ if (-not $GitHubToken) { } $Tag = "v$Version" -$AssetUrl = "https://github.com/AI45Lab/Box/releases/download/$Tag/a3s-box-$Tag-windows-x86_64.zip" +$AssetUrl = "https://github.com/A3S-Lab/Box/releases/download/$Tag/a3s-box-$Tag-windows-x86_64.zip" Write-Host "=== Submitting a3s-box $Version to winget ===" -ForegroundColor Cyan Write-Host "" diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 00000000..4bf22c74 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,76 @@ +# A3S Box Python SDK + +`a3s-box` is a typed convenience package around the checksum-pinned official +E2B Python clients used by A3S Box compatibility tests. It re-exports the +official `e2b` 2.32.0 API instead of maintaining a fork, so existing E2B code +can keep the same classes and method signatures. A3S Box provides the runtime; +this native package does not read `E2B_API_URL` or contact E2B Cloud. + +```bash +export A3S_BOX_ENDPOINT=https://api.box.example.com +export A3S_BOX_API_KEY=a3s_your_key +``` + +```python +import asyncio + +from a3s_box import A3SConnectionConfig, AsyncSandbox + + +async def main() -> None: + connection = A3SConnectionConfig.from_environment() + sandbox = await AsyncSandbox.create( + "code-interpreter-v1", + **connection.python_options(), + ) + async with sandbox: + result = await sandbox.commands.run("python -c 'print(6 * 7)'") + print(result.stdout) + + +asyncio.run(main()) +``` + +The synchronous and asynchronous Code Interpreter exports are available from +`a3s_box.code_interpreter`. + +The production-tested Sandbox backend supports memory-preserving pause through +the unchanged SDK methods: `await sandbox.pause(keep_memory=True)` followed by +`await sandbox.connect(timeout=60)`. The A3S OS matrix proves that a process +started before pause continues after resume. `keep_memory=False` remains +explicitly unsupported until filesystem-only pause is implemented. + +`A3SConnectionConfig` reads `A3S_BOX_ENDPOINT` and `A3S_BOX_API_KEY` without +changing process-global environment variables. It derives the Sandbox domain +from conventional `https://api.` endpoints. Set `A3S_BOX_DOMAIN` only +when that convention does not apply. The A3S service returns the public direct +Sandbox authority, including a non-standard TLS port when configured. +`A3S_BOX_SANDBOX_URL` is retained only for single-Sandbox fixtures. The A3S +endpoint decides the execution template and isolation policy; the SDK never +invokes a local runtime. `E2B_API_URL` is not read by this package. It is used +only when the unchanged official SDK is intentionally connected to the same +A3S Box endpoint. + +Volume control requests use `connection.python_options()`. Volume content +requests use `connection.volume_options()` so they reach that same A3S Box +endpoint without `E2B_VOLUME_API_URL`: + +```python +from a3s_box import A3SConnectionConfig, Volume + +connection = A3SConnectionConfig.from_environment() +volume = Volume.create("data", **connection.python_options()) +volume.write_file("/input.txt", "hello", **connection.volume_options()) +``` + +Filesystem Snapshots use the same Sandbox connection. They capture rootfs +state, preserve the source Sandbox state, and restore into a writable private +copy-on-write layer: + +```python +snapshot = await sandbox.create_snapshot(name="checkpoint") +restored = await AsyncSandbox.create( + snapshot.snapshot_id, + **connection.python_options(), +) +``` diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 00000000..0f0f2927 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=77,<81"] +build-backend = "setuptools.build_meta" + +[project] +name = "a3s-box" +version = "3.0.10" +description = "Typed Python convenience package for the A3S Box E2B-compatible endpoint" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +authors = [{ name = "A3S Lab" }] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Typing :: Typed", +] +dependencies = [ + "e2b==2.32.0", + "e2b-code-interpreter==2.8.1", +] + +[project.urls] +Homepage = "https://github.com/A3S-Lab/Box" +Repository = "https://github.com/A3S-Lab/Box" +Issues = "https://github.com/A3S-Lab/Box/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +a3s_box = ["py.typed"] diff --git a/sdk/python/src/a3s_box/__init__.py b/sdk/python/src/a3s_box/__init__.py new file mode 100644 index 00000000..5d0fea00 --- /dev/null +++ b/sdk/python/src/a3s_box/__init__.py @@ -0,0 +1,8 @@ +"""A3S Box SDK with the official E2B Python surface re-exported unchanged.""" + +from e2b import * # noqa: F403 +from e2b import __all__ as _e2b_all + +from .connection import A3SConnectionConfig + +__all__ = [*_e2b_all, "A3SConnectionConfig"] diff --git a/sdk/python/src/a3s_box/code_interpreter.py b/sdk/python/src/a3s_box/code_interpreter.py new file mode 100644 index 00000000..779a29b2 --- /dev/null +++ b/sdk/python/src/a3s_box/code_interpreter.py @@ -0,0 +1,3 @@ +"""Official E2B Code Interpreter exports configured for A3S endpoints.""" + +from e2b_code_interpreter import * # noqa: F401,F403 diff --git a/sdk/python/src/a3s_box/connection.py b/sdk/python/src/a3s_box/connection.py new file mode 100644 index 00000000..16314d89 --- /dev/null +++ b/sdk/python/src/a3s_box/connection.py @@ -0,0 +1,77 @@ +"""Typed A3S endpoint configuration helpers.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Mapping +from urllib.parse import urlparse + + +@dataclass(frozen=True, slots=True) +class A3SConnectionConfig: + """Connection values accepted by the pinned official E2B Python SDK.""" + + api_url: str + domain: str | None = None + api_key: str | None = None + sandbox_url: str | None = None + + def __post_init__(self) -> None: + if not self.api_url.strip(): + raise ValueError("api_url cannot be empty") + derived_domain = _domain_from_endpoint(self.api_url) + domain = derived_domain if self.domain is None else self.domain + if not domain.strip(): + raise ValueError("domain cannot be empty when provided") + object.__setattr__(self, "domain", domain) + if self.api_key is not None and not self.api_key.strip(): + raise ValueError("api_key cannot be empty when provided") + if self.sandbox_url is not None and not self.sandbox_url.strip(): + raise ValueError("sandbox_url cannot be empty when provided") + + @classmethod + def from_environment( + cls, + environment: Mapping[str, str] | None = None, + ) -> A3SConnectionConfig: + """Read A3S Box endpoint variables without mutating the process.""" + + values = os.environ if environment is None else environment + api_url = values.get("A3S_BOX_ENDPOINT") + if not api_url: + raise ValueError("A3S_BOX_ENDPOINT is required") + return cls( + api_url=api_url, + domain=values.get("A3S_BOX_DOMAIN"), + api_key=values.get("A3S_BOX_API_KEY"), + sandbox_url=values.get("A3S_BOX_SANDBOX_URL"), + ) + + def python_options(self) -> dict[str, str | bool]: + """Return keyword arguments for Python Sandbox create/connect calls.""" + + assert self.domain is not None + options: dict[str, str | bool] = { + "api_url": self.api_url, + "domain": self.domain, + "validate_api_key": False, + } + if self.api_key is not None: + options["api_key"] = self.api_key + if self.sandbox_url is not None: + options["sandbox_url"] = self.sandbox_url + return options + + def volume_options(self) -> dict[str, str]: + """Return A3S Box endpoint options for Volume content calls.""" + + return {"api_url": self.api_url} + + +def _domain_from_endpoint(endpoint: str) -> str: + parsed = urlparse(endpoint) + if parsed.scheme not in {"http", "https"} or parsed.hostname is None: + raise ValueError("api_url must be an absolute HTTP or HTTPS URL") + hostname = parsed.hostname + return hostname.removeprefix("api.") diff --git a/sdk/python/src/a3s_box/py.typed b/sdk/python/src/a3s_box/py.typed new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/sdk/python/src/a3s_box/py.typed @@ -0,0 +1 @@ + diff --git a/sdk/python/tests/test_sdk.py b/sdk/python/tests/test_sdk.py new file mode 100644 index 00000000..d2f9a68c --- /dev/null +++ b/sdk/python/tests/test_sdk.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import unittest + +import e2b +import e2b_code_interpreter + +import a3s_box +from a3s_box import A3SConnectionConfig +from a3s_box import code_interpreter + + +class SdkTests(unittest.TestCase): + def test_reexports_pinned_official_clients(self) -> None: + self.assertIs(a3s_box.Sandbox, e2b.Sandbox) + self.assertIs(a3s_box.AsyncSandbox, e2b.AsyncSandbox) + self.assertIs(code_interpreter.Sandbox, e2b_code_interpreter.Sandbox) + self.assertIs( + code_interpreter.AsyncSandbox, + e2b_code_interpreter.AsyncSandbox, + ) + + def test_connection_options_use_standard_e2b_names(self) -> None: + connection = A3SConnectionConfig.from_environment( + { + "A3S_BOX_ENDPOINT": "https://api.box.example.com", + "A3S_BOX_API_KEY": "a3s_a1b2c3", + "A3S_BOX_SANDBOX_URL": "https://sandbox.box.example.com", + } + ) + self.assertEqual( + connection.python_options(), + { + "api_url": "https://api.box.example.com", + "domain": "box.example.com", + "validate_api_key": False, + "api_key": "a3s_a1b2c3", + "sandbox_url": "https://sandbox.box.example.com", + }, + ) + self.assertEqual( + connection.volume_options(), + {"api_url": "https://api.box.example.com"}, + ) + + def test_connection_domain_can_be_overridden_for_self_hosting(self) -> None: + connection = A3SConnectionConfig.from_environment( + { + "A3S_BOX_ENDPOINT": "https://gateway.internal.example", + "A3S_BOX_DOMAIN": "sandboxes.internal.example", + } + ) + self.assertEqual(connection.domain, "sandboxes.internal.example") + + def test_native_config_does_not_require_e2b_environment_names(self) -> None: + with self.assertRaisesRegex(ValueError, "A3S_BOX_ENDPOINT is required"): + A3SConnectionConfig.from_environment( + { + "E2B_API_URL": "https://api.box.example.com", + "E2B_DOMAIN": "box.example.com", + } + ) + + def test_connection_rejects_a_non_http_endpoint(self) -> None: + with self.assertRaisesRegex( + ValueError, + "api_url must be an absolute HTTP or HTTPS URL", + ): + A3SConnectionConfig(api_url="unix:///run/a3s-box.sock") + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 00000000..f5381f2c --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,67 @@ +# A3S Box TypeScript SDK + +`@a3s-lab/box` re-exports the official `e2b` 2.33.0 TypeScript API and the +pinned `@e2b/code-interpreter` 2.6.1 package. It does not fork or translate the +public protocol. A3S Box provides the runtime; this native package does not +read `E2B_API_URL` or contact E2B Cloud. + +```bash +export A3S_BOX_ENDPOINT=https://api.box.example.com +export A3S_BOX_API_KEY=a3s_your_key +``` + +```typescript +import { A3SConnectionConfig, Sandbox } from '@a3s-lab/box' + +const connection = A3SConnectionConfig.fromEnvironment(process.env) +const sandbox = await Sandbox.create('code-interpreter-v1', { + ...connection.typescriptOptions(), + timeoutMs: 60_000, +}) + +try { + const result = await sandbox.commands.run('node -e "console.log(6 * 7)"') + console.log(result.stdout) +} finally { + await sandbox.kill() +} +``` + +Code Interpreter exports are available from `@a3s-lab/box/code-interpreter`. +The production-tested Sandbox backend supports memory-preserving pause through +the unchanged SDK methods: `await sandbox.pause({ keepMemory: true })` followed +by `await sandbox.connect({ timeoutMs: 60_000 })`. The A3S OS matrix proves +that a process started before pause continues after resume. `keepMemory: false` +remains explicitly unsupported until filesystem-only pause is implemented. + +`A3SConnectionConfig` derives the Sandbox domain from conventional +`https://api.` endpoints. Set `A3S_BOX_DOMAIN` only when that convention +does not apply. The service returns the public direct Sandbox authority, +including a configured non-standard TLS port. `A3S_BOX_SANDBOX_URL` is retained +only for single-Sandbox fixtures. The A3S service owns template and isolation +selection; this package never starts a local container or runtime. +`E2B_API_URL` is not read by this package; that name is used only when an +unchanged official SDK is connected directly to the same A3S Box endpoint. + +Volume control requests use `connection.typescriptOptions()`. Volume content +requests use `connection.volumeOptions()` so they reach that same A3S Box +endpoint without `E2B_VOLUME_API_URL`: + +```typescript +import { A3SConnectionConfig, Volume } from '@a3s-lab/box' + +const connection = A3SConnectionConfig.fromEnvironment(process.env) +const volume = await Volume.create('data', connection.typescriptOptions()) +await volume.writeFile('/input.txt', 'hello', connection.volumeOptions()) +``` + +Filesystem Snapshots use the same Sandbox connection. They capture rootfs +state, preserve the source Sandbox state, and restore into a writable private +copy-on-write layer: + +```typescript +const snapshot = await sandbox.createSnapshot({ name: 'checkpoint' }) +const restored = await Sandbox.create(snapshot.snapshotId, { + ...connection.typescriptOptions(), +}) +``` diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json new file mode 100644 index 00000000..7ab0cfcc --- /dev/null +++ b/sdk/typescript/package-lock.json @@ -0,0 +1,444 @@ +{ + "name": "@a3s-lab/box", + "version": "3.0.10", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@a3s-lab/box", + "version": "3.0.10", + "license": "Apache-2.0", + "dependencies": { + "@e2b/code-interpreter": "2.6.1", + "e2b": "2.33.0" + }, + "devDependencies": { + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.1.tgz", + "integrity": "sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.2.tgz", + "integrity": "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.2.tgz", + "integrity": "sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.2" + } + }, + "node_modules/@e2b/code-interpreter": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@e2b/code-interpreter/-/code-interpreter-2.6.1.tgz", + "integrity": "sha512-5sKJaw2w/XZNHq7NpcNm8cpGu3IHlwLjavRd6V5BsVQyyl+5zGnSWE+EvA6W1VHDbPXiSdm6pN8cREWAifdrJw==", + "license": "MIT", + "dependencies": { + "e2b": "^2.28.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dockerfile-ast": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/dockerfile-ast/-/dockerfile-ast-0.7.1.tgz", + "integrity": "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-textdocument": "^1.0.8", + "vscode-languageserver-types": "^3.17.3" + }, + "engines": { + "node": "*" + } + }, + "node_modules/e2b": { + "version": "2.33.0", + "resolved": "https://registry.npmjs.org/e2b/-/e2b-2.33.0.tgz", + "integrity": "sha512-n6W0nsJMetz8m30sLPMYfliPTW0VpuaDTUOjB0ZdYZ87li4zyX0UQqeQ5S/YPvq8EX9zEYcy3SijBxn1FGFlkQ==", + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.12.1", + "@connectrpc/connect": "^2.1.2", + "@connectrpc/connect-web": "^2.1.2", + "chalk": "^5.3.0", + "compare-versions": "^6.1.0", + "dockerfile-ast": "^0.7.1", + "glob": "^11.1.0", + "openapi-fetch": "^0.14.1", + "platform": "^1.3.6", + "tar": "^7.5.16", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=20.18.1 <21 || >=22" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/openapi-fetch": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.1.tgz", + "integrity": "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", + "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 00000000..0d441972 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,46 @@ +{ + "name": "@a3s-lab/box", + "version": "3.0.10", + "description": "Typed TypeScript convenience package for the A3S Box E2B-compatible endpoint", + "type": "module", + "sideEffects": false, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/A3S-Lab/Box.git", + "directory": "sdk/typescript" + }, + "files": [ + "dist", + "README.md" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./code-interpreter": { + "types": "./dist/code-interpreter.d.ts", + "import": "./dist/code-interpreter.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node tests/exports.mjs" + }, + "dependencies": { + "@e2b/code-interpreter": "2.6.1", + "e2b": "2.33.0" + }, + "devDependencies": { + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/sdk/typescript/src/code-interpreter.ts b/sdk/typescript/src/code-interpreter.ts new file mode 100644 index 00000000..2bd16df1 --- /dev/null +++ b/sdk/typescript/src/code-interpreter.ts @@ -0,0 +1,2 @@ +export * from '@e2b/code-interpreter' +export { Sandbox as default } from '@e2b/code-interpreter' diff --git a/sdk/typescript/src/connection.ts b/sdk/typescript/src/connection.ts new file mode 100644 index 00000000..bc609933 --- /dev/null +++ b/sdk/typescript/src/connection.ts @@ -0,0 +1,93 @@ +export interface A3SConnectionEnvironment { + A3S_BOX_ENDPOINT?: string + A3S_BOX_DOMAIN?: string + A3S_BOX_API_KEY?: string + A3S_BOX_SANDBOX_URL?: string +} + +export interface A3SConnectionOptions { + apiUrl: string + domain?: string + apiKey?: string + sandboxUrl?: string +} + +export interface A3SSandboxConnectionOptions { + apiUrl: string + domain: string + validateApiKey: false + apiKey?: string + sandboxUrl?: string +} + +export interface A3SVolumeConnectionOptions { + apiUrl: string +} + +/** Typed connection values accepted by the pinned official E2B SDK. */ +export class A3SConnectionConfig { + readonly apiUrl: string + readonly domain: string + readonly apiKey?: string + readonly sandboxUrl?: string + + constructor(options: A3SConnectionOptions) { + if (!options.apiUrl.trim()) throw new Error('apiUrl cannot be empty') + const derivedDomain = domainFromEndpoint(options.apiUrl) + const domain = options.domain ?? derivedDomain + if (!domain.trim()) throw new Error('domain cannot be empty when provided') + if (options.apiKey !== undefined && !options.apiKey.trim()) { + throw new Error('apiKey cannot be empty when provided') + } + if (options.sandboxUrl !== undefined && !options.sandboxUrl.trim()) { + throw new Error('sandboxUrl cannot be empty when provided') + } + this.apiUrl = options.apiUrl + this.domain = domain + this.apiKey = options.apiKey + this.sandboxUrl = options.sandboxUrl + } + + static fromEnvironment( + environment: Readonly + ): A3SConnectionConfig { + if (!environment.A3S_BOX_ENDPOINT) { + throw new Error('A3S_BOX_ENDPOINT is required') + } + return new A3SConnectionConfig({ + apiUrl: environment.A3S_BOX_ENDPOINT, + domain: environment.A3S_BOX_DOMAIN, + apiKey: environment.A3S_BOX_API_KEY, + sandboxUrl: environment.A3S_BOX_SANDBOX_URL, + }) + } + + typescriptOptions(): A3SSandboxConnectionOptions { + return { + apiUrl: this.apiUrl, + domain: this.domain, + validateApiKey: false, + ...(this.apiKey === undefined ? {} : { apiKey: this.apiKey }), + ...(this.sandboxUrl === undefined + ? {} + : { sandboxUrl: this.sandboxUrl }), + } + } + + volumeOptions(): A3SVolumeConnectionOptions { + return { apiUrl: this.apiUrl } + } +} + +function domainFromEndpoint(endpoint: string): string { + let url: URL + try { + url = new URL(endpoint) + } catch { + throw new Error('apiUrl must be an absolute HTTP or HTTPS URL') + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('apiUrl must be an absolute HTTP or HTTPS URL') + } + return url.hostname.startsWith('api.') ? url.hostname.slice(4) : url.hostname +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 00000000..cf1a06aa --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,10 @@ +export * from 'e2b' +export { Sandbox as default } from 'e2b' + +export { + A3SConnectionConfig, + type A3SConnectionEnvironment, + type A3SConnectionOptions, + type A3SSandboxConnectionOptions, + type A3SVolumeConnectionOptions, +} from './connection.js' diff --git a/sdk/typescript/tests/exports.mjs b/sdk/typescript/tests/exports.mjs new file mode 100644 index 00000000..e952d77d --- /dev/null +++ b/sdk/typescript/tests/exports.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' + +import { Sandbox as OfficialSandbox } from 'e2b' +import { Sandbox as OfficialCodeInterpreter } from '@e2b/code-interpreter' +import { A3SConnectionConfig, Sandbox } from '../dist/index.js' +import { Sandbox as CodeInterpreter } from '../dist/code-interpreter.js' + +assert.equal(Sandbox, OfficialSandbox) +assert.equal(CodeInterpreter, OfficialCodeInterpreter) + +const connection = A3SConnectionConfig.fromEnvironment({ + A3S_BOX_ENDPOINT: 'https://api.box.example.com', + A3S_BOX_API_KEY: 'a3s_a1b2c3', + A3S_BOX_SANDBOX_URL: 'https://sandbox.box.example.com', +}) +assert.deepEqual(connection.typescriptOptions(), { + apiUrl: 'https://api.box.example.com', + domain: 'box.example.com', + validateApiKey: false, + apiKey: 'a3s_a1b2c3', + sandboxUrl: 'https://sandbox.box.example.com', +}) +assert.deepEqual(connection.volumeOptions(), { + apiUrl: 'https://api.box.example.com', +}) + +const selfHosted = A3SConnectionConfig.fromEnvironment({ + A3S_BOX_ENDPOINT: 'https://gateway.internal.example', + A3S_BOX_DOMAIN: 'sandboxes.internal.example', +}) +assert.equal(selfHosted.domain, 'sandboxes.internal.example') + +assert.throws( + () => + A3SConnectionConfig.fromEnvironment({ + E2B_API_URL: 'https://api.box.example.com', + E2B_DOMAIN: 'box.example.com', + }), + /A3S_BOX_ENDPOINT is required/ +) + +assert.throws( + () => new A3SConnectionConfig({ apiUrl: 'unix:///run/a3s-box.sock' }), + /apiUrl must be an absolute HTTP or HTTPS URL/ +) diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 00000000..28266a29 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*.ts"] +} diff --git a/src/Cargo.lock b/src/Cargo.lock index fec1b763..c26c7bc5 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -2,9 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "a3s-acl" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2dc4eb3b0dd1b11efa0ad9bf397c97fd1d16e3eb4f9ca43872df069560d0b69" + [[package]] name = "a3s-box-cli" -version = "2.6.0" +version = "3.0.10" dependencies = [ "a3s-box-core", "a3s-box-runtime", @@ -34,12 +40,54 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "a3s-box-compat" +version = "3.0.10" +dependencies = [ + "a3s-acl", + "a3s-box-core", + "a3s-box-runtime", + "anyhow", + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "clap", + "futures", + "hex", + "hyper 0.14.32", + "libc", + "prost", + "prost-types", + "rcgen", + "ring", + "rustls", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-rusqlite", + "tokio-rustls", + "tower 0.4.13", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + [[package]] name = "a3s-box-core" -version = "2.6.0" +version = "3.0.10" dependencies = [ + "a3s-acl", "a3s-common", "async-trait", + "base64 0.22.1", "chrono", "dirs", "flate2", @@ -54,7 +102,7 @@ dependencies = [ [[package]] name = "a3s-box-cri" -version = "2.6.0" +version = "3.0.10" dependencies = [ "a3s-box-core", "a3s-box-runtime", @@ -84,7 +132,7 @@ dependencies = [ [[package]] name = "a3s-box-guest-init" -version = "2.6.0" +version = "3.0.10" dependencies = [ "a3s-box-core", "a3s-common", @@ -99,6 +147,7 @@ dependencies = [ "serde_json", "serial_test", "sha2", + "tar", "tempfile", "thiserror 2.0.18", "tracing", @@ -107,7 +156,7 @@ dependencies = [ [[package]] name = "a3s-box-lambda" -version = "2.6.0" +version = "3.0.10" dependencies = [ "a3s-box-core", "a3s-box-runtime", @@ -126,7 +175,7 @@ dependencies = [ [[package]] name = "a3s-box-netproxy" -version = "2.6.0" +version = "3.0.10" dependencies = [ "a3s-box-core", "libc", @@ -138,12 +187,13 @@ dependencies = [ [[package]] name = "a3s-box-runtime" -version = "2.6.0" +version = "3.0.10" dependencies = [ "a3s-box-core", "a3s-box-netproxy", "a3s-common", "async-trait", + "axum", "base64 0.22.1", "bzip2", "chrono", @@ -151,6 +201,7 @@ dependencies = [ "der", "dirs", "ecdsa", + "filetime", "flate2", "hex", "libc", @@ -163,6 +214,7 @@ dependencies = [ "rand", "rcgen", "reqwest 0.11.27", + "reqwest 0.12.28", "ring", "rustls", "rustls-pki-types", @@ -178,18 +230,34 @@ dependencies = [ "tokio-rustls", "tracing", "uuid", + "windows-sys 0.48.0", "x509-cert", + "xattr", "xz2", "zstd", ] [[package]] name = "a3s-box-sdk" -version = "2.6.0" +version = "3.0.10" +dependencies = [ + "a3s-box-core", + "a3s-box-runtime", + "async-trait", + "chrono", + "libc", + "serde", + "serde_json", + "sysinfo", + "tempfile", + "thiserror 1.0.69", + "tokio", + "uuid", +] [[package]] name = "a3s-box-shim" -version = "2.6.0" +version = "3.0.10" dependencies = [ "a3s-box-core", "a3s-box-netproxy", @@ -223,7 +291,7 @@ dependencies = [ [[package]] name = "a3s-libkrun-sys" -version = "2.6.0" +version = "3.0.10" dependencies = [ "libc", "num_cpus", @@ -440,7 +508,11 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper 0.1.2", + "tokio", "tower 0.4.13", "tower-layer", "tower-service", @@ -694,6 +766,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1095,6 +1176,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1431,6 +1524,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heapless" version = "0.8.0" @@ -1934,6 +2036,17 @@ dependencies = [ "redox_syscall 0.7.1", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2802,7 +2915,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", @@ -2883,6 +2996,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2939,6 +3066,15 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3122,6 +3258,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3641,6 +3788,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rusqlite" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc343d3557ce02aa35bcf205e9770bdd684a6f9056b4da91fa3fbcc45ca64906" +dependencies = [ + "crossbeam-channel", + "rusqlite", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/src/Cargo.toml b/src/Cargo.toml index 05a48b6c..ec3d36ed 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "core", + "compat", "runtime", "netproxy", "shim", @@ -23,21 +24,24 @@ resolver = "2" h2 = { path = "third_party/h2" } [workspace.package] -version = "2.6.0" +version = "3.0.10" edition = "2021" authors = ["A3S Lab Team"] license = "MIT" -repository = "https://github.com/AI45Lab/Box" +repository = "https://github.com/A3S-Lab/Box" [workspace.dependencies] # Async runtime tokio = { version = "1.35", features = ["full"] } tokio-stream = { version = "0.1", features = ["net"] } +tokio-rusqlite = { version = "0.6", features = ["bundled"] } # gRPC +axum = { version = "0.6", features = ["json"] } tonic = "0.11" tonic-build = "0.11" prost = "0.12" +prost-types = "0.12" tower = "0.4" http = "0.2" hyper = { version = "0.14", features = ["client"] } @@ -85,6 +89,7 @@ rustls-pki-types = "1" # HTTP client (for skill downloads) reqwest = { version = "0.11", features = ["json", "stream"] } +url = "2.5" # Time chrono = { version = "0.4", features = ["serde"] } @@ -120,6 +125,7 @@ base64 = "0.22" ring = "0.17" # Shared types (transport protocol, privacy, tools) +a3s-acl = "=0.2.2" a3s-transport = { version = "0.1.1", package = "a3s-common" } # Metrics diff --git a/src/cli/src/boot.rs b/src/cli/src/boot.rs index ff2c5022..9cf7b6a6 100644 --- a/src/cli/src/boot.rs +++ b/src/cli/src/boot.rs @@ -233,7 +233,7 @@ pub async fn boot_and_record( if let Some(pid) = booted_pid { crate::process::graceful_stop(pid, libc::SIGTERM, 5).await; } - crate::cleanup::cleanup_removed_box(record); + crate::cleanup::cleanup_removed_box(record)?; Ok(BootOutcome::RemovedDuringBoot) } } @@ -310,6 +310,7 @@ pub async fn boot_from_record( ) -> Result> { let config = config_from_record(record).map_err(|e| -> Box { e.into() })?; + a3s_box_core::resolve_execution(&config)?; let emitter = EventEmitter::new(256); let mut vm = VmManager::with_box_id(config, emitter, record.id.clone()); @@ -360,17 +361,6 @@ pub async fn boot_from_record( let stop_signal = common::effective_stop_signal(record.stop_signal.as_deref(), image_stop_signal.as_deref()); - // Spawn health checker if configured (self-terminates when box stops) - if let Some(ref hc) = health_check { - crate::health::spawn_health_checker( - record.id.clone(), - exec_socket_path - .clone() - .unwrap_or_else(|| record.exec_socket_path.clone()), - hc.clone(), - ); - } - Ok(BootResult { pid, exec_socket_path, @@ -402,6 +392,7 @@ fn config_from_record(record: &BoxRecord) -> Result { .map_err(|e| format!("Invalid persisted add-host entry: {e}"))?; Ok(BoxConfig { + isolation: record.isolation, image: record.image.clone(), resources: ResourceConfig { vcpus: record.cpus, @@ -414,6 +405,7 @@ fn config_from_record(record: &BoxRecord) -> Result { workdir: record.workdir.clone(), hostname: record.hostname.clone(), volumes: record.volumes.clone(), + virtiofs_cache: record.virtiofs_cache.clone(), extra_env: record .env .iter() @@ -447,12 +439,15 @@ mod tests { short_id, name: "test_box".to_string(), image: "alpine:latest".to_string(), + isolation: Default::default(), + managed_execution: None, status: "stopped".to_string(), pid: None, pid_start_time: None, cpus: 4, memory_mb: 2048, volumes: vec!["/host:/guest".to_string()], + virtiofs_cache: Some("always".to_string()), env: { let mut m = HashMap::new(); m.insert("FOO".to_string(), "bar".to_string()); @@ -516,6 +511,21 @@ mod tests { assert_eq!(config.image, "alpine:latest"); } + #[test] + fn test_config_from_record_preserves_sandbox_isolation() { + let mut record = sample_record(); + record.isolation = a3s_box_core::ExecutionIsolation::Sandbox; + record.port_map.clear(); + + let config = config_from_record(&record).unwrap(); + + assert_eq!(config.isolation, a3s_box_core::ExecutionIsolation::Sandbox); + assert_eq!( + a3s_box_core::resolve_execution(&config).unwrap().backend, + a3s_box_core::ExecutionBackend::Crun + ); + } + #[test] fn test_config_from_record_resources() { let record = sample_record(); @@ -543,6 +553,7 @@ mod tests { let config = config_from_record(&record).unwrap(); assert_eq!(config.volumes, vec!["/host:/guest"]); + assert_eq!(config.virtiofs_cache.as_deref(), Some("always")); assert_eq!(config.tmpfs, vec!["/tmp"]); } diff --git a/src/cli/src/cleanup.rs b/src/cli/src/cleanup.rs index e6051dae..c7459d42 100644 --- a/src/cli/src/cleanup.rs +++ b/src/cli/src/cleanup.rs @@ -75,7 +75,8 @@ pub(crate) fn remove_host_cgroup(box_id: &str) { } /// Remove transient host resources for a stopped box while keeping its state. -pub fn cleanup_stopped_box(record: &BoxRecord) { +pub fn cleanup_stopped_box(record: &BoxRecord) -> a3s_box_core::error::Result<()> { + cleanup_sandbox_runtime(record)?; // Detach volumes but KEEP the network endpoint (network_name = None): a // persistent (non---rm) box must retain its IP/MAC across stop/start, the // same way `restart.rs` does (it skips this cleanup). Releasing it on stop @@ -86,8 +87,10 @@ pub fn cleanup_stopped_box(record: &BoxRecord) { // Release the overlayfs mount so a stopped box never leaves a live mount // (and a later restart re-mounts cleanly instead of stacking). a3s_box_runtime::rootfs::unmount_box_overlay(&record.box_dir.join("merged")); + a3s_box_runtime::rootfs::unmount_box_rootfs(&record.box_dir.join("rootfs")); cleanup_external_socket_dir(&record.box_dir, &record.exec_socket_path); remove_host_cgroup(&record.id); + Ok(()) } /// Remove anonymous volumes created from OCI `VOLUME` declarations. @@ -131,7 +134,22 @@ pub fn cleanup_external_socket_dir(box_dir: &Path, exec_socket_path: &Path) { } /// Remove all host-side resources owned by a box record. -pub fn cleanup_removed_box(record: &BoxRecord) { +pub fn cleanup_removed_box(record: &BoxRecord) -> a3s_box_core::error::Result<()> { + // A Sandbox log worker exits only after crun closes both output streams. + // Reconcile that runtime first so the archive includes final stderr and a + // complete structured projection. This does not remove the box log dir. + cleanup_sandbox_runtime(record)?; + + if record.auto_remove { + if let Err(err) = crate::log_archive::archive_removed_logs(record) { + tracing::debug!( + box_id = %record.id, + error = %err, + "Failed to archive auto-removed box logs" + ); + } + } + cleanup_record_resources(record); cleanup_anonymous_volumes(&record.anonymous_volumes); remove_host_cgroup(&record.id); @@ -140,6 +158,7 @@ pub fn cleanup_removed_box(record: &BoxRecord) { // Release the overlayfs mount FIRST: otherwise remove_dir_all deletes // into the live mount ("Stale file handle") and leaks it. a3s_box_runtime::rootfs::unmount_box_overlay(&record.box_dir.join("merged")); + a3s_box_runtime::rootfs::unmount_box_rootfs(&record.box_dir.join("rootfs")); if let Err(err) = std::fs::remove_dir_all(&record.box_dir) { tracing::debug!( path = %record.box_dir.display(), @@ -157,11 +176,27 @@ pub fn cleanup_removed_box(record: &BoxRecord) { if fs_mount_dir.exists() { let _ = std::fs::remove_dir_all(&fs_mount_dir); } + Ok(()) +} + +fn cleanup_sandbox_runtime(record: &BoxRecord) -> a3s_box_core::error::Result<()> { + if !record.isolation.is_sandbox() { + return Ok(()); + } + + a3s_box_runtime::vm::reap::cleanup_recorded_sandbox_runtime(&record.box_dir, &record.id) } /// Roll back a box record that was partially created. pub fn cleanup_partial_box_record(record: &BoxRecord, state: Option<&mut StateFile>) { - cleanup_removed_box(record); + if let Err(err) = cleanup_removed_box(record) { + tracing::warn!( + box_id = %record.id, + error = %err, + "Failed to clean partial Box resources; preserving state for recovery" + ); + return; + } if let Some(state) = state { if let Err(err) = state.remove(&record.id) { diff --git a/src/cli/src/commands/build.rs b/src/cli/src/commands/build.rs index 6fa70b9e..68a23993 100644 --- a/src/cli/src/commands/build.rs +++ b/src/cli/src/commands/build.rs @@ -7,7 +7,24 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; -use clap::Args; +use clap::{Args, ValueEnum}; + +#[path = "build_buildkit_vm.rs"] +mod buildkit_vm; + +const BUILD_RUN_POOL_SOCKET_ENV: &str = "A3S_BOX_BUILD_RUN_POOL_SOCKET"; +const BUILD_RUN_CACHE_DIR_ENV: &str = "A3S_BOX_BUILD_RUN_CACHE_DIR"; +const DEFAULT_BUILD_RUN_POOL_GUEST_ROOTFS: &str = "/run/a3s/build-rootfs"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum BuildBackend { + /// Use the default backend for the host and Dockerfile. + Auto, + /// Use the built-in host-side A3S build engine. + Host, + /// Run BuildKit inside an A3S Linux VM and load its OCI output. + BuildkitVm, +} #[derive(Args)] pub struct BuildArgs { @@ -44,6 +61,68 @@ pub struct BuildArgs { /// Do not use the layer build cache; rebuild every layer. #[arg(long = "no-cache")] pub no_cache: bool, + + /// Build backend: auto, host, or buildkit-vm. + /// + /// On macOS, auto delegates Dockerfiles containing RUN to BuildKit in an A3S VM. + #[arg(long, value_enum, default_value_t = BuildBackend::Auto)] + pub builder: BuildBackend, + + /// BuildKit image to run when --builder=buildkit-vm is selected. + #[arg(long = "buildkit-image", value_name = "IMAGE")] + pub buildkit_image: Option, + + /// CPUs for the BuildKit VM helper box. + #[arg(long = "buildkit-cpus", value_name = "N")] + pub buildkit_cpus: Option, + + /// Memory for the BuildKit VM helper box. + #[arg(long = "buildkit-memory", value_name = "SIZE")] + pub buildkit_memory: Option, + + /// Push the built tag directly from the BuildKit VM. + /// + /// Currently supported only with --builder=buildkit-vm and requires --tag. + #[arg(long)] + pub push: bool, + + /// Use plain HTTP when pushing from the BuildKit VM to a trusted registry. + #[arg(long, alias = "insecure")] + pub plain_http: bool, + + /// Execute Dockerfile RUN instructions through the warm-pool daemon. + #[arg(long = "run-pool")] + pub run_pool: bool, + + /// Warm-pool daemon socket for Dockerfile RUN execution. + #[arg(long = "run-pool-socket", value_name = "PATH")] + pub run_pool_socket: Option, + + /// Start the Dockerfile RUN warm-pool daemon when one is not already running. + /// + /// Requires --run-pool-image so build leases use an explicit helper VM image. + #[arg(long = "run-pool-autostart")] + pub run_pool_autostart: bool, + + /// Helper VM image for Dockerfile RUN pool leases; omitted uses daemon default. + #[arg(long = "run-pool-image", value_name = "IMAGE")] + pub run_pool_image: Option, + + /// CPUs for lazily-created Dockerfile RUN pool helper VMs. + #[arg(long = "run-pool-cpus", default_value_t = 2)] + pub run_pool_cpus: u32, + + /// Memory for lazily-created Dockerfile RUN pool helper VMs. + #[arg(long = "run-pool-memory", default_value = "512m")] + pub run_pool_memory: String, + + /// Timeout for each Dockerfile RUN command when using --run-pool. + #[arg(long = "run-pool-timeout", default_value = "1h", value_parser = crate::output::parse_duration_secs)] + pub run_pool_timeout: u64, + + /// Persistent cache directory for Dockerfile RUN --mount=type=cache with --run-pool. + #[arg(long = "run-cache-dir", value_name = "PATH")] + pub run_cache_dir: Option, } pub async fn execute(args: BuildArgs) -> Result<(), Box> { @@ -64,11 +143,59 @@ pub async fn execute(args: BuildArgs) -> Result<(), Box> // Parse build args let build_args = parse_build_args(&args.build_arg)?; + let platforms = parse_platforms(args.platform.as_deref())?; + + let run_pool = resolve_run_pool_config(&args)?; + if run_pool.is_some() && args.builder == BuildBackend::BuildkitVm { + return Err("--run-pool cannot be combined with --builder=buildkit-vm".into()); + } + if args.run_pool_autostart { + if let Some(config) = &run_pool { + super::pool::ensure_pool_daemon_running(&pool_autostart_config_for_build(config)?) + .await?; + } + } + + let use_buildkit_vm = if run_pool.is_some() { + false + } else { + should_use_buildkit_vm(args.builder, &dockerfile_path)? + }; + if args.push && !use_buildkit_vm { + return Err("--push is currently supported only with --builder=buildkit-vm".into()); + } + + if use_buildkit_vm { + return buildkit_vm::execute(buildkit_vm::Build { + context_dir, + dockerfile_path, + tag: args.tag.clone(), + build_args: args.build_arg.clone(), + quiet: args.quiet, + platform: args.platform.clone(), + target: args.target.clone(), + no_cache: args.no_cache, + push: args.push, + plain_http: args.plain_http, + image: args + .buildkit_image + .clone() + .unwrap_or_else(buildkit_vm::default_image), + cpus: args + .buildkit_cpus + .clone() + .unwrap_or_else(buildkit_vm::default_cpus), + memory: args + .buildkit_memory + .clone() + .unwrap_or_else(buildkit_vm::default_memory), + }) + .await; + } + // Open image store let store = Arc::new(super::open_image_store()?); - let platforms = parse_platforms(args.platform.as_deref())?; - let config = a3s_box_runtime::BuildConfig { context_dir, dockerfile_path, @@ -79,6 +206,7 @@ pub async fn execute(args: BuildArgs) -> Result<(), Box> target: args.target.clone(), no_cache: args.no_cache, metrics: None, + run_pool, }; let result = a3s_box_runtime::oci::build::engine::build(config, store).await?; @@ -90,6 +218,115 @@ pub async fn execute(args: BuildArgs) -> Result<(), Box> Ok(()) } +fn resolve_run_pool_config( + args: &BuildArgs, +) -> Result, Box> { + let env_socket = std::env::var(BUILD_RUN_POOL_SOCKET_ENV) + .ok() + .filter(|value| !value.trim().is_empty()); + let env_cache_dir = std::env::var(BUILD_RUN_CACHE_DIR_ENV) + .ok() + .filter(|value| !value.trim().is_empty()); + let enabled = args.run_pool + || args.run_pool_autostart + || args.run_pool_socket.is_some() + || env_socket.is_some() + || args.run_cache_dir.is_some() + || env_cache_dir.is_some(); + if !enabled { + return Ok(None); + } + + if args.run_pool_timeout == 0 { + return Err("--run-pool-timeout must be greater than 0".into()); + } + + let socket = args + .run_pool_socket + .clone() + .or(env_socket) + .unwrap_or_else(|| super::pool::DEFAULT_SOCKET.to_string()); + let memory_mb = crate::output::parse_memory(&args.run_pool_memory) + .map_err(|e| format!("Invalid --run-pool-memory: {e}"))?; + if args.run_pool_autostart && args.run_pool_image.is_none() { + return Err( + "--run-pool-autostart requires --run-pool-image so the helper VM image is explicit" + .into(), + ); + } + let run_cache_dir = args + .run_cache_dir + .clone() + .or(env_cache_dir) + .map(PathBuf::from) + .unwrap_or_else(|| { + a3s_box_core::dirs_home() + .join("buildcache") + .join("run-cache") + }); + + Ok(Some(a3s_box_runtime::BuildRunPoolConfig { + socket, + image: args.run_pool_image.clone(), + vcpus: args.run_pool_cpus, + memory_mb, + guest_rootfs: DEFAULT_BUILD_RUN_POOL_GUEST_ROOTFS.to_string(), + timeout_ns: args.run_pool_timeout.saturating_mul(1_000_000_000), + run_cache_dir, + })) +} + +fn pool_autostart_config_for_build( + config: &a3s_box_runtime::BuildRunPoolConfig, +) -> Result> { + Ok(super::pool::PoolAutoStartConfig { + socket: config.socket.clone(), + image: None, + size: super::pool::DEFAULT_AUTOSTART_POOL_SIZE, + max: super::pool::DEFAULT_AUTOSTART_POOL_MAX, + }) +} + +fn should_use_buildkit_vm( + backend: BuildBackend, + dockerfile_path: &std::path::Path, +) -> Result> { + match backend { + BuildBackend::BuildkitVm => Ok(true), + BuildBackend::Host => Ok(false), + BuildBackend::Auto => { + #[cfg(target_os = "macos")] + { + Ok(dockerfile_has_run(dockerfile_path)? && !unsafe_host_run_enabled()) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = dockerfile_path; + Ok(false) + } + } + } +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn unsafe_host_run_enabled() -> bool { + std::env::var("A3S_BOX_UNSAFE_HOST_RUN") + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn dockerfile_has_run( + dockerfile_path: &std::path::Path, +) -> Result> { + let dockerfile = a3s_box_runtime::Dockerfile::from_file(dockerfile_path)?; + Ok(dockerfile + .instructions + .iter() + .any(|instruction| matches!(instruction, a3s_box_runtime::Instruction::Run { .. }))) +} + /// Parse KEY=VALUE pairs into a HashMap. fn parse_build_args(args: &[String]) -> Result, String> { let mut map = HashMap::new(); @@ -160,6 +397,55 @@ fn resolve_build_file( mod tests { use super::*; + struct EnvGuard { + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + fn build_args() -> BuildArgs { + BuildArgs { + path: ".".to_string(), + tag: None, + file: None, + build_arg: vec![], + quiet: false, + platform: None, + target: None, + no_cache: false, + builder: BuildBackend::Auto, + buildkit_image: None, + buildkit_cpus: None, + buildkit_memory: None, + push: false, + plain_http: false, + run_pool: false, + run_pool_socket: None, + run_pool_autostart: false, + run_pool_image: None, + run_pool_cpus: 2, + run_pool_memory: "512m".to_string(), + run_pool_timeout: 3600, + run_cache_dir: None, + } + } + #[test] fn test_parse_build_args_valid() { let args = vec!["VERSION=1.0".to_string(), "DEBUG=true".to_string()]; @@ -190,6 +476,109 @@ mod tests { ); } + #[test] + fn test_should_use_buildkit_vm_respects_explicit_backend() { + let tmp = tempfile::tempdir().unwrap(); + let dockerfile = tmp.path().join("Dockerfile"); + std::fs::write(&dockerfile, "FROM scratch\nRUN echo hi\n").unwrap(); + + assert!(should_use_buildkit_vm(BuildBackend::BuildkitVm, &dockerfile).unwrap()); + assert!(!should_use_buildkit_vm(BuildBackend::Host, &dockerfile).unwrap()); + } + + #[test] + fn test_resolve_run_pool_config_explicit_socket() { + let mut args = build_args(); + args.run_pool = true; + args.run_pool_socket = Some("/tmp/a3s-build-pool.sock".to_string()); + args.run_pool_image = Some("alpine:latest".to_string()); + args.run_pool_cpus = 4; + args.run_pool_memory = "1g".to_string(); + args.run_pool_timeout = 90; + args.run_cache_dir = Some("/tmp/a3s-run-cache".to_string()); + + let config = resolve_run_pool_config(&args).unwrap().unwrap(); + + assert_eq!(config.socket, "/tmp/a3s-build-pool.sock"); + assert_eq!(config.image.as_deref(), Some("alpine:latest")); + assert_eq!(config.vcpus, 4); + assert_eq!(config.memory_mb, 1024); + assert_eq!(config.guest_rootfs, DEFAULT_BUILD_RUN_POOL_GUEST_ROOTFS); + assert_eq!(config.timeout_ns, 90_000_000_000); + assert_eq!(config.run_cache_dir, PathBuf::from("/tmp/a3s-run-cache")); + } + + #[test] + fn test_resolve_run_pool_config_rejects_zero_timeout() { + let mut args = build_args(); + args.run_pool = true; + args.run_pool_timeout = 0; + + let err = resolve_run_pool_config(&args).unwrap_err().to_string(); + + assert!(err.contains("--run-pool-timeout")); + } + + #[test] + fn test_resolve_run_pool_config_autostart_requires_image() { + let mut args = build_args(); + args.run_pool_autostart = true; + + let err = resolve_run_pool_config(&args).unwrap_err().to_string(); + + assert!(err.contains("--run-pool-autostart")); + assert!(err.contains("--run-pool-image")); + } + + #[test] + fn test_pool_autostart_config_for_build_starts_lazy_helper_daemon() { + let mut args = build_args(); + args.run_pool = true; + args.run_pool_autostart = true; + args.run_pool_socket = Some("/tmp/a3s-build-pool.sock".to_string()); + args.run_pool_image = Some("alpine:latest".to_string()); + + let config = resolve_run_pool_config(&args).unwrap().unwrap(); + let autostart = pool_autostart_config_for_build(&config).unwrap(); + + assert_eq!(config.image.as_deref(), Some("alpine:latest")); + assert_eq!(autostart.socket, "/tmp/a3s-build-pool.sock"); + assert!(autostart.image.is_none()); + assert_eq!( + autostart.size, + crate::commands::pool::DEFAULT_AUTOSTART_POOL_SIZE + ); + assert_eq!( + autostart.max, + crate::commands::pool::DEFAULT_AUTOSTART_POOL_MAX + ); + } + + #[test] + fn test_resolve_run_pool_config_env_cache_dir_enables_pool() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = tmp.path().join("run-cache"); + let _guard = EnvGuard::set(BUILD_RUN_CACHE_DIR_ENV, cache_dir.as_os_str()); + let args = build_args(); + + let config = resolve_run_pool_config(&args).unwrap().unwrap(); + + assert_eq!(config.socket, crate::commands::pool::DEFAULT_SOCKET); + assert_eq!(config.run_cache_dir, cache_dir); + } + + #[test] + fn test_dockerfile_has_run_detects_run_instruction() { + let tmp = tempfile::tempdir().unwrap(); + let dockerfile = tmp.path().join("Dockerfile"); + std::fs::write(&dockerfile, "FROM scratch\nRUN echo hi\n").unwrap(); + + assert!(dockerfile_has_run(&dockerfile).unwrap()); + + std::fs::write(&dockerfile, "FROM scratch\nCOPY . /app\n").unwrap(); + assert!(!dockerfile_has_run(&dockerfile).unwrap()); + } + #[test] fn test_parse_platforms_empty() { let result = parse_platforms(None).unwrap(); diff --git a/src/cli/src/commands/build_buildkit_vm.rs b/src/cli/src/commands/build_buildkit_vm.rs new file mode 100644 index 00000000..110d4191 --- /dev/null +++ b/src/cli/src/commands/build_buildkit_vm.rs @@ -0,0 +1,562 @@ +//! BuildKit-in-A3S-VM delegation for `a3s-box build`. + +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use tokio::process::Command; + +const DEFAULT_BUILDKIT_IMAGE: &str = "moby/buildkit:latest"; +const DEFAULT_BUILDKIT_CPUS: &str = "4"; +const DEFAULT_BUILDKIT_MEMORY: &str = "8g"; +const OUTPUT_TAR: &str = "image.tar"; +const BUILD_SCRIPT: &str = "a3s-buildkit-build.sh"; +const DOCKER_CONFIG_GUEST_PATH: &str = "/root/.docker/config.json"; +const BUILDKIT_STATE_DIR: &str = "/var/lib/buildkit"; + +pub(super) struct Build { + pub(super) context_dir: PathBuf, + pub(super) dockerfile_path: PathBuf, + pub(super) tag: Option, + pub(super) build_args: Vec, + pub(super) quiet: bool, + pub(super) platform: Option, + pub(super) target: Option, + pub(super) no_cache: bool, + pub(super) push: bool, + pub(super) plain_http: bool, + pub(super) image: String, + pub(super) cpus: String, + pub(super) memory: String, +} + +struct BuildkitAuthConfig { + _dir: tempfile::TempDir, + path: PathBuf, +} + +impl BuildkitAuthConfig { + fn path(&self) -> &Path { + &self.path + } +} + +pub(super) async fn execute(options: Build) -> Result<(), Box> { + if options.push && options.tag.is_none() { + return Err("--push requires --tag so BuildKit knows which image reference to push".into()); + } + + let dockerfile_arg = dockerfile_arg(&options.context_dir, &options.dockerfile_path)?; + let output_dir = + tempfile::tempdir().map_err(|e| format!("Failed to create BuildKit output dir: {e}"))?; + let output_tar = output_dir.path().join(OUTPUT_TAR); + let auth_config = if options.push { + let tag = options + .tag + .as_deref() + .ok_or("--push requires --tag so BuildKit knows which image reference to push")?; + buildkit_auth_config(tag)? + } else { + None + }; + + let buildctl_args = buildctl_args(&options, &dockerfile_arg, &output_tar)?; + write_build_script(output_dir.path(), &buildctl_args)?; + + let run_args = run_args( + &options, + output_dir.path(), + auth_config.as_ref().map(BuildkitAuthConfig::path), + )?; + if !options.quiet { + eprintln!("Building with BuildKit inside an A3S VM..."); + } + run_current_a3s_box(&run_args).await?; + + if options.push { + return Ok(()); + } + + if !output_tar.exists() { + return Err(format!( + "BuildKit did not produce the expected OCI archive at {}", + output_tar.display() + ) + .into()); + } + let load_args = load_args(&output_tar, options.tag.as_deref()); + run_current_a3s_box(&load_args).await?; + Ok(()) +} + +async fn run_current_a3s_box(args: &[String]) -> Result<(), Box> { + let exe = + std::env::current_exe().map_err(|e| format!("Failed to locate a3s-box binary: {e}"))?; + let status = Command::new(&exe) + .args(args) + .status() + .await + .map_err(|e| format!("Failed to run {} {}: {e}", exe.display(), args.join(" ")))?; + if !status.success() { + return Err(format!( + "`{} {}` failed with status {}", + exe.display(), + args.join(" "), + status + ) + .into()); + } + Ok(()) +} + +fn dockerfile_arg( + context_dir: &Path, + dockerfile_path: &Path, +) -> Result> { + let context = context_dir.canonicalize().map_err(|e| { + format!( + "Failed to canonicalize build context {}: {}", + context_dir.display(), + e + ) + })?; + let dockerfile = dockerfile_path.canonicalize().map_err(|e| { + format!( + "Failed to canonicalize build file {}: {}", + dockerfile_path.display(), + e + ) + })?; + let rel = dockerfile.strip_prefix(&context).map_err(|_| { + format!( + "BuildKit VM delegation requires the build file to be inside the build context: {} is outside {}", + dockerfile.display(), + context.display() + ) + })?; + let rel = rel.to_str().ok_or_else(|| { + format!( + "Build file path is not valid UTF-8 for BuildKit VM delegation: {}", + rel.display() + ) + })?; + Ok(rel.replace('\\', "/")) +} + +fn run_args( + options: &Build, + output_dir: &Path, + auth_config: Option<&Path>, +) -> Result, Box> { + let context = options.context_dir.to_str().ok_or_else(|| { + format!( + "Build context path is not valid UTF-8: {}", + options.context_dir.display() + ) + })?; + let output = output_dir.to_str().ok_or_else(|| { + format!( + "BuildKit output path is not valid UTF-8: {}", + output_dir.display() + ) + })?; + let mut args = vec![ + "run".to_string(), + "--rm".to_string(), + "--no-stdin".to_string(), + "--cpus".to_string(), + options.cpus.clone(), + "--memory".to_string(), + options.memory.clone(), + "--privileged".to_string(), + "--volume".to_string(), + format!("{context}:/workspace:ro"), + "--volume".to_string(), + format!("{output}:/out"), + "--tmpfs".to_string(), + BUILDKIT_STATE_DIR.to_string(), + ]; + + if let Some(auth_config) = auth_config { + let auth_config = auth_config.to_str().ok_or_else(|| { + format!( + "BuildKit auth config path is not valid UTF-8: {}", + auth_config.display() + ) + })?; + args.push("--volume".to_string()); + args.push(format!("{auth_config}:{DOCKER_CONFIG_GUEST_PATH}:ro")); + } + + args.extend([ + "--entrypoint".to_string(), + "/bin/sh".to_string(), + options.image.clone(), + "--".to_string(), + format!("/out/{BUILD_SCRIPT}"), + ]); + Ok(args) +} + +fn buildctl_args( + options: &Build, + dockerfile_arg: &str, + output_tar: &Path, +) -> Result, Box> { + let output_name = output_tar + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("Invalid BuildKit output file: {}", output_tar.display()))?; + let mut args = vec![ + "build".to_string(), + "--frontend".to_string(), + "dockerfile.v0".to_string(), + "--local".to_string(), + "context=/workspace".to_string(), + "--local".to_string(), + "dockerfile=/workspace".to_string(), + "--opt".to_string(), + format!("filename={dockerfile_arg}"), + ]; + + for build_arg in &options.build_args { + args.push("--opt".to_string()); + args.push(format!("build-arg:{build_arg}")); + } + if let Some(platform) = &options.platform { + args.push("--opt".to_string()); + args.push(format!("platform={platform}")); + } + if let Some(target) = &options.target { + args.push("--opt".to_string()); + args.push(format!("target={target}")); + } + if options.no_cache { + args.push("--no-cache".to_string()); + } + + args.push("--output".to_string()); + args.push(output_attr(options, output_name)?); + Ok(args) +} + +fn write_build_script( + output_dir: &Path, + args: &[String], +) -> Result<(), Box> { + let script_path = output_dir.join(BUILD_SCRIPT); + let mut script = String::from("#!/bin/sh\nset -eu\nexec buildctl-daemonless.sh"); + for arg in args { + script.push(' '); + script.push_str(&shell_quote(arg)); + } + script.push('\n'); + std::fs::write(&script_path, script).map_err(|error| { + format!( + "Failed to write BuildKit helper script {}: {error}", + script_path.display() + ) + })?; + Ok(()) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn buildkit_auth_config( + tag: &str, +) -> Result, Box> { + let reference = a3s_box_runtime::ImageReference::parse(tag)?; + let auth = a3s_box_runtime::RegistryAuth::from_credential_store(&reference.registry); + let Some((username, password)) = auth.basic_credentials() else { + return Ok(None); + }; + + Ok(Some(write_buildkit_auth_config( + &reference.registry, + &username, + &password, + )?)) +} + +fn write_buildkit_auth_config( + registry: &str, + username: &str, + password: &str, +) -> Result> { + let dir = + tempfile::tempdir().map_err(|e| format!("Failed to create BuildKit auth dir: {e}"))?; + let path = dir.path().join("config.json"); + let auth = base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + let mut auths = serde_json::Map::new(); + for key in docker_config_registry_keys(registry) { + auths.insert(key, serde_json::json!({ "auth": auth.clone() })); + } + let config = serde_json::json!({ "auths": auths }); + let data = serde_json::to_vec_pretty(&config)?; + std::fs::write(&path, data).map_err(|e| { + format!( + "Failed to write BuildKit Docker auth config {}: {}", + path.display(), + e + ) + })?; + + Ok(BuildkitAuthConfig { _dir: dir, path }) +} + +fn docker_config_registry_keys(registry: &str) -> Vec { + let registry = registry.trim().to_lowercase(); + let mut keys = if matches!( + registry.as_str(), + "docker.io" | "index.docker.io" | "registry-1.docker.io" + ) { + vec![ + "docker.io".to_string(), + "index.docker.io".to_string(), + "registry-1.docker.io".to_string(), + "https://index.docker.io/v1/".to_string(), + ] + } else { + vec![registry] + }; + keys.sort(); + keys.dedup(); + keys +} + +fn output_attr(options: &Build, output_name: &str) -> Result> { + if options.push { + let tag = options + .tag + .as_deref() + .ok_or("--push requires --tag so BuildKit knows which image reference to push")?; + let insecure = if options.plain_http { + ",registry.insecure=true" + } else { + "" + }; + return Ok(format!("type=image,name={tag},push=true{insecure}")); + } + + Ok(format!("type=oci,dest=/out/{output_name}")) +} + +fn load_args(output_tar: &Path, tag: Option<&str>) -> Vec { + let mut args = vec![ + "load".to_string(), + "--input".to_string(), + output_tar.to_string_lossy().to_string(), + ]; + if let Some(tag) = tag { + args.push("--tag".to_string()); + args.push(tag.to_string()); + } + args +} + +pub(super) fn default_image() -> String { + std::env::var("A3S_BOX_BUILDKIT_IMAGE") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_BUILDKIT_IMAGE.to_string()) +} + +pub(super) fn default_cpus() -> String { + std::env::var("A3S_BOX_BUILDKIT_CPUS") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_BUILDKIT_CPUS.to_string()) +} + +pub(super) fn default_memory() -> String { + std::env::var("A3S_BOX_BUILDKIT_MEMORY") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_BUILDKIT_MEMORY.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_options() -> Build { + Build { + context_dir: PathBuf::from("/context"), + dockerfile_path: PathBuf::from("/context/docker/Dockerfile.web"), + tag: Some("example.com/app:latest".to_string()), + build_args: vec!["VERSION=1.2.3".to_string()], + quiet: true, + platform: Some("linux/arm64".to_string()), + target: Some("builder".to_string()), + no_cache: true, + push: false, + plain_http: false, + image: "moby/buildkit:latest".to_string(), + cpus: "6".to_string(), + memory: "8g".to_string(), + } + } + + #[test] + fn test_dockerfile_arg_requires_file_inside_context() { + let tmp = tempfile::tempdir().unwrap(); + let context = tmp.path().join("context"); + let outside = tmp.path().join("Dockerfile.outside"); + std::fs::create_dir_all(context.join("docker")).unwrap(); + std::fs::write(context.join("docker/Dockerfile.web"), "FROM scratch\n").unwrap(); + std::fs::write(&outside, "FROM scratch\n").unwrap(); + + let rel = dockerfile_arg(&context, &context.join("docker/Dockerfile.web")).unwrap(); + assert_eq!(rel, "docker/Dockerfile.web"); + + let err = dockerfile_arg(&context, &outside).unwrap_err().to_string(); + assert!(err.contains("inside the build context")); + } + + #[test] + fn test_run_args_buildkit_daemonless_oci_output() { + let options = base_options(); + let args = run_args(&options, Path::new("/tmp/out"), None).unwrap(); + let build_args = buildctl_args( + &options, + "docker/Dockerfile.web", + Path::new("/tmp/out/image.tar"), + ) + .unwrap(); + + assert_eq!(args[0], "run"); + assert!(args.contains(&"--privileged".to_string())); + assert!(args.contains(&"moby/buildkit:latest".to_string())); + assert!(args.contains(&"/bin/sh".to_string())); + assert!(args.contains(&format!("/out/{BUILD_SCRIPT}"))); + assert!(args.contains(&"--tmpfs".to_string())); + assert!(args.contains(&"/var/lib/buildkit".to_string())); + assert!(build_args.contains(&"context=/workspace".to_string())); + assert!(build_args.contains(&"dockerfile=/workspace".to_string())); + assert!(build_args.contains(&"filename=docker/Dockerfile.web".to_string())); + assert!(build_args.contains(&"build-arg:VERSION=1.2.3".to_string())); + assert!(build_args.contains(&"platform=linux/arm64".to_string())); + assert!(build_args.contains(&"target=builder".to_string())); + assert!(build_args.contains(&"--no-cache".to_string())); + assert!(build_args.contains(&"type=oci,dest=/out/image.tar".to_string())); + } + + #[test] + fn test_run_args_buildkit_image_push_output() { + let mut options = base_options(); + options.push = true; + options.plain_http = true; + let args = buildctl_args( + &options, + "docker/Dockerfile.web", + Path::new("/tmp/out/image.tar"), + ) + .unwrap(); + + assert!(args.contains( + &"type=image,name=example.com/app:latest,push=true,registry.insecure=true".to_string() + )); + assert!(!args.contains(&"type=oci,dest=/out/image.tar".to_string())); + } + + #[test] + fn test_run_args_mounts_buildkit_auth_config() { + let options = base_options(); + let args = run_args( + &options, + Path::new("/tmp/out"), + Some(Path::new("/tmp/auth/config.json")), + ) + .unwrap(); + + assert!(args.contains(&"/tmp/auth/config.json:/root/.docker/config.json:ro".to_string())); + } + + #[test] + fn test_build_script_preserves_spaces_quotes_and_multiple_build_args() { + let tmp = tempfile::tempdir().unwrap(); + let args = vec![ + "build".to_string(), + "--opt".to_string(), + "build-arg:PLAIN=custom".to_string(), + "--opt".to_string(), + "build-arg:QUOTED=two words and 'quote'".to_string(), + ]; + + write_build_script(tmp.path(), &args).unwrap(); + let script = std::fs::read_to_string(tmp.path().join(BUILD_SCRIPT)).unwrap(); + + assert!(script.starts_with("#!/bin/sh\nset -eu\nexec buildctl-daemonless.sh ")); + assert!(script.contains("'build-arg:PLAIN=custom'")); + assert!(script.contains("'build-arg:QUOTED=two words and '\"'\"'quote'\"'\"''")); + } + + #[test] + fn test_write_buildkit_auth_config_writes_single_registry_auth() { + let config = write_buildkit_auth_config("ghcr.io", "user", "secret").unwrap(); + let data: serde_json::Value = + serde_json::from_slice(&std::fs::read(config.path()).unwrap()).unwrap(); + + let auth = data["auths"]["ghcr.io"]["auth"].as_str().unwrap(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(auth) + .unwrap(); + + assert_eq!(decoded, b"user:secret"); + } + + #[test] + fn test_docker_config_registry_keys_include_docker_hub_aliases() { + assert_eq!( + docker_config_registry_keys("docker.io"), + vec![ + "docker.io".to_string(), + "https://index.docker.io/v1/".to_string(), + "index.docker.io".to_string(), + "registry-1.docker.io".to_string(), + ] + ); + } + + #[test] + fn test_output_attr_push_requires_tag() { + let mut options = base_options(); + options.push = true; + options.tag = None; + + let err = output_attr(&options, "image.tar").unwrap_err().to_string(); + + assert!(err.contains("--push requires --tag")); + } + + #[test] + fn test_load_args_adds_tag() { + let args = load_args(Path::new("/tmp/out/image.tar"), Some("app:latest")); + assert_eq!( + args, + vec![ + "load", + "--input", + "/tmp/out/image.tar", + "--tag", + "app:latest" + ] + ); + } + + #[test] + fn test_run_args_accepts_amd64_platform_for_buildkit_emulation() { + let mut options = base_options(); + options.platform = Some("linux/amd64".to_string()); + + let args = buildctl_args( + &options, + "docker/Dockerfile.web", + Path::new("/tmp/out/image.tar"), + ) + .unwrap(); + + assert!(args.contains(&"platform=linux/amd64".to_string())); + } +} diff --git a/src/cli/src/commands/commit.rs b/src/cli/src/commands/commit.rs index daf649d0..63a7ec62 100644 --- a/src/cli/src/commands/commit.rs +++ b/src/cli/src/commands/commit.rs @@ -6,6 +6,8 @@ use std::path::Path; use std::sync::Arc; +#[cfg(unix)] +use base64::Engine; use clap::Args; use sha2::{Digest, Sha256}; @@ -41,15 +43,24 @@ pub async fn execute(args: CommitArgs) -> Result<(), Box> let state = StateFile::load_default()?; let record = resolve::resolve(&state, &args.name)?; - let rootfs_dir = super::resolve_box_rootfs(&record.box_dir).ok_or_else(|| { - format!( - "Rootfs not found for box '{}' under {} (looked for merged/ and rootfs/). \ - For overlay-backed boxes the filesystem is only available while the box exists; \ - commit a running box.", - args.name, - record.box_dir.display() - ) - })?; + let attached_rootfs = if record.status == "running" { + None + } else { + a3s_box_runtime::rootfs::attach_persistent_rootfs(&record.box_dir)? + }; + let rootfs_dir = attached_rootfs + .as_ref() + .map(|rootfs| rootfs.path().to_path_buf()) + .or_else(|| super::resolve_box_rootfs(&record.box_dir)) + .ok_or_else(|| { + format!( + "Rootfs not found for box '{}' under {} (looked for merged/ and rootfs/). \ + For overlay-backed boxes the filesystem is only available while the box exists; \ + commit a running box.", + args.name, + record.box_dir.display() + ) + })?; let reference = args.repository.unwrap_or_else(|| { format!( @@ -63,16 +74,20 @@ pub async fn execute(args: CommitArgs) -> Result<(), Box> // Create a temporary directory for the OCI image layout let tmp = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?; let image_dir = tmp.path(); + let rootfs_tar = image_dir.join("rootfs.tar"); + + capture_rootfs_tar(record, &rootfs_dir, &rootfs_tar, args.pause).await?; // Build OCI image layout - build_oci_image( + build_oci_image_from_tar( image_dir, - &rootfs_dir, + &rootfs_tar, &reference, &args.message, &args.author, &args.change, )?; + std::fs::remove_file(&rootfs_tar)?; // Compute image digest from manifest let manifest_bytes = std::fs::read(image_dir.join("manifest.json")).or_else(|_| { @@ -87,7 +102,7 @@ pub async fn execute(args: CommitArgs) -> Result<(), Box> println!( "sha256:{}", - &stored + stored .digest .strip_prefix("sha256:") .unwrap_or(&stored.digest) @@ -96,6 +111,174 @@ pub async fn execute(args: CommitArgs) -> Result<(), Box> Ok(()) } +#[cfg(unix)] +async fn capture_rootfs_tar( + record: &crate::state::BoxRecord, + rootfs_dir: &Path, + output: &Path, + pause: bool, +) -> Result<(), Box> { + if record.status == "running" && record.exec_socket_path.exists() { + let client = a3s_box_runtime::ExecClient::connect(&record.exec_socket_path).await?; + let mut file = tokio::fs::File::create(output).await?; + let written = client.archive_rootfs(&mut file, pause).await?; + if written == 0 { + return Err("Guest rootfs archive was empty".into()); + } + file.sync_all().await?; + return Ok(()); + } + + let metadata_path = rootfs_dir + .join(a3s_box_core::rootfs_metadata::ROOTFS_METADATA_PATH.trim_start_matches('/')); + let bytes = std::fs::read(&metadata_path).map_err(|error| { + format!( + "Guest rootfs metadata is unavailable at {}: {error}. Start the box with this A3S Box version and stop it cleanly before committing.", + metadata_path.display() + ) + })?; + let manifest: a3s_box_core::rootfs_metadata::RootfsMetadataManifest = + serde_json::from_slice(&bytes)?; + manifest + .validate() + .map_err(|error| format!("Invalid guest rootfs metadata: {error}"))?; + create_tar_from_guest_metadata(rootfs_dir, &manifest, output) +} + +#[cfg(windows)] +async fn capture_rootfs_tar( + _record: &crate::state::BoxRecord, + _rootfs_dir: &Path, + _output: &Path, + _pause: bool, +) -> Result<(), Box> { + Err("committing box filesystems is not supported on Windows".into()) +} + +#[cfg(unix)] +fn create_tar_from_guest_metadata( + rootfs_dir: &Path, + manifest: &a3s_box_core::rootfs_metadata::RootfsMetadataManifest, + output: &Path, +) -> Result<(), Box> { + use a3s_box_core::rootfs_metadata::RootfsEntryKind; + use std::collections::{HashMap, HashSet}; + use std::ffi::OsString; + use std::io::Cursor; + use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::MetadataExt; + + let mut decoded = Vec::with_capacity(manifest.entries.len()); + let mut paths = HashSet::with_capacity(manifest.entries.len()); + for entry in &manifest.entries { + let bytes = base64::engine::general_purpose::STANDARD + .decode(&entry.path_base64) + .map_err(|error| format!("Invalid rootfs metadata path: {error}"))?; + let path = std::path::PathBuf::from(OsString::from_vec(bytes)); + validate_archive_path(&path)?; + if !paths.insert(path.clone()) { + return Err(format!("Duplicate rootfs metadata path: {}", path.display()).into()); + } + decoded.push((path, entry)); + } + decoded.sort_by(|left, right| left.0.as_os_str().cmp(right.0.as_os_str())); + + let file = std::fs::File::create(output)?; + let mut builder = tar::Builder::new(file); + let mut hardlinks = HashMap::<(u64, u64), std::path::PathBuf>::new(); + for (path, entry) in decoded { + let source = rootfs_dir.join(&path); + let host_metadata = std::fs::symlink_metadata(&source).map_err(|error| { + format!( + "Rootfs changed after terminal metadata capture at {}: {error}", + source.display() + ) + })?; + let mut header = tar::Header::new_gnu(); + header.set_mode(entry.mode & 0o7777); + header.set_uid(entry.uid); + header.set_gid(entry.gid); + header.set_mtime(entry.mtime); + + match entry.kind { + RootfsEntryKind::Directory => { + if !host_metadata.file_type().is_dir() { + return Err(format!("Rootfs entry changed type: {}", path.display()).into()); + } + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_cksum(); + builder.append_data(&mut header, &path, Cursor::new([]))?; + } + RootfsEntryKind::Regular => { + if !host_metadata.file_type().is_file() || host_metadata.len() != entry.size { + return Err( + format!("Rootfs entry changed after capture: {}", path.display()).into(), + ); + } + let inode = (host_metadata.dev(), host_metadata.ino()); + if host_metadata.nlink() > 1 { + if let Some(first_path) = hardlinks.get(&inode) { + header.set_entry_type(tar::EntryType::Link); + header.set_size(0); + header.set_link_name(first_path)?; + header.set_cksum(); + builder.append_data(&mut header, &path, Cursor::new([]))?; + continue; + } + hardlinks.insert(inode, path.clone()); + } + header.set_entry_type(tar::EntryType::Regular); + header.set_size(entry.size); + header.set_cksum(); + let file = std::fs::File::open(&source)?; + builder.append_data(&mut header, &path, file)?; + } + RootfsEntryKind::Symlink => { + if !host_metadata.file_type().is_symlink() { + return Err(format!("Rootfs entry changed type: {}", path.display()).into()); + } + let target = entry + .link_target_base64 + .as_ref() + .ok_or_else(|| format!("Missing symlink target: {}", path.display()))?; + let target = base64::engine::general_purpose::STANDARD.decode(target)?; + let target = std::path::PathBuf::from(OsString::from_vec(target)); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_cksum(); + builder.append_link(&mut header, &path, target)?; + } + } + } + builder.finish()?; + Ok(()) +} + +#[cfg(not(unix))] +fn create_tar_from_guest_metadata( + _rootfs_dir: &Path, + _manifest: &a3s_box_core::rootfs_metadata::RootfsMetadataManifest, + _output: &Path, +) -> Result<(), Box> { + Err("Stopped-box guest metadata commit is not supported on this host".into()) +} + +fn validate_archive_path(path: &Path) -> Result<(), Box> { + if path.as_os_str().is_empty() || path.is_absolute() { + return Err("Rootfs metadata contains an absolute or empty path".into()); + } + if path.components().any(|component| { + !matches!( + component, + std::path::Component::Normal(_) | std::path::Component::CurDir + ) + }) { + return Err(format!("Unsafe rootfs metadata path: {}", path.display()).into()); + } + Ok(()) +} + /// Find the manifest blob in the OCI layout. fn find_manifest_blob(image_dir: &Path) -> Result, std::io::Error> { let index_path = image_dir.join("index.json"); @@ -116,6 +299,7 @@ fn find_manifest_blob(image_dir: &Path) -> Result, std::io::Error> { } /// Build a minimal OCI image layout from a rootfs directory. +#[cfg(test)] fn build_oci_image( output_dir: &Path, rootfs_dir: &Path, @@ -123,6 +307,28 @@ fn build_oci_image( message: &Option, author: &Option, changes: &[String], +) -> Result<(), Box> { + let tar_path = output_dir.join("rootfs.host.tar"); + { + let file = std::fs::File::create(&tar_path)?; + let mut builder = tar::Builder::new(file); + builder.follow_symlinks(false); + builder.append_dir_all(".", rootfs_dir)?; + builder.finish()?; + } + let result = + build_oci_image_from_tar(output_dir, &tar_path, _reference, message, author, changes); + let _ = std::fs::remove_file(tar_path); + result +} + +fn build_oci_image_from_tar( + output_dir: &Path, + rootfs_tar: &Path, + _reference: &str, + message: &Option, + author: &Option, + changes: &[String], ) -> Result<(), Box> { use flate2::write::GzEncoder; use flate2::Compression; @@ -134,15 +340,10 @@ fn build_oci_image( let layer_path = blobs_dir.join("layer.tmp"); { let file = std::fs::File::create(&layer_path)?; - let encoder = GzEncoder::new(file, Compression::default()); - let mut builder = tar::Builder::new(encoder); - builder.follow_symlinks(false); - builder - .append_dir_all(".", rootfs_dir) - .map_err(|e| format!("Failed to archive rootfs: {e}"))?; - builder - .finish() - .map_err(|e| format!("Failed to finalize layer: {e}"))?; + let mut encoder = GzEncoder::new(file, Compression::default()); + let mut input = std::fs::File::open(rootfs_tar)?; + std::io::copy(&mut input, &mut encoder)?; + encoder.finish()?; } // Hash the layer @@ -153,7 +354,7 @@ fn build_oci_image( std::fs::rename(&layer_path, &layer_blob)?; // Compute diff_id (sha256 of uncompressed tar) - let diff_id = compute_diff_id(rootfs_dir)?; + let diff_id = compute_file_sha256(rootfs_tar)?; // 2. Create image config let mut config_obj = serde_json::json!({ @@ -228,7 +429,23 @@ fn build_oci_image( Ok(()) } +fn compute_file_sha256(path: &Path) -> Result> { + use std::io::Read; + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + /// Compute the diff_id (sha256 of uncompressed tar) for a directory. +#[cfg(test)] fn compute_diff_id(rootfs_dir: &Path) -> Result> { let mut hasher = Sha256::new(); let buf = Vec::new(); @@ -378,6 +595,117 @@ mod tests { assert_eq!(id.len(), 64); // sha256 hex } + #[cfg(unix)] + #[test] + fn test_guest_metadata_overrides_host_uid_gid_and_mode_in_tar() { + use a3s_box_core::rootfs_metadata::{ + RootfsEntryKind, RootfsMetadataEntry, RootfsMetadataManifest, + }; + use std::os::unix::ffi::OsStrExt; + + let rootfs = tempfile::TempDir::new().unwrap(); + let file = rootfs.path().join("probe"); + std::fs::write(&file, b"payload").unwrap(); + let encoded = base64::engine::general_purpose::STANDARD + .encode(Path::new("probe").as_os_str().as_bytes()); + let manifest = RootfsMetadataManifest::new(vec![RootfsMetadataEntry { + path_base64: encoded, + kind: RootfsEntryKind::Regular, + mode: 0o100755, + uid: 0, + gid: 0, + mtime: 123, + size: 7, + link_target_base64: None, + }]); + let output = rootfs.path().join("rootfs.tar"); + + create_tar_from_guest_metadata(rootfs.path(), &manifest, &output).unwrap(); + + let mut archive = tar::Archive::new(std::fs::File::open(output).unwrap()); + let entry = archive.entries().unwrap().next().unwrap().unwrap(); + assert_eq!(entry.path().unwrap(), Path::new("probe")); + assert_eq!(entry.header().mode().unwrap() & 0o7777, 0o755); + assert_eq!(entry.header().uid().unwrap(), 0); + assert_eq!(entry.header().gid().unwrap(), 0); + assert_eq!(entry.header().mtime().unwrap(), 123); + } + + #[cfg(unix)] + #[test] + fn test_guest_metadata_preserves_hardlinks_without_duplicate_payloads() { + use a3s_box_core::rootfs_metadata::{ + RootfsEntryKind, RootfsMetadataEntry, RootfsMetadataManifest, + }; + use std::os::unix::ffi::OsStrExt; + + let rootfs = tempfile::TempDir::new().unwrap(); + std::fs::write(rootfs.path().join("busybox"), b"payload").unwrap(); + std::fs::hard_link(rootfs.path().join("busybox"), rootfs.path().join("sh")).unwrap(); + let entries = ["busybox", "sh"] + .into_iter() + .map(|path| RootfsMetadataEntry { + path_base64: base64::engine::general_purpose::STANDARD + .encode(Path::new(path).as_os_str().as_bytes()), + kind: RootfsEntryKind::Regular, + mode: 0o100755, + uid: 0, + gid: 0, + mtime: 123, + size: 7, + link_target_base64: None, + }) + .collect(); + let output = rootfs.path().join("rootfs.tar"); + + create_tar_from_guest_metadata( + rootfs.path(), + &RootfsMetadataManifest::new(entries), + &output, + ) + .unwrap(); + + let mut archive = tar::Archive::new(std::fs::File::open(output).unwrap()); + let mut entries = archive.entries().unwrap(); + let first = entries.next().unwrap().unwrap(); + assert_eq!(first.header().entry_type(), tar::EntryType::Regular); + drop(first); + let second = entries.next().unwrap().unwrap(); + assert_eq!(second.header().entry_type(), tar::EntryType::Link); + assert_eq!(second.link_name().unwrap().unwrap(), Path::new("busybox")); + } + + #[cfg(unix)] + #[test] + fn test_guest_metadata_rejects_parent_traversal() { + use a3s_box_core::rootfs_metadata::{ + RootfsEntryKind, RootfsMetadataEntry, RootfsMetadataManifest, + }; + use std::os::unix::ffi::OsStrExt; + + let rootfs = tempfile::TempDir::new().unwrap(); + let encoded = base64::engine::general_purpose::STANDARD + .encode(Path::new("../escape").as_os_str().as_bytes()); + let manifest = RootfsMetadataManifest::new(vec![RootfsMetadataEntry { + path_base64: encoded, + kind: RootfsEntryKind::Regular, + mode: 0o100600, + uid: 0, + gid: 0, + mtime: 0, + size: 0, + link_target_base64: None, + }]); + + let error = create_tar_from_guest_metadata( + rootfs.path(), + &manifest, + &rootfs.path().join("rootfs.tar"), + ) + .unwrap_err(); + assert!(error.to_string().contains("Unsafe rootfs metadata path")); + } + #[test] fn test_build_oci_image() { let rootfs = tempfile::tempdir().unwrap(); diff --git a/src/cli/src/commands/common.rs b/src/cli/src/commands/common.rs index 89195cda..991862b4 100644 --- a/src/cli/src/commands/common.rs +++ b/src/cli/src/commands/common.rs @@ -2,19 +2,64 @@ use std::collections::HashMap; -use a3s_box_core::config::ResourceLimits; +use a3s_box_core::config::{ExecutionIsolation, ResourceLimits}; use a3s_box_runtime::oci::{OciHealthCheck, OciImageConfig}; -use clap::Args; +use clap::{Args, ValueEnum}; use crate::image_usage; use crate::state::HealthCheck; +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum VirtiofsCacheMode { + /// Stable host/guest traversal, matching the default. + None, + /// Let virtio-fs choose its automatic cache policy. + Auto, + /// Prefer faster host source-tree reads when the host tree is not changing. + Always, + /// Do not pass an explicit cache option to virtio-fs. + Default, +} + +impl VirtiofsCacheMode { + pub(crate) fn as_guest_value(self) -> &'static str { + match self { + Self::None => "none", + Self::Auto => "auto", + Self::Always => "always", + Self::Default => "default", + } + } +} + +/// Explicit isolation choices exposed by the CLI. +/// +/// MicroVM is selected by omitting `--isolation`; only the opt-in sandbox +/// value is accepted explicitly. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum IsolationArg { + Sandbox, +} + +/// Resolve the optional CLI selector without exposing an explicit MicroVM +/// spelling. Omitting the option preserves the historical MicroVM default. +pub(crate) fn resolve_isolation(value: Option) -> ExecutionIsolation { + match value { + Some(IsolationArg::Sandbox) => ExecutionIsolation::Sandbox, + None => ExecutionIsolation::Microvm, + } +} + /// Common arguments shared between `run` and `create` commands. #[derive(Args)] pub struct CommonBoxArgs { /// OCI image reference pub image: String, + /// Use the shared-kernel sandbox backend (omit for MicroVM isolation) + #[arg(long, value_enum)] + pub isolation: Option, + /// Assign a name to the box #[arg(long)] pub name: Option, @@ -71,6 +116,10 @@ pub struct CommonBoxArgs { #[arg(long)] pub tmpfs: Vec, + /// virtio-fs cache mode for host directory volumes. + #[arg(long = "virtiofs-cache", value_enum)] + pub virtiofs_cache: Option, + /// Connect to a network (e.g., "mynet") #[arg(long)] pub network: Option, @@ -428,7 +477,35 @@ pub(crate) fn validate_runtime_options(common: &CommonBoxArgs) -> Result<(), Str &common.cap_drop, common.privileged, ); - security.validate() + security.validate()?; + + if execution_isolation(common).is_sandbox() { + let network = match common.network.as_ref() { + Some(network) => a3s_box_core::NetworkMode::Bridge { + network: network.clone(), + }, + None => a3s_box_core::NetworkMode::Tsi, + }; + let compatibility_config = a3s_box_core::BoxConfig { + isolation: a3s_box_core::ExecutionIsolation::Sandbox, + port_map: common.publish.clone(), + network, + cap_add: common.cap_add.clone(), + cap_drop: common.cap_drop.clone(), + security_opt: common.security_opt.clone(), + privileged: common.privileged, + ..Default::default() + }; + a3s_box_core::validate_sandbox_compatibility(&compatibility_config) + .map_err(|error| error.to_string())?; + } + + Ok(()) +} + +/// Resolve the CLI's opt-in selector to the persisted execution isolation. +pub(crate) fn execution_isolation(common: &CommonBoxArgs) -> ExecutionIsolation { + resolve_isolation(common.isolation) } /// Normalize a user option into the runtime-supported numeric format. @@ -828,6 +905,7 @@ mod tests { fn default_common_args() -> CommonBoxArgs { CommonBoxArgs { image: "test".to_string(), + isolation: None, name: None, cpus: 2, memory: "512m".to_string(), @@ -842,6 +920,7 @@ mod tests { restart: "no".to_string(), labels: vec![], tmpfs: vec![], + virtiofs_cache: None, network: None, health_cmd: None, health_interval: 30, diff --git a/src/cli/src/commands/compose.rs b/src/cli/src/commands/compose.rs index 694020f1..b388cb7b 100644 --- a/src/cli/src/commands/compose.rs +++ b/src/cli/src/commands/compose.rs @@ -1,6 +1,15 @@ //! `a3s-box compose` command — Multi-container orchestration. //! -//! Subcommands: `up`, `down`, `ps`, `config`. +//! Project discovery, service selection, and lifecycle operations are kept in +//! this command while individual box behavior is delegated to the existing +//! single-box commands. + +mod args; +mod lifecycle; +mod operations; +mod read; +#[cfg(test)] +mod tests; use std::collections::HashMap; use std::path::PathBuf; @@ -8,90 +17,52 @@ use std::path::PathBuf; use a3s_box_core::compose::{ComposeConfig, ServiceConfig}; use a3s_box_core::event::EventEmitter; use a3s_box_runtime::{ComposeProject, NetworkStore, VmManager}; -use clap::{Args, Subcommand}; +use sha2::{Digest, Sha256}; use super::common; use crate::state::{BoxRecord, HealthCheck, StateFile}; use crate::status; +pub use args::{ComposeArgs, ComposeCommand, ComposeDownArgs, ComposeLogsArgs, ComposeUpArgs}; +use lifecycle::{ + cleanup_partial_service_box, cleanup_service_box, execute_down, rollback_compose_up, + rollback_with_current, stop_service_process, ServiceBox, +}; +use operations::{ComposeStopArgs, ProjectServicesArgs}; +use read::{execute_config, execute_logs, execute_ps}; + /// Label key for compose project name. const LABEL_PROJECT: &str = "com.a3s.compose.project"; /// Label key for compose service name. const LABEL_SERVICE: &str = "com.a3s.compose.service"; +/// Label key for the normalized service configuration digest. +const LABEL_CONFIG_HASH: &str = "com.a3s.compose.config-hash"; +type ExistingService = (ServiceBox, Option); /// Default compose file names to search for. const COMPOSE_FILES: &[&str] = &[ + "compose.acl", "compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml", ]; -#[derive(Args)] -pub struct ComposeArgs { - /// Path to compose file (default: compose.yaml or docker-compose.yml) - #[arg(short = 'f', long = "file")] - pub file: Option, - - /// Project name (default: directory name) - #[arg(short = 'p', long = "project-name")] - pub project_name: Option, - - #[command(subcommand)] - pub command: ComposeCommand, -} - -#[derive(Subcommand)] -pub enum ComposeCommand { - /// Create and start all services - Up(ComposeUpArgs), - /// Stop and remove all services - Down(ComposeDownArgs), - /// List services and their status - Ps, - /// Validate and display the compose configuration - Config, - /// View logs from all services - Logs(ComposeLogsArgs), -} - -#[derive(Args)] -pub struct ComposeUpArgs { - /// Run in detached mode (background) - #[arg(short = 'd', long)] - pub detach: bool, - - /// Timeout in seconds to wait for healthy dependencies (default: 120) - #[arg(long, default_value = "120")] - pub timeout: u64, -} - -#[derive(Args)] -pub struct ComposeDownArgs { - /// Remove named volumes declared in the compose file - #[arg(short = 'v', long)] - pub volumes: bool, -} - -#[derive(Args)] -pub struct ComposeLogsArgs { - /// Follow log output - #[arg(short = 'f', long)] - pub follow: bool, - - /// Number of lines to show from the end of the logs - #[arg(long, default_value = "100")] - pub tail: usize, +pub async fn execute(args: ComposeArgs) -> Result<(), Box> { + let ComposeArgs { + file, + project_name, + command, + } = args; - /// Show logs for a specific service only - pub service: Option, -} + if let ComposeCommand::Ls(ls_args) = command { + return operations::execute_ls(ls_args).await; + } -pub async fn execute(args: ComposeArgs) -> Result<(), Box> { - let (compose_path, config) = load_compose_file(args.file.as_deref())?; + let (compose_path, config) = load_compose_file(file.as_deref())?; // Derive project name from flag or directory name - let project_name = args.project_name.unwrap_or_else(|| { + let project_name = project_name.unwrap_or_else(|| { compose_path .parent() .and_then(|p| p.file_name()) @@ -100,14 +71,60 @@ pub async fn execute(args: ComposeArgs) -> Result<(), Box .to_string() }); - match args.command { + match command { ComposeCommand::Up(up_args) => { execute_up(&project_name, config, compose_path, up_args).await } - ComposeCommand::Down(down_args) => execute_down(&project_name, down_args).await, - ComposeCommand::Ps => execute_ps(&project_name).await, + ComposeCommand::Down(down_args) => execute_down(&project_name, &config, down_args).await, + ComposeCommand::Ps(command_args) => execute_ps(&project_name, &config, command_args).await, ComposeCommand::Config => execute_config(&project_name, config), - ComposeCommand::Logs(logs_args) => execute_logs(&project_name, logs_args).await, + ComposeCommand::Logs(logs_args) => execute_logs(&project_name, &config, logs_args).await, + ComposeCommand::Start(command_args) => { + operations::execute_start(&project_name, &config, command_args).await + } + ComposeCommand::Stop(command_args) => { + operations::execute_stop(&project_name, &config, command_args).await + } + ComposeCommand::Restart(command_args) => { + operations::execute_restart(&project_name, &config, command_args).await + } + ComposeCommand::Rm(command_args) => { + operations::execute_rm(&project_name, &config, command_args).await + } + ComposeCommand::Kill(command_args) => { + operations::execute_kill(&project_name, &config, command_args).await + } + ComposeCommand::Pause(command_args) => { + operations::execute_pause(&project_name, &config, command_args).await + } + ComposeCommand::Unpause(command_args) => { + operations::execute_unpause(&project_name, &config, command_args).await + } + ComposeCommand::Wait(command_args) => { + operations::execute_wait(&project_name, &config, command_args).await + } + ComposeCommand::Exec(command_args) => { + operations::execute_exec(&project_name, &config, command_args).await + } + ComposeCommand::Top(command_args) => { + operations::execute_top(&project_name, &config, command_args).await + } + ComposeCommand::Port(command_args) => { + operations::execute_port(&project_name, &config, command_args).await + } + ComposeCommand::Cp(command_args) => { + operations::execute_cp(&project_name, &config, command_args).await + } + ComposeCommand::Images(command_args) => { + operations::execute_images(&project_name, &config, command_args) + } + ComposeCommand::Pull(command_args) => { + operations::execute_pull(&project_name, &config, command_args).await + } + ComposeCommand::Volumes => operations::execute_volumes(&project_name, &config), + ComposeCommand::Ls(_) => { + Err("Compose ls was not dispatched before project file loading".into()) + } } } @@ -115,33 +132,82 @@ pub async fn execute(args: ComposeArgs) -> Result<(), Box fn load_compose_file( explicit_path: Option<&std::path::Path>, ) -> Result<(PathBuf, ComposeConfig), Box> { - let path = if let Some(p) = explicit_path { + load_compose_file_with_environment(explicit_path, std::env::vars()) +} + +fn load_compose_file_with_environment( + explicit_path: Option<&std::path::Path>, + shell_environment: impl IntoIterator, +) -> Result<(PathBuf, ComposeConfig), Box> { + let cwd = std::env::current_dir()?; + let path = resolve_compose_path(explicit_path, &cwd)?; + + let source = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; + + let mut environment = HashMap::new(); + let environment_path = path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join(".env"); + match std::fs::read_to_string(&environment_path) { + Ok(contents) => { + for (key, value) in a3s_box_core::env::parse_env_file_content(&contents) { + environment.insert(key, value); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "Failed to read Compose environment file {}: {}", + environment_path.display(), + error + ) + .into()); + } + } + environment.extend(shell_environment); + + let config = if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("acl")) + { + ComposeConfig::from_acl_str_with_environment(&source, &environment) + .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))? + } else { + let source = a3s_box_core::compose::interpolate_compose_yaml(&source, &environment) + .map_err(|e| format!("Failed to interpolate {}: {}", path.display(), e))?; + ComposeConfig::from_yaml_str(&source) + .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))? + }; + + Ok((path, config)) +} + +fn resolve_compose_path( + explicit_path: Option<&std::path::Path>, + search_directory: &std::path::Path, +) -> Result> { + if let Some(p) = explicit_path { if !p.exists() { return Err(format!("Compose file not found: {}", p.display()).into()); } - p.to_path_buf() + Ok(p.to_path_buf()) } else { - // Search for default compose files in current directory - let cwd = std::env::current_dir()?; - COMPOSE_FILES + match COMPOSE_FILES .iter() - .map(|name| cwd.join(name)) + .map(|name| search_directory.join(name)) .find(|p| p.exists()) - .ok_or_else(|| { - format!( - "No compose file found. Looked for: {}", - COMPOSE_FILES.join(", ") - ) - })? - }; - - let yaml = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?; - - let config = ComposeConfig::from_yaml_str(&yaml) - .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?; - - Ok((path, config)) + { + Some(path) => Ok(path), + None => Err(format!( + "No compose file found. Looked for: {}", + COMPOSE_FILES.join(", ") + ) + .into()), + } + } } fn validate_compose_restart_policies(config: &ComposeConfig) -> Result<(), String> { @@ -177,36 +243,23 @@ async fn execute_up( compose_path: PathBuf, up_args: ComposeUpArgs, ) -> Result<(), Box> { + let isolation = common::resolve_isolation(up_args.isolation); + let config = operations::select_up_config(config, &up_args.services)?; let base_dir = compose_path .parent() .unwrap_or_else(|| std::path::Path::new(".")); validate_compose_restart_policies(&config) .map_err(|e| -> Box { e.into() })?; let project = ComposeProject::with_base_dir(project_name, config, base_dir)?; - let mut state = StateFile::load_default()?; - - // Check for already-active services - let existing = state.find_by_label(LABEL_PROJECT, project_name); - let active: Vec<_> = existing - .iter() - .filter(|record| status::is_active(record)) - .collect(); - if !active.is_empty() { - let names: Vec<_> = active - .iter() - .filter_map(|r| r.labels.get(LABEL_SERVICE)) - .collect(); - return Err(format!( - "Project '{}' already has active services: {}. Run `compose down` first.", - project_name, - names - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ) - .into()); + if isolation.is_sandbox() { + let default_network = project.default_network_name(); + for service_name in &project.service_order { + let mut config = project.build_box_config(service_name, Some(&default_network))?; + config.isolation = isolation; + a3s_box_core::resolve_execution(&config)?; + } } + let mut state = StateFile::load_default()?; // Step 1: Create networks let networks = project.required_networks(); @@ -284,6 +337,29 @@ async fn execute_up( ); for svc_name in &project.service_order { + let service = project.config.services.get(svc_name).ok_or_else(|| { + format!("Service '{svc_name}' disappeared from the resolved Compose project") + })?; + let mut desired_box_config = project.build_box_config(svc_name, Some(&default_net))?; + desired_box_config.isolation = isolation; + let config_hash = service_config_hash(service, &desired_box_config)?; + if let Some((existing, existing_hash)) = find_existing_service(project_name, svc_name)? { + if existing.is_active() && existing_hash.as_deref() == Some(config_hash.as_str()) { + println!(" [=] {} is unchanged and already running", svc_name); + continue; + } + + if existing.is_active() { + println!(" [~] Recreating changed service {}...", svc_name); + stop_service_process(&existing).await; + } else { + println!(" [~] Recreating existing service {}...", svc_name); + } + StateFile::remove_record(&existing.box_id)?; + state.forget(&existing.box_id); + cleanup_service_box(&existing); + } + // Wait for healthy dependencies before booting this service let health_deps = project.health_wait_deps(svc_name); if !health_deps.is_empty() { @@ -325,18 +401,7 @@ async fn execute_up( println!(" ✓"); } - let mut box_config = match project.build_box_config(svc_name, Some(&default_net)) { - Ok(config) => config, - Err(error) => { - return rollback_compose_up( - &mut state, - &started_services, - &created_networks, - error, - ) - .await; - } - }; + let mut box_config = desired_box_config; let (resolved_volumes, volume_names) = match resolve_service_volumes(&box_config.volumes) { Ok(volumes) => volumes, Err(error) => { @@ -355,6 +420,7 @@ async fn execute_up( let record_hostname = box_config.hostname.clone(); let record_add_hosts = box_config.add_hosts.clone(); let network_mode = box_config.network.clone(); + let record_isolation = box_config.isolation; let network_name = match &network_mode { a3s_box_core::NetworkMode::Bridge { network } => Some(network.clone()), _ => None, @@ -366,13 +432,30 @@ async fn execute_up( let mut vm = VmManager::new(box_config, emitter); let box_id = vm.box_id().to_string(); let box_dir = home.join("boxes").join(&box_id); + let initial_exec_socket_path = box_dir.join("sockets").join("exec.sock"); // Create box directory structure if let Err(error) = std::fs::create_dir_all(box_dir.join("sockets")) { + cleanup_partial_service_box( + &box_id, + &box_dir, + &initial_exec_socket_path, + network_name.as_deref(), + &volume_names, + &[], + ); return rollback_compose_up(&mut state, &started_services, &created_networks, error) .await; } if let Err(error) = std::fs::create_dir_all(box_dir.join("logs")) { + cleanup_partial_service_box( + &box_id, + &box_dir, + &initial_exec_socket_path, + network_name.as_deref(), + &volume_names, + &[], + ); return rollback_compose_up(&mut state, &started_services, &created_networks, error) .await; } @@ -413,6 +496,14 @@ async fn execute_up( ) { Ok(endpoint) => endpoint, Err(error) => { + cleanup_partial_service_box( + &box_id, + &box_dir, + &initial_exec_socket_path, + network_name.as_deref(), + &volume_names, + &[], + ); return rollback_compose_up( &mut state, &started_services, @@ -429,17 +520,14 @@ async fn execute_up( } if let Err(e) = vm.boot().await { - crate::cleanup::cleanup_box_resources(&box_id, &volume_names, network_name.as_deref()); - crate::cleanup::cleanup_external_socket_dir( + cleanup_partial_service_box( + &box_id, &box_dir, - &box_dir.join("sockets/exec.sock"), + &initial_exec_socket_path, + network_name.as_deref(), + &volume_names, + vm.anonymous_volumes(), ); - // Release the overlay mount before deleting the box dir, else - // remove_dir_all recurses into the live mount ("Stale file handle") - // and leaks it — the same class as the rm/restart leak fixed in #33, - // which cleanup_box_resources (volumes + network only) does not cover. - a3s_box_runtime::rootfs::unmount_box_overlay(&box_dir.join("merged")); - let _ = std::fs::remove_dir_all(&box_dir); return rollback_compose_up( &mut state, &started_services, @@ -467,6 +555,7 @@ async fn execute_up( let mut labels = svc.map(|s| s.labels.to_map()).unwrap_or_default(); labels.insert(LABEL_PROJECT.to_string(), project_name.to_string()); labels.insert(LABEL_SERVICE.to_string(), svc_name.to_string()); + labels.insert(LABEL_CONFIG_HASH.to_string(), config_hash); // Get service config for extra fields let port_map: Vec = svc.map(|s| s.ports.clone()).unwrap_or_default(); @@ -503,6 +592,8 @@ async fn execute_up( short_id: BoxRecord::make_short_id(&box_id), name: box_name, image, + isolation: record_isolation, + managed_execution: None, status: "running".to_string(), pid, pid_start_time: pid.and_then(crate::process::pid_start_time), @@ -512,6 +603,7 @@ async fn execute_up( .and_then(|m| crate::output::parse_memory(m).ok()) .unwrap_or(512), volumes: resolved_volumes, + virtiofs_cache: None, env: record_env, cmd: svc .and_then(|s| s.command.as_ref()) @@ -583,7 +675,7 @@ async fn execute_up( // Atomic append under the state lock (load-fresh + push + save): a plain // state.add() saved a snapshot loaded before concurrent health/sibling // writes, clobbering them (the lost-registration → orphan-VM race). - if let Err(error) = StateFile::add_record(record) { + if let Err(error) = StateFile::add_record(record.clone()) { let rollback_services = rollback_with_current(&started_services, service_box); return rollback_compose_up(&mut state, &rollback_services, &created_networks, error) .await; @@ -595,13 +687,19 @@ async fn execute_up( } started_services.push(service_box); - // Spawn health checker if configured - if let Some(ref hc) = health_check { - crate::health::spawn_health_checker( - box_id.clone(), - exec_socket_path.clone(), - hc.clone(), - ); + // Compose returns after startup, so health ownership must outlive this + // CLI process. A generation-fenced worker updates the same state record. + if health_check.is_some() { + if let Err(error) = crate::health::spawn_detached_health_checker(&record) { + let rollback_services = started_services.clone(); + return rollback_compose_up( + &mut state, + &rollback_services, + &created_networks, + error, + ) + .await; + } } // Ensure the log dir exists; the shim runs the log processor (default @@ -611,10 +709,78 @@ async fn execute_up( println!(" ✓"); } - println!("All {} services started.", project.service_order.len()); + println!("All {} services converged.", project.service_order.len()); + + if !up_args.detach { + println!("Attaching to project logs. Press Ctrl-C to stop services."); + let logs = execute_logs( + project_name, + &project.config, + ComposeLogsArgs { + follow: true, + tail: 100, + services: project.service_order.clone(), + }, + ); + tokio::select! { + result = logs => result?, + signal = tokio::signal::ctrl_c() => { + signal.map_err(|error| format!("Failed to listen for Ctrl-C: {error}"))?; + operations::execute_stop( + project_name, + &project.config, + ComposeStopArgs { + timeout: None, + services: project.service_order.clone(), + }, + ) + .await?; + } + } + } + Ok(()) } +fn service_config_hash( + service: &ServiceConfig, + config: &a3s_box_core::BoxConfig, +) -> Result { + let mut normalized = config.clone(); + normalized.extra_env.sort(); + let value = serde_json::json!({ + "service": service, + "runtime": normalized, + }); + let encoded = serde_json::to_vec(&value)?; + Ok(hex::encode(Sha256::digest(encoded))) +} + +fn find_existing_service( + project_name: &str, + service_name: &str, +) -> Result, Box> { + let state = StateFile::load_default()?; + let matching = state + .find_by_label(LABEL_PROJECT, project_name) + .into_iter() + .filter(|record| record.labels.get(LABEL_SERVICE).map(String::as_str) == Some(service_name)) + .collect::>(); + if matching.len() > 1 { + return Err(format!( + "service '{service_name}' has {} existing boxes; scaling is not yet enabled for this project", + matching.len() + ) + .into()); + } + Ok(matching.first().map(|record| { + ( + ServiceBox::from_record(record), + record.labels.get(LABEL_CONFIG_HASH).cloned(), + ) + })) +} + fn resolve_service_volumes( volume_specs: &[String], ) -> Result<(Vec, Vec), Box> { @@ -737,628 +903,3 @@ async fn wait_for_completed( tokio::time::sleep(std::time::Duration::from_secs(1)).await; } } - -// ============================================================================ -// compose down -// ============================================================================ - -/// Snapshot of a compose service box for the `down` operation. -#[derive(Clone)] -struct ServiceBox { - box_id: String, - svc_name: String, - pid: Option, - status: String, - box_dir: PathBuf, - exec_socket_path: PathBuf, - network_name: Option, - volume_names: Vec, - anonymous_volumes: Vec, - stop_signal: Option, - stop_timeout: Option, -} - -impl ServiceBox { - fn from_record(record: &BoxRecord) -> Self { - Self { - box_id: record.id.clone(), - svc_name: record - .labels - .get(LABEL_SERVICE) - .cloned() - .unwrap_or_default(), - pid: record.pid, - status: record.status.clone(), - box_dir: record.box_dir.clone(), - exec_socket_path: record.exec_socket_path.clone(), - network_name: crate::cleanup::record_network_name(record).map(str::to_string), - volume_names: record.volume_names.clone(), - anonymous_volumes: record.anonymous_volumes.clone(), - stop_signal: record.stop_signal.clone(), - stop_timeout: record.stop_timeout, - } - } - - fn is_active(&self) -> bool { - status::is_active_status(&self.status) - } -} - -fn cleanup_service_box(svc: &ServiceBox) { - crate::cleanup::cleanup_box_resources( - &svc.box_id, - &svc.volume_names, - svc.network_name.as_deref(), - ); - crate::cleanup::cleanup_anonymous_volumes(&svc.anonymous_volumes); - // Release the overlay mount before deleting the box dir, else remove_dir_all - // recurses into the live mount ("Stale file handle") and leaks it (#33). - // cleanup_box_resources above only detaches volumes + network, not the mount. - a3s_box_runtime::rootfs::unmount_box_overlay(&svc.box_dir.join("merged")); - let _ = std::fs::remove_dir_all(&svc.box_dir); - crate::cleanup::cleanup_external_socket_dir(&svc.box_dir, &svc.exec_socket_path); -} - -fn rollback_with_current(started_services: &[ServiceBox], current: ServiceBox) -> Vec { - let mut rollback_services = started_services.to_vec(); - rollback_services.push(current); - rollback_services -} - -async fn rollback_compose_up( - state: &mut StateFile, - started_services: &[ServiceBox], - created_networks: &[String], - error: impl Into>, -) -> Result> { - rollback_started_services(state, started_services).await; - cleanup_created_networks(created_networks); - Err(error.into()) -} - -async fn rollback_started_services(state: &mut StateFile, started_services: &[ServiceBox]) { - if started_services.is_empty() { - return; - } - - eprintln!( - " [!] Rolling back {} started service(s)...", - started_services.len() - ); - - for svc in started_services.iter().rev() { - stop_service_process(svc).await; - - cleanup_service_box(svc); - let _ = state.remove(&svc.box_id); - } -} - -async fn stop_service_process(svc: &ServiceBox) { - if !svc.is_active() { - return; - } - - let Some(pid) = svc.pid else { - eprintln!( - " Warning: service {} is {} but has no recorded PID; removing stale service state.", - svc.svc_name, svc.status - ); - return; - }; - - if svc.status == "paused" { - #[cfg(unix)] - if let Err(error) = crate::process::send_signal(pid, libc::SIGCONT) { - eprintln!( - " Warning: failed to resume paused service {} before stopping: {}", - svc.svc_name, error - ); - } - } - - let stop_signal = svc - .stop_signal - .as_deref() - .map(a3s_box_core::vmm::parse_signal_name) - .unwrap_or(libc::SIGTERM); - let stop_timeout = svc.stop_timeout.unwrap_or(10); - let exec_socket = if svc.exec_socket_path.as_os_str().is_empty() { - svc.box_dir.join("sockets").join("exec.sock") - } else { - svc.exec_socket_path.clone() - }; - crate::process::graceful_stop_via_guest(pid, &exec_socket, stop_signal, stop_timeout).await; -} - -fn cleanup_created_networks(created_networks: &[String]) { - if created_networks.is_empty() { - return; - } - - let Ok(net_store) = NetworkStore::default_path() else { - return; - }; - - for net_name in created_networks.iter().rev() { - if let Ok(Some(mut net_config)) = net_store.get(net_name) { - let endpoint_ids: Vec<_> = net_config.endpoints.keys().cloned().collect(); - for endpoint_id in endpoint_ids { - let _ = net_config.disconnect(&endpoint_id); - } - let _ = net_store.update(&net_config); - } - - if let Err(error) = net_store.remove(net_name) { - eprintln!( - " Warning: failed to roll back network {}: {}", - net_name, error - ); - } - } -} - -/// `compose down` — Stop and remove all services, networks, and optionally volumes. -async fn execute_down( - project_name: &str, - down_args: ComposeDownArgs, -) -> Result<(), Box> { - let mut state = StateFile::load_default()?; - - // Find all boxes belonging to this project - let project_boxes: Vec = state - .find_by_label(LABEL_PROJECT, project_name) - .iter() - .map(|r| ServiceBox::from_record(r)) - .collect(); - - if project_boxes.is_empty() { - println!("No services found for project '{}'.", project_name); - return Ok(()); - } - - println!( - "Stopping project '{}' ({} services)...", - project_name, - project_boxes.len() - ); - - // Stop in reverse order (last started = first stopped) - for svc in project_boxes.iter().rev() { - print!(" [-] Stopping {}...", svc.svc_name); - - stop_service_process(svc).await; - - cleanup_service_box(svc); - state.remove(&svc.box_id)?; - - println!(" ✓"); - } - - // Clean up networks - if let Ok(net_store) = NetworkStore::default_path() { - let prefix = format!("{}_", project_name); - if let Ok(all_nets) = net_store.list() { - for net in all_nets { - if net.name.starts_with(&prefix) { - // Disconnect any remaining endpoints first - if !net.endpoints.is_empty() { - let mut net_config = net.clone(); - let ids: Vec<_> = net_config.endpoints.keys().cloned().collect(); - for id in ids { - net_config.disconnect(&id).ok(); - } - let _ = net_store.update(&net_config); - } - if let Err(e) = net_store.remove(&net.name) { - eprintln!(" Warning: failed to remove network {}: {}", net.name, e); - } else { - println!(" [-] Network {} removed", net.name); - } - } - } - } - } - - // Optionally remove named volumes - if down_args.volumes { - let vol_store = a3s_box_runtime::volume::VolumeStore::default_path()?; - let mut removed = 0u32; - for svc in &project_boxes { - for vol_name in &svc.volume_names { - match vol_store.remove(vol_name, true) { - Ok(_) => { - println!(" [-] Volume {} removed", vol_name); - removed += 1; - } - Err(e) => { - eprintln!(" Warning: failed to remove volume {}: {}", vol_name, e); - } - } - } - } - if removed > 0 { - println!(" Removed {} volume(s).", removed); - } - } - - println!("Project '{}' stopped.", project_name); - Ok(()) -} - -// ============================================================================ -// compose ps -// ============================================================================ - -/// `compose ps` — List services and their actual status. -async fn execute_ps(project_name: &str) -> Result<(), Box> { - let state = StateFile::load_default()?; - let boxes = state.find_by_label(LABEL_PROJECT, project_name); - - if boxes.is_empty() { - println!("No services found for project '{}'.", project_name); - return Ok(()); - } - - println!( - "{:<20} {:<30} {:<12} {:<12} {:<10}", - "SERVICE", "IMAGE", "STATUS", "HEALTH", "PID" - ); - println!("{}", "-".repeat(84)); - - for record in &boxes { - let svc_name = record - .labels - .get(LABEL_SERVICE) - .map(|s| s.as_str()) - .unwrap_or("?"); - let pid_str = record - .pid - .map(|p| p.to_string()) - .unwrap_or_else(|| "-".to_string()); - println!( - "{:<20} {:<30} {:<12} {:<12} {:<10}", - svc_name, record.image, record.status, record.health_status, pid_str - ); - } - - Ok(()) -} - -// ============================================================================ -// compose config -// ============================================================================ - -/// `compose config` — Validate and display the parsed compose configuration. -fn execute_config( - project_name: &str, - config: ComposeConfig, -) -> Result<(), Box> { - validate_compose_restart_policies(&config) - .map_err(|e| -> Box { e.into() })?; - let project = ComposeProject::new(project_name, config)?; - - println!("Project: {}", project_name); - println!("Services: {}", project.config.services.len()); - println!("Networks: {}", project.required_networks().len()); - println!("Volumes: {}", project.config.volumes.len()); - println!("\nBoot order: {}", project.service_order.join(" → ")); - - for svc_name in &project.service_order { - if let Some(svc) = project.config.services.get(svc_name) { - println!("\n[{}]", svc_name); - if let Some(ref img) = svc.image { - println!(" image: {}", img); - } - if !svc.ports.is_empty() { - println!(" ports: {}", svc.ports.join(", ")); - } - if !svc.volumes.is_empty() { - println!(" volumes: {}", svc.volumes.join(", ")); - } - let deps = svc.depends_on.services(); - if !deps.is_empty() { - println!(" depends_on: {}", deps.join(", ")); - } - let env = svc.environment.to_pairs(); - if !env.is_empty() { - println!(" environment:"); - for (k, v) in &env { - println!(" {}={}", k, v); - } - } - } - } - - println!("\nConfiguration is valid."); - Ok(()) -} - -// ============================================================================ -// compose logs -// ============================================================================ - -/// `compose logs` — View logs from all (or one) service in the project. -async fn execute_logs( - project_name: &str, - logs_args: ComposeLogsArgs, -) -> Result<(), Box> { - let state = StateFile::load_default()?; - let boxes = state.find_by_label(LABEL_PROJECT, project_name); - - if boxes.is_empty() { - println!("No services found for project '{}'.", project_name); - return Ok(()); - } - - // Filter to specific service if requested - let targets: Vec<_> = if let Some(ref svc) = logs_args.service { - boxes - .iter() - .filter(|r| { - r.labels - .get(LABEL_SERVICE) - .map(|s| s == svc) - .unwrap_or(false) - }) - .collect() - } else { - boxes.iter().collect() - }; - - if targets.is_empty() { - if let Some(ref svc) = logs_args.service { - return Err( - format!("Service '{}' not found in project '{}'.", svc, project_name).into(), - ); - } - } - - for record in &targets { - let svc_name = record - .labels - .get(LABEL_SERVICE) - .map(|s| s.as_str()) - .unwrap_or("?"); - - let log_path = record.console_log.clone(); - if !log_path.exists() { - println!("[{}] (no logs)", svc_name); - continue; - } - - let content = std::fs::read_to_string(&log_path) - .map_err(|e| format!("Failed to read logs for {}: {}", svc_name, e))?; - - let lines: Vec<&str> = content.lines().collect(); - let start = lines.len().saturating_sub(logs_args.tail); - let prefix = if targets.len() > 1 { - format!("{} | ", svc_name) - } else { - String::new() - }; - - for line in &lines[start..] { - println!("{}{}", prefix, line); - } - } - - if logs_args.follow { - println!("(follow mode: use Ctrl-C to stop)"); - // In follow mode, tail all log files concurrently - // For simplicity, we poll every second - let mut last_sizes: HashMap = HashMap::new(); - for record in &targets { - let size = std::fs::metadata(&record.console_log) - .map(|m| m.len()) - .unwrap_or(0); - last_sizes.insert(record.id.clone(), size); - } - - loop { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - for record in &targets { - let log_path = &record.console_log; - let current_size = std::fs::metadata(log_path).map(|m| m.len()).unwrap_or(0); - let last_size = last_sizes.get(&record.id).copied().unwrap_or(0); - - if current_size > last_size { - let svc_name = record - .labels - .get(LABEL_SERVICE) - .map(|s| s.as_str()) - .unwrap_or("?"); - let prefix = if targets.len() > 1 { - format!("{} | ", svc_name) - } else { - String::new() - }; - - if let Ok(file) = std::fs::File::open(log_path) { - use std::io::{Read, Seek, SeekFrom}; - let mut file = file; - if file.seek(SeekFrom::Start(last_size)).is_ok() { - let mut buf = String::new(); - if file.read_to_string(&mut buf).is_ok() { - for line in buf.lines() { - println!("{}{}", prefix, line); - } - } - } - } - - last_sizes.insert(record.id.clone(), current_size); - } - } - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_load_compose_file_not_found() { - let result = load_compose_file(Some(std::path::Path::new("/nonexistent/compose.yaml"))); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not found")); - } - - #[test] - fn test_compose_files_constant() { - assert_eq!(COMPOSE_FILES.len(), 4); - assert!(COMPOSE_FILES.contains(&"compose.yaml")); - assert!(COMPOSE_FILES.contains(&"docker-compose.yml")); - } - - #[test] - fn test_label_constants() { - assert_eq!(LABEL_PROJECT, "com.a3s.compose.project"); - assert_eq!(LABEL_SERVICE, "com.a3s.compose.service"); - } - - #[test] - fn test_service_restart_policy_normalizes_on_failure_limit() { - let service = ServiceConfig { - restart: Some("on-failure:3".to_string()), - ..Default::default() - }; - - let (policy, max_count) = service_restart_policy("web", Some(&service)).unwrap(); - - assert_eq!(policy, "on-failure"); - assert_eq!(max_count, 3); - } - - #[test] - fn test_validate_compose_restart_policies_rejects_invalid_service_policy() { - let mut services = HashMap::new(); - services.insert( - "web".to_string(), - ServiceConfig { - image: Some("docker.io/library/alpine:latest".to_string()), - restart: Some("never".to_string()), - ..Default::default() - }, - ); - let config = ComposeConfig { - version: None, - services, - volumes: HashMap::new(), - networks: HashMap::new(), - }; - - let error = validate_compose_restart_policies(&config).unwrap_err(); - - assert!(error.contains("Service 'web' has invalid restart policy")); - assert!(error.contains("Invalid restart policy")); - } - - #[test] - fn test_service_box_from_record_captures_cleanup_fields() { - let mut record = crate::test_helpers::fixtures::make_record( - "compose-id", - "project-web", - "running", - Some(123), - ); - record - .labels - .insert(LABEL_SERVICE.to_string(), "web".to_string()); - record.network_name = Some("project_default".to_string()); - record.volume_names = vec!["data".to_string()]; - record.anonymous_volumes = vec!["anon".to_string()]; - record.stop_signal = Some("SIGINT".to_string()); - record.stop_timeout = Some(3); - - let service = ServiceBox::from_record(&record); - - assert_eq!(service.box_id, "compose-id"); - assert_eq!(service.svc_name, "web"); - assert_eq!(service.pid, Some(123)); - assert_eq!(service.network_name.as_deref(), Some("project_default")); - assert_eq!(service.volume_names, vec!["data".to_string()]); - assert_eq!(service.anonymous_volumes, vec!["anon".to_string()]); - assert_eq!(service.stop_signal.as_deref(), Some("SIGINT")); - assert_eq!(service.stop_timeout, Some(3)); - assert!(service.is_active()); - } - - #[test] - fn test_service_box_from_record_uses_network_mode_fallback() { - let mut record = crate::test_helpers::fixtures::make_record( - "compose-id", - "project-web", - "running", - None, - ); - record.network_name = None; - record.network_mode = a3s_box_core::NetworkMode::Bridge { - network: "legacy_default".to_string(), - }; - - let service = ServiceBox::from_record(&record); - - assert_eq!(service.network_name.as_deref(), Some("legacy_default")); - } - - #[test] - fn test_rollback_with_current_appends_current_service() { - let mut first_record = - crate::test_helpers::fixtures::make_record("first-id", "project-db", "running", None); - first_record - .labels - .insert(LABEL_SERVICE.to_string(), "db".to_string()); - let mut current_record = crate::test_helpers::fixtures::make_record( - "current-id", - "project-web", - "running", - None, - ); - current_record - .labels - .insert(LABEL_SERVICE.to_string(), "web".to_string()); - - let first = ServiceBox::from_record(&first_record); - let current = ServiceBox::from_record(¤t_record); - let rollback_services = rollback_with_current(&[first], current); - - assert_eq!(rollback_services.len(), 2); - assert_eq!(rollback_services[0].svc_name, "db"); - assert_eq!(rollback_services[1].svc_name, "web"); - } - - #[test] - fn test_service_box_paused_is_active() { - let mut record = - crate::test_helpers::fixtures::make_record("compose-id", "project-web", "paused", None); - record - .labels - .insert(LABEL_SERVICE.to_string(), "web".to_string()); - - let service = ServiceBox::from_record(&record); - - assert!(service.is_active()); - } - - #[test] - fn test_service_box_stopped_is_not_active() { - let mut record = crate::test_helpers::fixtures::make_record( - "compose-id", - "project-web", - "stopped", - None, - ); - record - .labels - .insert(LABEL_SERVICE.to_string(), "web".to_string()); - - let service = ServiceBox::from_record(&record); - - assert!(!service.is_active()); - } -} diff --git a/src/cli/src/commands/compose/args.rs b/src/cli/src/commands/compose/args.rs new file mode 100644 index 00000000..d70f361d --- /dev/null +++ b/src/cli/src/commands/compose/args.rs @@ -0,0 +1,113 @@ +//! Clap argument models for Compose commands. + +use std::path::PathBuf; + +use clap::{Args, Subcommand}; + +use super::super::common; +use super::operations::{ + ComposeCpArgs, ComposeExecArgs, ComposeKillArgs, ComposeLsArgs, ComposePortArgs, + ComposePullArgs, ComposeRestartArgs, ComposeRmArgs, ComposeStopArgs, ComposeTopArgs, + ComposeWaitArgs, ProjectServicesArgs, +}; + +#[derive(Args)] +pub struct ComposeArgs { + /// Path to compose file (default: compose.acl, then Compose YAML names) + #[arg(short = 'f', long = "file")] + pub file: Option, + + /// Project name (default: directory name) + #[arg(short = 'p', long = "project-name")] + pub project_name: Option, + + #[command(subcommand)] + pub command: ComposeCommand, +} + +#[derive(Subcommand)] +pub enum ComposeCommand { + /// Create and start all services + Up(ComposeUpArgs), + /// Stop and remove all services + Down(ComposeDownArgs), + /// List services and their status + Ps(ProjectServicesArgs), + /// Validate and display the compose configuration + Config, + /// View logs from all services + Logs(ComposeLogsArgs), + /// Start existing service boxes + Start(ProjectServicesArgs), + /// Stop running service boxes without removing them + Stop(ComposeStopArgs), + /// Restart service boxes + Restart(ComposeRestartArgs), + /// Remove stopped service boxes + Rm(ComposeRmArgs), + /// Force-stop service boxes with a signal + Kill(ComposeKillArgs), + /// Pause running service boxes + Pause(ProjectServicesArgs), + /// Resume paused service boxes + Unpause(ProjectServicesArgs), + /// Wait for service boxes to stop + Wait(ComposeWaitArgs), + /// Execute a command in a running service box + Exec(ComposeExecArgs), + /// Display running processes for service boxes + Top(ComposeTopArgs), + /// Print published ports for a service + Port(ComposePortArgs), + /// Copy files between a service box and the host + Cp(ComposeCpArgs), + /// List images declared by services + Images(ProjectServicesArgs), + /// Pull service images + Pull(ComposePullArgs), + /// List Compose projects known to A3S Box + Ls(ComposeLsArgs), + /// List named volumes declared by the project + Volumes, +} + +#[derive(Args)] +pub struct ComposeUpArgs { + /// Run in detached mode (background) + #[arg(short = 'd', long)] + pub detach: bool, + + /// Timeout in seconds to wait for healthy dependencies (default: 120) + #[arg(long, default_value = "120")] + pub timeout: u64, + + /// Use the shared-kernel sandbox backend (omit for MicroVM isolation) + #[arg(long, value_enum)] + pub isolation: Option, + + /// Limit convergence to these services and their dependencies + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposeDownArgs { + /// Remove named volumes declared in the compose file + #[arg(short = 'v', long)] + pub volumes: bool, +} + +#[derive(Args)] +pub struct ComposeLogsArgs { + /// Follow log output + #[arg(short = 'f', long)] + pub follow: bool, + + /// Number of lines to show from the end of the logs + #[arg(long, default_value = "100")] + pub tail: usize, + + /// Limit output to these services + #[arg(value_name = "SERVICE")] + pub services: Vec, +} diff --git a/src/cli/src/commands/compose/lifecycle.rs b/src/cli/src/commands/compose/lifecycle.rs new file mode 100644 index 00000000..22b7af1b --- /dev/null +++ b/src/cli/src/commands/compose/lifecycle.rs @@ -0,0 +1,362 @@ +//! Compose service teardown, rollback, and project removal. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use a3s_box_core::compose::ComposeConfig; +use a3s_box_runtime::NetworkStore; + +use super::{ComposeDownArgs, LABEL_PROJECT, LABEL_SERVICE}; +use crate::state::{BoxRecord, StateFile}; +use crate::status; + +// ============================================================================ +// compose down +// ============================================================================ + +/// Snapshot of a compose service box for the `down` operation. +#[derive(Clone)] +pub(super) struct ServiceBox { + pub(super) box_id: String, + pub(super) svc_name: String, + pub(super) pid: Option, + pub(super) status: String, + pub(super) box_dir: PathBuf, + pub(super) exec_socket_path: PathBuf, + pub(super) network_name: Option, + pub(super) volume_names: Vec, + pub(super) anonymous_volumes: Vec, + pub(super) stop_signal: Option, + pub(super) stop_timeout: Option, +} + +impl ServiceBox { + pub(super) fn from_record(record: &BoxRecord) -> Self { + Self { + box_id: record.id.clone(), + svc_name: record + .labels + .get(LABEL_SERVICE) + .cloned() + .unwrap_or_default(), + pid: record.pid, + status: record.status.clone(), + box_dir: record.box_dir.clone(), + exec_socket_path: record.exec_socket_path.clone(), + network_name: crate::cleanup::record_network_name(record).map(str::to_string), + volume_names: record.volume_names.clone(), + anonymous_volumes: record.anonymous_volumes.clone(), + stop_signal: record.stop_signal.clone(), + stop_timeout: record.stop_timeout, + } + } + + pub(super) fn is_active(&self) -> bool { + status::is_active_status(&self.status) + } +} + +pub(super) fn cleanup_service_box(svc: &ServiceBox) { + cleanup_partial_service_box( + &svc.box_id, + &svc.box_dir, + &svc.exec_socket_path, + svc.network_name.as_deref(), + &svc.volume_names, + &svc.anonymous_volumes, + ); +} + +pub(super) fn cleanup_partial_service_box( + box_id: &str, + box_dir: &std::path::Path, + exec_socket_path: &std::path::Path, + network_name: Option<&str>, + volume_names: &[String], + anonymous_volumes: &[String], +) { + crate::cleanup::cleanup_box_resources(box_id, volume_names, network_name); + crate::cleanup::cleanup_anonymous_volumes(anonymous_volumes); + // Release every rootfs provider before deleting the box dir. Linux uses an + // overlay mount, while macOS mounts a case-sensitive APFS image at rootfs. + // cleanup_box_resources above only detaches volumes and networking. + a3s_box_runtime::rootfs::unmount_box_overlay(&box_dir.join("merged")); + a3s_box_runtime::rootfs::unmount_box_rootfs(&box_dir.join("rootfs")); + let _ = std::fs::remove_dir_all(box_dir); + crate::cleanup::cleanup_external_socket_dir(box_dir, exec_socket_path); +} + +pub(super) fn rollback_with_current( + started_services: &[ServiceBox], + current: ServiceBox, +) -> Vec { + let mut rollback_services = started_services.to_vec(); + rollback_services.push(current); + rollback_services +} + +pub(super) async fn rollback_compose_up( + state: &mut StateFile, + started_services: &[ServiceBox], + created_networks: &[String], + error: impl Into>, +) -> Result> { + rollback_started_services(state, started_services).await; + cleanup_created_networks(created_networks); + Err(error.into()) +} + +async fn rollback_started_services(state: &mut StateFile, started_services: &[ServiceBox]) { + if started_services.is_empty() { + return; + } + + eprintln!( + " [!] Rolling back {} started service(s)...", + started_services.len() + ); + + for svc in started_services.iter().rev() { + stop_service_process(svc).await; + + match StateFile::remove_record(&svc.box_id) { + Ok(_) => state.forget(&svc.box_id), + Err(error) => eprintln!( + " Warning: failed to remove rolled-back service {} from state: {}", + svc.svc_name, error + ), + } + cleanup_service_box(svc); + } +} + +pub(super) async fn stop_service_process(svc: &ServiceBox) { + if !svc.is_active() { + return; + } + + let Some(pid) = svc.pid else { + eprintln!( + " Warning: service {} is {} but has no recorded PID; removing stale service state.", + svc.svc_name, svc.status + ); + return; + }; + + if svc.status == "paused" { + #[cfg(unix)] + if let Err(error) = crate::process::send_signal(pid, libc::SIGCONT) { + eprintln!( + " Warning: failed to resume paused service {} before stopping: {}", + svc.svc_name, error + ); + } + } + + let stop_signal = svc + .stop_signal + .as_deref() + .map(a3s_box_core::vmm::parse_signal_name) + .unwrap_or(libc::SIGTERM); + let stop_timeout = svc.stop_timeout.unwrap_or(10); + let exec_socket = if svc.exec_socket_path.as_os_str().is_empty() { + svc.box_dir.join("sockets").join("exec.sock") + } else { + svc.exec_socket_path.clone() + }; + crate::process::graceful_stop_via_guest(pid, &exec_socket, stop_signal, stop_timeout).await; +} + +fn cleanup_created_networks(created_networks: &[String]) { + if created_networks.is_empty() { + return; + } + + let Ok(net_store) = NetworkStore::default_path() else { + return; + }; + + for net_name in created_networks.iter().rev() { + if let Ok(Some(mut net_config)) = net_store.get(net_name) { + let endpoint_ids: Vec<_> = net_config.endpoints.keys().cloned().collect(); + for endpoint_id in endpoint_ids { + let _ = net_config.disconnect(&endpoint_id); + } + let _ = net_store.update(&net_config); + } + + if let Err(error) = net_store.remove(net_name) { + eprintln!( + " Warning: failed to roll back network {}: {}", + net_name, error + ); + } + } +} + +/// `compose down` — Stop and remove all services, networks, and optionally volumes. +pub(super) async fn execute_down( + project_name: &str, + config: &ComposeConfig, + down_args: ComposeDownArgs, +) -> Result<(), Box> { + let mut state = StateFile::load_default()?; + + // Find all boxes belonging to this project + let project_boxes: Vec = state + .find_by_label(LABEL_PROJECT, project_name) + .iter() + .map(|r| ServiceBox::from_record(r)) + .collect(); + let network_names = project_network_names(project_name, config, &project_boxes); + let volume_names = project_volume_names(config, &project_boxes); + + if project_boxes.is_empty() { + println!("No services found for project '{}'.", project_name); + } else { + println!( + "Stopping project '{}' ({} services)...", + project_name, + project_boxes.len() + ); + + // Stop in reverse order (last started = first stopped) + for svc in project_boxes.iter().rev() { + print!(" [-] Stopping {}...", svc.svc_name); + + stop_service_process(svc).await; + StateFile::remove_record(&svc.box_id)?; + state.forget(&svc.box_id); + cleanup_service_box(svc); + + println!(" ✓"); + } + } + + // Clean up networks + if let Ok(net_store) = NetworkStore::default_path() { + for network_name in network_names { + if let Ok(Some(mut network)) = net_store.get(&network_name) { + let ids = network.endpoints.keys().cloned().collect::>(); + for id in ids { + network.disconnect(&id).ok(); + } + let _ = net_store.update(&network); + if let Err(error) = net_store.remove(&network_name) { + eprintln!( + " Warning: failed to remove network {}: {}", + network_name, error + ); + } else { + println!(" [-] Network {} removed", network_name); + } + } + } + } + + // Optionally remove named volumes + if down_args.volumes { + let vol_store = a3s_box_runtime::volume::VolumeStore::default_path()?; + let mut removed = 0u32; + for volume_name in volume_names { + match vol_store.remove(&volume_name, true) { + Ok(_) => { + println!(" [-] Volume {} removed", volume_name); + removed += 1; + } + Err(error) => { + eprintln!( + " Warning: failed to remove volume {}: {}", + volume_name, error + ); + } + } + } + if removed > 0 { + println!(" Removed {} volume(s).", removed); + } + } + + println!("Project '{}' stopped.", project_name); + Ok(()) +} + +fn project_network_names( + project_name: &str, + config: &ComposeConfig, + project_boxes: &[ServiceBox], +) -> BTreeSet { + let mut names = BTreeSet::from([format!("{project_name}_default")]); + names.extend( + config + .networks + .keys() + .map(|network| format!("{project_name}_{network}")), + ); + names.extend(config.services.values().flat_map(|service| { + service + .networks + .names() + .into_iter() + .map(|network| format!("{project_name}_{network}")) + })); + names.extend( + project_boxes + .iter() + .filter_map(|service| service.network_name.clone()), + ); + names +} + +fn project_volume_names(config: &ComposeConfig, project_boxes: &[ServiceBox]) -> BTreeSet { + let mut names = config.volumes.keys().cloned().collect::>(); + names.extend( + project_boxes + .iter() + .flat_map(|service| service.volume_names.iter().cloned()), + ); + names +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service_box_with_resources(network: &str, volumes: &[&str]) -> ServiceBox { + let mut record = crate::test_helpers::fixtures::make_record( + "compose-id", + "project-api", + "stopped", + None, + ); + record.network_name = Some(network.to_string()); + record.volume_names = volumes.iter().map(|name| (*name).to_string()).collect(); + ServiceBox::from_record(&record) + } + + #[test] + fn teardown_names_are_exact_and_deduplicated() { + let config = ComposeConfig::from_yaml_str( + "services:\n api:\n image: api\n networks: [backend]\nvolumes:\n data:\nnetworks:\n backend:\n unused:\n", + ) + .unwrap(); + let boxes = vec![service_box_with_resources( + "project_legacy", + &["data", "legacy", "data"], + )]; + + assert_eq!( + project_network_names("project", &config, &boxes), + BTreeSet::from([ + "project_backend".to_string(), + "project_default".to_string(), + "project_legacy".to_string(), + "project_unused".to_string(), + ]) + ); + assert_eq!( + project_volume_names(&config, &boxes), + BTreeSet::from(["data".to_string(), "legacy".to_string()]) + ); + } +} diff --git a/src/cli/src/commands/compose/operations.rs b/src/cli/src/commands/compose/operations.rs new file mode 100644 index 00000000..e8bec0c8 --- /dev/null +++ b/src/cli/src/commands/compose/operations.rs @@ -0,0 +1,750 @@ +//! Project-scoped Compose operations built from the canonical box commands. + +use std::collections::{BTreeMap, BTreeSet}; + +use a3s_box_core::compose::ComposeConfig; +use clap::Args; + +use super::{LABEL_PROJECT, LABEL_SERVICE}; +use crate::state::{BoxRecord, StateFile}; + +pub(super) fn select_up_config( + mut config: ComposeConfig, + requested: &[String], +) -> Result> { + if requested.is_empty() { + config.service_order()?; + return Ok(config); + } + + let mut selected = BTreeSet::new(); + for service in requested { + collect_service_dependencies(&config, service, &mut selected)?; + } + config.services.retain(|name, _| selected.contains(name)); + config.service_order()?; + Ok(config) +} + +fn collect_service_dependencies( + config: &ComposeConfig, + service: &str, + selected: &mut BTreeSet, +) -> Result<(), Box> { + let definition = config + .services + .get(service) + .ok_or_else(|| format!("service '{service}' is not defined in the Compose project"))?; + if !selected.insert(service.to_string()) { + return Ok(()); + } + for dependency in definition.depends_on.services() { + collect_service_dependencies(config, &dependency, selected)?; + } + Ok(()) +} + +#[derive(Args)] +pub struct ProjectServicesArgs { + /// Limit the operation to these services (default: every project service) + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposeStopArgs { + /// Seconds to wait before force-killing + #[arg(short = 't', long)] + pub timeout: Option, + + /// Limit the operation to these services + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposeRestartArgs { + /// Seconds to wait before force-killing + #[arg(short = 't', long, default_value_t = 10)] + pub timeout: u64, + + /// Limit the operation to these services + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposeRmArgs { + /// Stop active services before removing them + #[arg(short = 's', long)] + pub stop: bool, + + /// Do not ask for confirmation (A3S Box is non-interactive by default) + #[arg(short = 'f', long)] + pub force: bool, + + /// Limit the operation to these services + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposeKillArgs { + /// Signal to send + #[arg(short = 's', long, default_value = "KILL")] + pub signal: String, + + /// Limit the operation to these services + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposeWaitArgs { + /// Seconds between keepalive messages (0 disables them) + #[arg(long, default_value_t = 60)] + pub heartbeat_interval: u64, + + /// Disable keepalive messages + #[arg(long)] + pub no_heartbeat: bool, + + /// Limit the operation to these services + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposeExecArgs { + /// Service name + pub service: String, + + /// Timeout in seconds + #[arg(long, default_value_t = 5)] + pub timeout: u64, + + /// Set an environment variable (KEY=VALUE) + #[arg(short, long = "env")] + pub envs: Vec, + + /// Working directory inside the service box + #[arg(short, long)] + pub workdir: Option, + + /// Keep standard input open + #[arg(short = 'i', long = "interactive")] + pub interactive: bool, + + /// Allocate a pseudo-terminal + #[arg(short = 't', long = "tty")] + pub tty: bool, + + /// Run as a specific user + #[arg(short = 'u', long)] + pub user: Option, + + /// Command and arguments + #[arg(last = true, required = true)] + pub command: Vec, +} + +#[derive(Args)] +pub struct ComposeTopArgs { + /// Limit output to these services + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposePortArgs { + /// Service name + pub service: String, + + /// Private port and optional protocol, for example 80 or 80/tcp + pub private_port: Option, +} + +#[derive(Args)] +pub struct ComposeCpArgs { + /// Source path (HOST_PATH or SERVICE:CONTAINER_PATH) + pub src: String, + + /// Destination path (HOST_PATH or SERVICE:CONTAINER_PATH) + pub dst: String, +} + +#[derive(Args)] +pub struct ComposePullArgs { + /// Suppress progress output + #[arg(short, long)] + pub quiet: bool, + + /// Target platform, for example linux/amd64 + #[arg(long)] + pub platform: Option, + + /// Limit the operation to these services + #[arg(value_name = "SERVICE")] + pub services: Vec, +} + +#[derive(Args)] +pub struct ComposeLsArgs { + /// Print project names only + #[arg(short, long)] + pub quiet: bool, +} + +#[derive(Clone)] +struct ProjectBox { + id: String, + name: String, + service: String, + status: String, + record: BoxRecord, +} + +pub async fn execute_start( + project_name: &str, + config: &ComposeConfig, + args: ProjectServicesArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, false)?; + let queries = matching_ids(&boxes, |status| { + matches!(status, "created" | "stopped" | "dead") + }); + if queries.is_empty() { + println!("All selected services are already active."); + return Ok(()); + } + super::super::start::execute(super::super::start::StartArgs { boxes: queries }).await +} + +pub async fn execute_stop( + project_name: &str, + config: &ComposeConfig, + args: ComposeStopArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, true)?; + let queries = matching_ids(&boxes, |status| matches!(status, "running" | "paused")); + if queries.is_empty() { + println!("All selected services are already stopped."); + return Ok(()); + } + super::super::stop::execute(super::super::stop::StopArgs { + boxes: queries, + timeout: args.timeout, + }) + .await +} + +pub async fn execute_restart( + project_name: &str, + config: &ComposeConfig, + args: ComposeRestartArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, false)?; + super::super::restart::execute(super::super::restart::RestartArgs { + boxes: ids(&boxes), + timeout: args.timeout, + }) + .await +} + +pub async fn execute_rm( + project_name: &str, + config: &ComposeConfig, + args: ComposeRmArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, true)?; + let active = matching_ids(&boxes, |status| matches!(status, "running" | "paused")); + if !active.is_empty() { + if !args.stop { + return Err( + "selected services are active; pass --stop or run `compose stop` first".into(), + ); + } + super::super::stop::execute(super::super::stop::StopArgs { + boxes: active, + timeout: None, + }) + .await?; + } + let _confirmation_is_implicit = args.force; + super::super::rm::execute(super::super::rm::RmArgs { + boxes: ids(&boxes), + force: false, + }) + .await +} + +pub async fn execute_kill( + project_name: &str, + config: &ComposeConfig, + args: ComposeKillArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, true)?; + let queries = matching_ids(&boxes, |status| matches!(status, "running" | "paused")); + if queries.is_empty() { + println!("No selected services are active."); + return Ok(()); + } + super::super::kill::execute(super::super::kill::KillArgs { + boxes: queries, + signal: args.signal, + }) + .await +} + +pub async fn execute_pause( + project_name: &str, + config: &ComposeConfig, + args: ProjectServicesArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, false)?; + let queries = matching_ids(&boxes, |status| status == "running"); + if queries.is_empty() { + println!("No selected services are running."); + return Ok(()); + } + super::super::pause::execute(super::super::pause::PauseArgs { boxes: queries }).await +} + +pub async fn execute_unpause( + project_name: &str, + config: &ComposeConfig, + args: ProjectServicesArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, false)?; + let queries = matching_ids(&boxes, |status| status == "paused"); + if queries.is_empty() { + println!("No selected services are paused."); + return Ok(()); + } + super::super::unpause::execute(super::super::unpause::UnpauseArgs { boxes: queries }).await +} + +pub async fn execute_wait( + project_name: &str, + config: &ComposeConfig, + args: ComposeWaitArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, false)?; + super::super::wait::execute(super::super::wait::WaitArgs { + boxes: ids(&boxes), + heartbeat_interval: args.heartbeat_interval, + no_heartbeat: args.no_heartbeat, + }) + .await +} + +pub async fn execute_exec( + project_name: &str, + config: &ComposeConfig, + args: ComposeExecArgs, +) -> Result<(), Box> { + let service_box = one_service(project_name, config, &args.service)?; + super::super::exec::execute(super::super::exec::ExecArgs { + r#box: service_box.id, + timeout: args.timeout, + envs: args.envs, + workdir: args.workdir, + interactive: args.interactive, + tty: args.tty, + user: args.user, + cmd: args.command, + }) + .await +} + +pub async fn execute_top( + project_name: &str, + config: &ComposeConfig, + args: ComposeTopArgs, +) -> Result<(), Box> { + let boxes = select_boxes(project_name, config, &args.services, false)?; + for service_box in boxes { + println!("{}", service_box.service); + super::super::top::execute(super::super::top::TopArgs { + r#box: service_box.id, + format: super::super::top::TopFormat::Table, + ps_args: Vec::new(), + }) + .await?; + } + Ok(()) +} + +pub async fn execute_port( + project_name: &str, + config: &ComposeConfig, + args: ComposePortArgs, +) -> Result<(), Box> { + let service_box = one_service(project_name, config, &args.service)?; + let requested = args.private_port.as_deref().map(normalize_private_port); + let mut found = false; + for value in &service_box.record.port_map { + let mapping = a3s_box_core::parse_port_mapping(value)?; + let private = format!("{}/{}", mapping.guest_port, mapping.protocol.as_str()); + if requested.as_deref().is_some_and(|value| value != private) { + continue; + } + found = true; + println!("0.0.0.0:{}", mapping.host_port); + } + if requested.is_some() && !found { + return Err(format!( + "service '{}' does not publish the requested port", + args.service + ) + .into()); + } + Ok(()) +} + +pub async fn execute_cp( + project_name: &str, + config: &ComposeConfig, + args: ComposeCpArgs, +) -> Result<(), Box> { + let src = resolve_copy_endpoint(project_name, config, args.src)?; + let dst = resolve_copy_endpoint(project_name, config, args.dst)?; + super::super::cp::execute(super::super::cp::CpArgs { src, dst }).await +} + +pub fn execute_images( + _project_name: &str, + config: &ComposeConfig, + args: ProjectServicesArgs, +) -> Result<(), Box> { + let services = selected_service_names(config, &args.services)?; + let mut table = crate::output::new_table(&["SERVICE", "IMAGE"]); + for service in services { + let image = config + .services + .get(&service) + .and_then(|value| value.image.as_deref()) + .unwrap_or(""); + table.add_row([service.as_str(), image]); + } + println!("{table}"); + Ok(()) +} + +pub async fn execute_pull( + _project_name: &str, + config: &ComposeConfig, + args: ComposePullArgs, +) -> Result<(), Box> { + let services = selected_service_names(config, &args.services)?; + let mut images = BTreeSet::new(); + for service in services { + if let Some(image) = config + .services + .get(&service) + .and_then(|value| value.image.clone()) + { + images.insert(image); + } + } + if images.is_empty() { + return Err("selected services do not declare an image".into()); + } + for image in images { + super::super::pull::execute(super::super::pull::PullArgs { + image, + quiet: args.quiet, + platform: args.platform.clone(), + verify_key: None, + verify_issuer: None, + verify_identity: None, + }) + .await?; + } + Ok(()) +} + +pub async fn execute_ls(args: ComposeLsArgs) -> Result<(), Box> { + let state = StateFile::load_default()?; + let mut projects: BTreeMap = BTreeMap::new(); + for record in state.records() { + let Some(project) = record.labels.get(LABEL_PROJECT) else { + continue; + }; + let entry = projects.entry(project.clone()).or_default(); + entry.0 += 1; + if matches!(record.status.as_str(), "running" | "paused") { + entry.1 += 1; + } + } + if args.quiet { + for project in projects.keys() { + println!("{project}"); + } + return Ok(()); + } + let mut table = crate::output::new_table(&["NAME", "STATUS", "SERVICES"]); + for (project, (total, active)) in projects { + let status = if active == total { + "running" + } else if active == 0 { + "stopped" + } else { + "partial" + }; + table.add_row([project, status.to_string(), format!("{active}/{total}")]); + } + println!("{table}"); + Ok(()) +} + +pub fn execute_volumes( + _project_name: &str, + config: &ComposeConfig, +) -> Result<(), Box> { + let mut volumes = config.volumes.keys().cloned().collect::>(); + volumes.sort(); + for volume in volumes { + println!("{volume}"); + } + Ok(()) +} + +fn select_boxes( + project_name: &str, + config: &ComposeConfig, + requested: &[String], + reverse: bool, +) -> Result, Box> { + let state = StateFile::load_default()?; + let mut by_service: BTreeMap> = BTreeMap::new(); + for record in state.find_by_label(LABEL_PROJECT, project_name) { + let Some(service) = record.labels.get(LABEL_SERVICE) else { + continue; + }; + by_service + .entry(service.clone()) + .or_default() + .push(ProjectBox { + id: record.id.clone(), + name: record.name.clone(), + service: service.clone(), + status: record.status.clone(), + record: record.clone(), + }); + } + if by_service.is_empty() { + return Err(format!( + "No services found for project '{project_name}'. Run `compose up` first." + ) + .into()); + } + + let existing = by_service.keys().cloned().collect::>(); + let service_order = service_box_order(config, &existing, requested, reverse)?; + + let mut result = Vec::new(); + for service in service_order { + let Some(mut boxes) = by_service.remove(&service) else { + if !requested.is_empty() { + return Err(format!("service '{service}' has not been created").into()); + } + continue; + }; + boxes.sort_by(|left, right| left.name.cmp(&right.name)); + result.extend(boxes); + } + Ok(result) +} + +fn service_box_order( + config: &ComposeConfig, + existing: &BTreeSet, + requested: &[String], + reverse: bool, +) -> Result, Box> { + let mut service_order = if requested.is_empty() { + let mut order = config.service_order()?; + for service in existing { + if !order.contains(service) { + order.push(service.clone()); + } + } + order + } else { + validate_requested_services(config, existing, requested)?; + unique_service_names(requested) + }; + if reverse { + service_order.reverse(); + } + Ok(service_order) +} + +fn validate_requested_services( + config: &ComposeConfig, + existing: &BTreeSet, + requested: &[String], +) -> Result<(), Box> { + for service in requested { + if !config.services.contains_key(service) && !existing.contains(service) { + return Err( + format!("service '{service}' is not defined in the Compose project").into(), + ); + } + } + Ok(()) +} + +pub(super) fn selected_service_names( + config: &ComposeConfig, + requested: &[String], +) -> Result, Box> { + if requested.is_empty() { + return Ok(config.service_order()?); + } + for service in requested { + if !config.services.contains_key(service) { + return Err( + format!("service '{service}' is not defined in the Compose project").into(), + ); + } + } + Ok(unique_service_names(requested)) +} + +fn unique_service_names(requested: &[String]) -> Vec { + let mut seen = BTreeSet::new(); + requested + .iter() + .filter(|service| seen.insert((*service).clone())) + .cloned() + .collect() +} + +fn one_service( + project_name: &str, + config: &ComposeConfig, + service: &str, +) -> Result> { + let boxes = select_boxes(project_name, config, &[service.to_string()], false)?; + if boxes.len() != 1 { + return Err(format!( + "service '{service}' resolves to {} boxes; select one instance explicitly", + boxes.len() + ) + .into()); + } + boxes + .into_iter() + .next() + .ok_or_else(|| format!("service '{service}' did not resolve to a box").into()) +} + +fn resolve_copy_endpoint( + project_name: &str, + config: &ComposeConfig, + endpoint: String, +) -> Result> { + let Some((service, path)) = endpoint.split_once(':') else { + return Ok(endpoint); + }; + if service.len() == 1 { + return Ok(endpoint); + } + if !config.services.contains_key(service) { + return Err(format!("service '{service}' is not defined in the Compose project").into()); + } + let service_box = one_service(project_name, config, service)?; + Ok(format!("{}:{path}", service_box.id)) +} + +fn normalize_private_port(value: &str) -> String { + if value.contains('/') { + value.to_string() + } else { + format!("{value}/tcp") + } +} + +fn ids(boxes: &[ProjectBox]) -> Vec { + boxes.iter().map(|value| value.id.clone()).collect() +} + +fn matching_ids(boxes: &[ProjectBox], predicate: impl Fn(&str) -> bool) -> Vec { + boxes + .iter() + .filter(|value| predicate(&value.status)) + .map(|value| value.id.clone()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_ports_default_to_tcp() { + assert_eq!(normalize_private_port("8080"), "8080/tcp"); + assert_eq!(normalize_private_port("53/udp"), "53/udp"); + } + + #[test] + fn service_selection_rejects_unknown_names() { + let config = ComposeConfig::from_yaml_str("services:\n web:\n image: nginx\n").unwrap(); + let error = selected_service_names(&config, &["db".to_string()]).unwrap_err(); + assert!(error.to_string().contains("service 'db' is not defined")); + } + + #[test] + fn up_selection_includes_transitive_dependencies_only() { + let config = ComposeConfig::from_yaml_str( + "services:\n db:\n image: postgres\n api:\n image: api\n depends_on: [db]\n worker:\n image: worker\n", + ) + .unwrap(); + + let selected = select_up_config(config, &["api".to_string()]).unwrap(); + + assert!(selected.services.contains_key("api")); + assert!(selected.services.contains_key("db")); + assert!(!selected.services.contains_key("worker")); + } + + #[test] + fn explicit_box_selection_does_not_include_unrequested_services() { + let config = ComposeConfig::from_yaml_str( + "services:\n db:\n image: postgres\n api:\n image: api\n depends_on: [db]\n", + ) + .unwrap(); + let existing = BTreeSet::from(["api".to_string(), "db".to_string(), "orphan".to_string()]); + + let selected = service_box_order(&config, &existing, &["api".to_string()], false).unwrap(); + + assert_eq!(selected, ["api"]); + } + + #[test] + fn implicit_box_selection_keeps_config_order_and_existing_orphans() { + let config = ComposeConfig::from_yaml_str( + "services:\n db:\n image: postgres\n api:\n image: api\n depends_on: [db]\n", + ) + .unwrap(); + let existing = BTreeSet::from(["api".to_string(), "db".to_string(), "orphan".to_string()]); + + let selected = service_box_order(&config, &existing, &[], false).unwrap(); + + assert_eq!(selected, ["db", "api", "orphan"]); + } + + #[test] + fn copy_endpoint_rejects_non_project_service_names() { + let config = ComposeConfig::from_yaml_str("services:\n api:\n image: api\n").unwrap(); + + let error = + resolve_copy_endpoint("project", &config, "other:/tmp/data".to_string()).unwrap_err(); + + assert!(error + .to_string() + .contains("service 'other' is not defined in the Compose project")); + } +} diff --git a/src/cli/src/commands/compose/read.rs b/src/cli/src/commands/compose/read.rs new file mode 100644 index 00000000..93582056 --- /dev/null +++ b/src/cli/src/commands/compose/read.rs @@ -0,0 +1,249 @@ +//! Read-only Compose project views and log streaming. + +use std::collections::HashMap; + +use a3s_box_core::compose::ComposeConfig; +use a3s_box_runtime::ComposeProject; + +use super::{ + validate_compose_restart_policies, ComposeLogsArgs, ProjectServicesArgs, LABEL_PROJECT, + LABEL_SERVICE, +}; +use crate::state::StateFile; + +// ============================================================================ +// compose ps +// ============================================================================ + +/// `compose ps` — List services and their actual status. +pub(super) async fn execute_ps( + project_name: &str, + config: &ComposeConfig, + args: ProjectServicesArgs, +) -> Result<(), Box> { + let state = StateFile::load_default()?; + let boxes = state + .find_by_label(LABEL_PROJECT, project_name) + .into_iter() + .filter(|record| { + args.services.is_empty() + || record + .labels + .get(LABEL_SERVICE) + .is_some_and(|service| args.services.contains(service)) + }) + .collect::>(); + + for service in &args.services { + if !config.services.contains_key(service) { + return Err( + format!("Service '{service}' is not defined in project '{project_name}'.").into(), + ); + } + } + + if boxes.is_empty() { + println!("No services found for project '{}'.", project_name); + return Ok(()); + } + + println!( + "{:<20} {:<30} {:<12} {:<12} {:<10}", + "SERVICE", "IMAGE", "STATUS", "HEALTH", "PID" + ); + println!("{}", "-".repeat(84)); + + for record in &boxes { + let svc_name = record + .labels + .get(LABEL_SERVICE) + .map(|s| s.as_str()) + .unwrap_or("?"); + let pid_str = record + .pid + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".to_string()); + println!( + "{:<20} {:<30} {:<12} {:<12} {:<10}", + svc_name, record.image, record.status, record.health_status, pid_str + ); + } + + Ok(()) +} + +// ============================================================================ +// compose config +// ============================================================================ + +/// `compose config` — Validate and display the parsed compose configuration. +pub(super) fn execute_config( + project_name: &str, + config: ComposeConfig, +) -> Result<(), Box> { + validate_compose_restart_policies(&config) + .map_err(|e| -> Box { e.into() })?; + let project = ComposeProject::new(project_name, config)?; + + println!("Project: {}", project_name); + println!("Services: {}", project.config.services.len()); + println!("Networks: {}", project.required_networks().len()); + println!("Volumes: {}", project.config.volumes.len()); + println!("\nBoot order: {}", project.service_order.join(" → ")); + + for svc_name in &project.service_order { + if let Some(svc) = project.config.services.get(svc_name) { + println!("\n[{}]", svc_name); + if let Some(ref img) = svc.image { + println!(" image: {}", img); + } + if !svc.ports.is_empty() { + println!(" ports: {}", svc.ports.join(", ")); + } + if !svc.volumes.is_empty() { + println!(" volumes: {}", svc.volumes.join(", ")); + } + let deps = svc.depends_on.services(); + if !deps.is_empty() { + println!(" depends_on: {}", deps.join(", ")); + } + let env = svc.environment.to_pairs(); + if !env.is_empty() { + println!(" environment:"); + for (k, v) in &env { + println!(" {}={}", k, v); + } + } + } + } + + println!("\nConfiguration is valid."); + Ok(()) +} + +// ============================================================================ +// compose logs +// ============================================================================ + +/// `compose logs` — View logs from all (or one) service in the project. +pub(super) async fn execute_logs( + project_name: &str, + config: &ComposeConfig, + logs_args: ComposeLogsArgs, +) -> Result<(), Box> { + if !logs_args.services.is_empty() { + super::operations::selected_service_names(config, &logs_args.services)?; + } + let state = StateFile::load_default()?; + let boxes = state.find_by_label(LABEL_PROJECT, project_name); + + if boxes.is_empty() { + println!("No services found for project '{}'.", project_name); + return Ok(()); + } + + // Filter to requested services if supplied. + let targets: Vec<_> = if !logs_args.services.is_empty() { + boxes + .iter() + .filter(|r| { + r.labels + .get(LABEL_SERVICE) + .is_some_and(|service| logs_args.services.contains(service)) + }) + .collect() + } else { + boxes.iter().collect() + }; + + if targets.is_empty() && !logs_args.services.is_empty() { + return Err(format!( + "Services '{}' were not found in project '{}'.", + logs_args.services.join(", "), + project_name + ) + .into()); + } + + for record in &targets { + let svc_name = record + .labels + .get(LABEL_SERVICE) + .map(|s| s.as_str()) + .unwrap_or("?"); + + let log_path = record.console_log.clone(); + if !log_path.exists() { + println!("[{}] (no logs)", svc_name); + continue; + } + + let content = std::fs::read_to_string(&log_path) + .map_err(|e| format!("Failed to read logs for {}: {}", svc_name, e))?; + + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(logs_args.tail); + let prefix = if targets.len() > 1 { + format!("{} | ", svc_name) + } else { + String::new() + }; + + for line in &lines[start..] { + println!("{}{}", prefix, line); + } + } + + if logs_args.follow { + println!("(follow mode: use Ctrl-C to stop)"); + // In follow mode, tail all log files concurrently + // For simplicity, we poll every second + let mut last_sizes: HashMap = HashMap::new(); + for record in &targets { + let size = std::fs::metadata(&record.console_log) + .map(|m| m.len()) + .unwrap_or(0); + last_sizes.insert(record.id.clone(), size); + } + + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + for record in &targets { + let log_path = &record.console_log; + let current_size = std::fs::metadata(log_path).map(|m| m.len()).unwrap_or(0); + let last_size = last_sizes.get(&record.id).copied().unwrap_or(0); + + if current_size > last_size { + let svc_name = record + .labels + .get(LABEL_SERVICE) + .map(|s| s.as_str()) + .unwrap_or("?"); + let prefix = if targets.len() > 1 { + format!("{} | ", svc_name) + } else { + String::new() + }; + + if let Ok(file) = std::fs::File::open(log_path) { + use std::io::{Read, Seek, SeekFrom}; + let mut file = file; + if file.seek(SeekFrom::Start(last_size)).is_ok() { + let mut buf = String::new(); + if file.read_to_string(&mut buf).is_ok() { + for line in buf.lines() { + println!("{}{}", prefix, line); + } + } + } + } + + last_sizes.insert(record.id.clone(), current_size); + } + } + } + } + + Ok(()) +} diff --git a/src/cli/src/commands/compose/tests.rs b/src/cli/src/commands/compose/tests.rs new file mode 100644 index 00000000..0f9c1904 --- /dev/null +++ b/src/cli/src/commands/compose/tests.rs @@ -0,0 +1,312 @@ +use super::*; + +#[test] +fn service_config_hash_is_stable_across_environment_order() { + let service = ServiceConfig::default(); + let first = a3s_box_core::BoxConfig { + image: "example:latest".to_string(), + extra_env: vec![ + ("B".to_string(), "2".to_string()), + ("A".to_string(), "1".to_string()), + ], + ..Default::default() + }; + let mut second = first.clone(); + second.extra_env.reverse(); + + assert_eq!( + service_config_hash(&service, &first).unwrap(), + service_config_hash(&service, &second).unwrap() + ); +} + +#[test] +fn service_config_hash_tracks_runtime_isolation() { + let service = ServiceConfig::default(); + let microvm = a3s_box_core::BoxConfig { + image: "example:latest".to_string(), + ..Default::default() + }; + let mut sandbox = microvm.clone(); + sandbox.isolation = a3s_box_core::ExecutionIsolation::Sandbox; + + assert_ne!( + service_config_hash(&service, µvm).unwrap(), + service_config_hash(&service, &sandbox).unwrap() + ); +} + +#[test] +fn test_load_compose_file_not_found() { + let result = load_compose_file(Some(std::path::Path::new("/nonexistent/compose.yaml"))); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); +} + +#[test] +fn test_load_compose_file_interpolates_dotenv_and_shell_before_validation() { + let directory = tempfile::TempDir::new().unwrap(); + let compose_path = directory.path().join("compose.yaml"); + std::fs::write( + &compose_path, + r#" +services: + redis: + image: redis:7-alpine + ports: + - "${REDIS_PORT:-6379}:6379" + environment: + DASH_EMPTY: ${EMPTY-default} + COLON_DASH_EMPTY: ${EMPTY:-default} + PLUS_EMPTY: ${EMPTY+replacement} + COLON_PLUS_EMPTY: ${EMPTY:+replacement} + DOTENV_ONLY: ${DOTENV_ONLY} + SHELL_WINS: ${SHELL_WINS} +"#, + ) + .unwrap(); + std::fs::write( + directory.path().join(".env"), + "REDIS_PORT=6379\nEMPTY=\nDOTENV_ONLY=dotenv\nSHELL_WINS=dotenv\n", + ) + .unwrap(); + let shell = HashMap::from([ + ("REDIS_PORT".to_string(), "16379".to_string()), + ("SHELL_WINS".to_string(), "shell".to_string()), + ]); + + let (_, config) = load_compose_file_with_environment(Some(&compose_path), shell).unwrap(); + let service = &config.services["redis"]; + assert_eq!(service.ports, vec!["16379:6379"]); + let environment: HashMap<_, _> = service.environment.to_pairs().into_iter().collect(); + assert_eq!(environment["DASH_EMPTY"], ""); + assert_eq!(environment["COLON_DASH_EMPTY"], "default"); + assert_eq!(environment["PLUS_EMPTY"], "replacement"); + assert_eq!(environment["COLON_PLUS_EMPTY"], ""); + assert_eq!(environment["DOTENV_ONLY"], "dotenv"); + assert_eq!(environment["SHELL_WINS"], "shell"); + + a3s_box_runtime::ComposeProject::with_base_dir("test", config, directory.path()) + .expect("interpolation must run before port validation"); +} + +#[test] +fn test_load_compose_file_reports_unreadable_dotenv() { + let directory = tempfile::TempDir::new().unwrap(); + let compose_path = directory.path().join("compose.yaml"); + std::fs::write(&compose_path, "services: {}\n").unwrap(); + std::fs::create_dir(directory.path().join(".env")).unwrap(); + + let error = + load_compose_file_with_environment(Some(&compose_path), HashMap::::new()) + .unwrap_err(); + + assert!(error + .to_string() + .contains("Failed to read Compose environment file")); + assert!(error.to_string().contains(".env")); +} + +#[test] +fn test_compose_files_constant() { + assert_eq!(COMPOSE_FILES.len(), 5); + assert_eq!(COMPOSE_FILES[0], "compose.acl"); + assert!(COMPOSE_FILES.contains(&"compose.yaml")); + assert!(COMPOSE_FILES.contains(&"docker-compose.yml")); +} + +#[test] +fn test_default_discovery_prefers_compose_acl() { + let directory = tempfile::TempDir::new().unwrap(); + let acl_path = directory.path().join("compose.acl"); + let yaml_path = directory.path().join("compose.yaml"); + std::fs::write(&acl_path, "service \"api\" { image = \"api:latest\" }").unwrap(); + std::fs::write(&yaml_path, "services: {}\n").unwrap(); + + let selected = resolve_compose_path(None, directory.path()).unwrap(); + + assert_eq!(selected, acl_path); +} + +#[test] +fn test_load_compose_acl_uses_dotenv_and_shell_environment() { + let directory = tempfile::TempDir::new().unwrap(); + let compose_path = directory.path().join("compose.acl"); + std::fs::write( + &compose_path, + r#"service "api" { + image = "api:${IMAGE_TAG}" + environment = { + FROM_DOTENV = env("FROM_DOTENV") + SHELL_WINS = env("SHELL_WINS") + } +} +"#, + ) + .unwrap(); + std::fs::write( + directory.path().join(".env"), + "IMAGE_TAG=dotenv\nFROM_DOTENV=present\nSHELL_WINS=dotenv\n", + ) + .unwrap(); + let shell = HashMap::from([ + ("IMAGE_TAG".to_string(), "shell".to_string()), + ("SHELL_WINS".to_string(), "shell".to_string()), + ]); + + let (_, config) = load_compose_file_with_environment(Some(&compose_path), shell).unwrap(); + let service = &config.services["api"]; + assert_eq!(service.image.as_deref(), Some("api:shell")); + let environment: HashMap<_, _> = service.environment.to_pairs().into_iter().collect(); + assert_eq!(environment["FROM_DOTENV"], "present"); + assert_eq!(environment["SHELL_WINS"], "shell"); +} + +#[test] +fn test_label_constants() { + assert_eq!(LABEL_PROJECT, "com.a3s.compose.project"); + assert_eq!(LABEL_SERVICE, "com.a3s.compose.service"); +} + +#[test] +fn test_service_restart_policy_normalizes_on_failure_limit() { + let service = ServiceConfig { + restart: Some("on-failure:3".to_string()), + ..Default::default() + }; + + let (policy, max_count) = service_restart_policy("web", Some(&service)).unwrap(); + + assert_eq!(policy, "on-failure"); + assert_eq!(max_count, 3); +} + +#[test] +fn test_validate_compose_restart_policies_rejects_invalid_service_policy() { + let mut services = HashMap::new(); + services.insert( + "web".to_string(), + ServiceConfig { + image: Some("docker.io/library/alpine:latest".to_string()), + restart: Some("never".to_string()), + ..Default::default() + }, + ); + let config = ComposeConfig { + version: None, + services, + volumes: HashMap::new(), + networks: HashMap::new(), + }; + + let error = validate_compose_restart_policies(&config).unwrap_err(); + + assert!(error.contains("Service 'web' has invalid restart policy")); + assert!(error.contains("Invalid restart policy")); +} + +#[test] +fn test_service_box_from_record_captures_cleanup_fields() { + let mut record = crate::test_helpers::fixtures::make_record( + "compose-id", + "project-web", + "running", + Some(123), + ); + record + .labels + .insert(LABEL_SERVICE.to_string(), "web".to_string()); + record.network_name = Some("project_default".to_string()); + record.volume_names = vec!["data".to_string()]; + record.anonymous_volumes = vec!["anon".to_string()]; + record.stop_signal = Some("SIGINT".to_string()); + record.stop_timeout = Some(3); + + let service = ServiceBox::from_record(&record); + + assert_eq!(service.box_id, "compose-id"); + assert_eq!(service.svc_name, "web"); + assert_eq!(service.pid, Some(123)); + assert_eq!(service.network_name.as_deref(), Some("project_default")); + assert_eq!(service.volume_names, vec!["data".to_string()]); + assert_eq!(service.anonymous_volumes, vec!["anon".to_string()]); + assert_eq!(service.stop_signal.as_deref(), Some("SIGINT")); + assert_eq!(service.stop_timeout, Some(3)); + assert!(service.is_active()); +} + +#[test] +fn test_service_box_from_record_uses_network_mode_fallback() { + let mut record = + crate::test_helpers::fixtures::make_record("compose-id", "project-web", "running", None); + record.network_name = None; + record.network_mode = a3s_box_core::NetworkMode::Bridge { + network: "legacy_default".to_string(), + }; + + let service = ServiceBox::from_record(&record); + + assert_eq!(service.network_name.as_deref(), Some("legacy_default")); +} + +#[test] +fn test_rollback_with_current_appends_current_service() { + let mut first_record = + crate::test_helpers::fixtures::make_record("first-id", "project-db", "running", None); + first_record + .labels + .insert(LABEL_SERVICE.to_string(), "db".to_string()); + let mut current_record = + crate::test_helpers::fixtures::make_record("current-id", "project-web", "running", None); + current_record + .labels + .insert(LABEL_SERVICE.to_string(), "web".to_string()); + + let first = ServiceBox::from_record(&first_record); + let current = ServiceBox::from_record(¤t_record); + let rollback_services = rollback_with_current(&[first], current); + + assert_eq!(rollback_services.len(), 2); + assert_eq!(rollback_services[0].svc_name, "db"); + assert_eq!(rollback_services[1].svc_name, "web"); +} + +#[test] +fn test_service_box_paused_is_active() { + let mut record = + crate::test_helpers::fixtures::make_record("compose-id", "project-web", "paused", None); + record + .labels + .insert(LABEL_SERVICE.to_string(), "web".to_string()); + + let service = ServiceBox::from_record(&record); + + assert!(service.is_active()); +} + +#[test] +fn test_service_box_stopped_is_not_active() { + let mut record = + crate::test_helpers::fixtures::make_record("compose-id", "project-web", "stopped", None); + record + .labels + .insert(LABEL_SERVICE.to_string(), "web".to_string()); + + let service = ServiceBox::from_record(&record); + + assert!(!service.is_active()); +} + +#[test] +fn test_partial_service_cleanup_removes_box_directory() { + let directory = tempfile::TempDir::new().unwrap(); + let box_dir = directory.path().join("box"); + let exec_socket = box_dir.join("sockets").join("exec.sock"); + std::fs::create_dir_all(box_dir.join("rootfs")).unwrap(); + std::fs::create_dir_all(exec_socket.parent().unwrap()).unwrap(); + std::fs::write(box_dir.join("rootfs").join("partial"), "data").unwrap(); + + cleanup_partial_service_box("partial-id", &box_dir, &exec_socket, None, &[], &[]); + + assert!(!box_dir.exists()); +} diff --git a/src/cli/src/commands/create.rs b/src/cli/src/commands/create.rs index b2c74169..82e05eec 100644 --- a/src/cli/src/commands/create.rs +++ b/src/cli/src/commands/create.rs @@ -1,10 +1,15 @@ //! `a3s-box create` command — Create without starting. +use a3s_box_core::{ + BoxConfig, CreateExecutionRequest, ExecutionManager, ExecutionRecordPolicy, + ExecutionRestartPolicy, OperationId, ResourceConfig, +}; +use a3s_box_runtime::LocalExecutionManager; use clap::Args; use super::common::{self, CommonBoxArgs}; use crate::output::parse_memory; -use crate::state::{generate_name, BoxRecord, StateFile}; +use crate::state::{generate_name, StateFile}; #[derive(Args)] pub struct CreateArgs { @@ -24,6 +29,7 @@ pub async fn execute(args: CreateArgs) -> Result<(), Box> let (restart_policy, max_restart_count) = crate::state::parse_restart_policy(&args.common.restart) .map_err(|e| -> Box { e.into() })?; + let restart_policy = execution_restart_policy(&restart_policy)?; let memory_mb = parse_memory(&args.common.memory).map_err(|e| format!("Invalid --memory: {e}"))?; @@ -35,7 +41,9 @@ pub async fn execute(args: CreateArgs) -> Result<(), Box> .map_err(|e| -> Box { e.into() })?; let env = common::build_env_map(&args.common)?; let labels = common::parse_env_vars(&args.common.labels) - .map_err(|e| e.replace("environment variable", "label"))?; + .map_err(|e| e.replace("environment variable", "label"))? + .into_iter() + .collect(); if let Some(network) = args.common.network.as_deref() { ensure_network_exists(network)?; } @@ -53,6 +61,7 @@ pub async fn execute(args: CreateArgs) -> Result<(), Box> .as_ref() .and_then(|config| config.stop_signal.as_deref()), ); + let isolation = common::execution_isolation(&args.common); let name = args.common.name.unwrap_or_else(generate_name); // Parse --shm-size @@ -63,22 +72,7 @@ pub async fn execute(args: CreateArgs) -> Result<(), Box> None => None, }; - let box_id = uuid::Uuid::new_v4().to_string(); - let short_id = BoxRecord::make_short_id(&box_id); - let home = a3s_box_core::dirs_home(); - let box_dir = home.join("boxes").join(&box_id); - - // Arm cleanup for the pre-registration section: create_dir_all and volume - // resolution below can fail with `?` after the box dir already exists, and - // until the box is registered it's invisible to `prune`/`rm`. The guard - // removes the orphaned dir on any early return; it is disarmed right before - // the add_record block, after which cleanup_partial_box_record takes over. - let mut dir_guard = crate::cleanup::BoxDirGuard::new(box_dir.clone()); - - // Create box directory structure - std::fs::create_dir_all(box_dir.join("sockets"))?; - std::fs::create_dir_all(box_dir.join("logs"))?; // Resolve named volumes let mut resolved_volumes = Vec::new(); @@ -104,84 +98,81 @@ pub async fn execute(args: CreateArgs) -> Result<(), Box> }, None => a3s_box_core::NetworkMode::Tsi, }; + let mut extra_env = env.into_iter().collect::>(); + extra_env.sort_by(|left, right| left.0.cmp(&right.0)); - let record = BoxRecord { - id: box_id.clone(), - short_id: short_id.clone(), - name: name.clone(), + let config = BoxConfig { + isolation, image: args.common.image.clone(), - status: "created".to_string(), - pid: None, - pid_start_time: None, - cpus: args.common.cpus, - memory_mb, - volumes: resolved_volumes, - env, + resources: ResourceConfig { + vcpus: args.common.cpus, + memory_mb, + ..Default::default() + }, cmd: args.cmd.clone(), - entrypoint, - box_dir: box_dir.clone(), - exec_socket_path: box_dir.join("sockets").join("exec.sock"), - console_log: box_dir.join("logs").join("console.log"), - created_at: chrono::Utc::now(), - started_at: None, + entrypoint_override: entrypoint, + user: args.common.user.clone(), + workdir: args.common.workdir.clone(), + hostname: args.common.hostname.clone(), + volumes: resolved_volumes, + virtiofs_cache: args + .common + .virtiofs_cache + .map(|mode| mode.as_guest_value().to_string()), + extra_env, + port_map, + dns: args.common.dns.clone(), + add_hosts: args.common.add_host.clone(), + network: network_mode, + tmpfs: args.common.tmpfs.clone(), + resource_limits, + read_only: args.common.read_only, + cap_add: args.common.cap_add.clone(), + cap_drop: args.common.cap_drop.clone(), + security_opt: args.common.security_opt.clone(), + privileged: args.common.privileged, + // A created box is restartable and therefore retains its writable + // filesystem until an explicit remove. + persistent: true, + ..Default::default() + }; + let policy = ExecutionRecordPolicy { + name: Some(name.clone()), auto_remove: false, - hostname: args.common.hostname, - user: args.common.user, - workdir: args.common.workdir, restart_policy, - port_map, - labels, - stopped_by_user: false, - restart_count: 0, max_restart_count, - exit_code: None, health_check, healthcheck_disabled: args.common.no_healthcheck, - health_status: "none".to_string(), - health_retries: 0, - health_last_check: None, - network_mode, - network_name: args.common.network, - volume_names: volume_names.clone(), - tmpfs: args.common.tmpfs, - anonymous_volumes: vec![], - resource_limits, log_config: a3s_box_core::log::LogConfig::default(), - add_host: args.common.add_host, - platform: args.common.platform, + volume_names: volume_names.clone(), + platform: args.common.platform.clone(), init: args.common.init, - read_only: args.common.read_only, - cap_add: args.common.cap_add, - cap_drop: args.common.cap_drop, - security_opt: args.common.security_opt, - privileged: args.common.privileged, - devices: args.common.device, - gpus: args.common.gpus, + devices: args.common.device.clone(), + gpus: args.common.gpus.clone(), shm_size, stop_signal: effective_stop_signal, stop_timeout: args.common.stop_timeout, oom_kill_disable: args.common.oom_kill_disable, oom_score_adj: args.common.oom_score_adj, }; - - let record_for_cleanup = record.clone(); - // Past the fallible pre-registration section: hand box-dir ownership to the - // record-level cleanup below (cleanup_partial_box_record), which also clears - // state + attached resources. - dir_guard.disarm(); - - // Atomic append under the state lock so concurrent `create`/`run` cannot - // lose records (load_default()+add() is a lost-update race). - if let Err(error) = StateFile::add_record(record) { - let mut state = StateFile::load_default()?; - crate::cleanup::cleanup_partial_box_record(&record_for_cleanup, Some(&mut state)); - return Err(error.into()); - } + let operation_id = OperationId::new(format!("cli-create-{}", uuid::Uuid::new_v4()))?; + let request = CreateExecutionRequest { + external_sandbox_id: operation_id.as_str().to_string(), + config, + labels, + policy, + rootfs_snapshot_id: None, + }; + let manager = LocalExecutionManager::with_vm_backend(home.join("boxes.json"), home); + let reservation = manager.create(request, &operation_id).await?; + let box_id = reservation.execution_id.to_string(); // Attach named volumes to this box if let Err(error) = super::volume::attach_volumes(&volume_names, &box_id) { let mut state = StateFile::load_default()?; - crate::cleanup::cleanup_partial_box_record(&record_for_cleanup, Some(&mut state)); + if let Some(record) = state.find_by_id(&box_id).cloned() { + crate::cleanup::cleanup_partial_box_record(&record, Some(&mut state)); + } return Err(error); } @@ -195,6 +186,16 @@ pub async fn execute(args: CreateArgs) -> Result<(), Box> Ok(()) } +fn execution_restart_policy(value: &str) -> Result { + match value { + "no" => Ok(ExecutionRestartPolicy::No), + "always" => Ok(ExecutionRestartPolicy::Always), + "on-failure" => Ok(ExecutionRestartPolicy::OnFailure), + "unless-stopped" => Ok(ExecutionRestartPolicy::UnlessStopped), + other => Err(format!("Invalid normalized restart policy: {other}")), + } +} + fn ensure_network_exists(network: &str) -> Result<(), Box> { let store = a3s_box_runtime::NetworkStore::default_path()?; let config = store @@ -204,3 +205,29 @@ fn ensure_network_exists(network: &str) -> Result<(), Box .map_err(|e| -> Box { e.into() })?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalized_restart_policies_map_to_typed_creation_policy() { + assert_eq!( + execution_restart_policy("no").unwrap(), + ExecutionRestartPolicy::No + ); + assert_eq!( + execution_restart_policy("always").unwrap(), + ExecutionRestartPolicy::Always + ); + assert_eq!( + execution_restart_policy("on-failure").unwrap(), + ExecutionRestartPolicy::OnFailure + ); + assert_eq!( + execution_restart_policy("unless-stopped").unwrap(), + ExecutionRestartPolicy::UnlessStopped + ); + assert!(execution_restart_policy("on-failure:3").is_err()); + } +} diff --git a/src/cli/src/commands/info.rs b/src/cli/src/commands/info.rs index 41eb6076..ded48e10 100644 --- a/src/cli/src/commands/info.rs +++ b/src/cli/src/commands/info.rs @@ -1,6 +1,8 @@ //! `a3s-box info` command. use clap::Args; +use std::path::Path; +use std::time::{Duration, Instant}; use crate::state::BoxRecord; use crate::state::StateFile; @@ -8,6 +10,14 @@ use crate::status; use super::images_dir; +const PNPM_CACHE_VOLUME_NAME: &str = "a3s-cache-pnpm"; +const NPM_CACHE_VOLUME_NAME: &str = "a3s-cache-npm"; +const PACKAGE_CACHE_SIZE_ENV: &str = "A3S_BOX_INFO_CACHE_SIZE"; +const PACKAGE_CACHE_SIZE_BUDGET: Duration = Duration::from_millis(500); +const DEFAULT_POOL_SOCKET: &str = "/tmp/a3s-box-pool.sock"; +const RUN_POOL_SOCKET_ENV: &str = "A3S_BOX_RUN_POOL_SOCKET"; +const BUILD_RUN_POOL_SOCKET_ENV: &str = "A3S_BOX_BUILD_RUN_POOL_SOCKET"; + #[derive(Args)] pub struct InfoArgs; @@ -64,6 +74,9 @@ pub async fn execute(_args: InfoArgs) -> Result<(), Box> } else { println!("Images: 0 cached"); } + print_package_cache_info(); + print_host_mount_info(); + print_warm_pool_info().await; Ok(()) } @@ -102,6 +115,198 @@ fn availability(value: bool) -> &'static str { } } +fn print_package_cache_info() { + let Ok(store) = a3s_box_runtime::VolumeStore::default_path() else { + println!("Package cache (pnpm): unavailable"); + println!("Package cache (npm): unavailable"); + return; + }; + + print_named_package_cache(&store, "pnpm", PNPM_CACHE_VOLUME_NAME); + print_named_package_cache(&store, "npm", NPM_CACHE_VOLUME_NAME); +} + +fn print_named_package_cache(store: &a3s_box_runtime::VolumeStore, label: &str, volume_name: &str) { + match store.get(volume_name) { + Ok(Some(volume)) => { + if !scan_package_cache_size_enabled() { + println!( + "Package cache ({label}): created at {} (size scan skipped; set {PACKAGE_CACHE_SIZE_ENV}=1 to enable)", + volume.mount_point + ); + return; + } + + match directory_size_bounded(Path::new(&volume.mount_point), PACKAGE_CACHE_SIZE_BUDGET) + { + Ok(DirectorySize::Complete(size)) => { + println!( + "Package cache ({label}): {} at {}", + crate::output::format_bytes(size), + volume.mount_point + ); + } + Ok(DirectorySize::TimedOut(partial)) => { + println!( + "Package cache ({label}): at least {} at {} (size scan timed out after {}ms)", + crate::output::format_bytes(partial), + volume.mount_point, + PACKAGE_CACHE_SIZE_BUDGET.as_millis() + ); + } + Err(error) => println!("Package cache ({label}): unavailable ({error})"), + } + } + Ok(None) => println!("Package cache ({label}): not created"), + Err(error) => println!("Package cache ({label}): unavailable ({error})"), + } +} + +fn scan_package_cache_size_enabled() -> bool { + std::env::var(PACKAGE_CACHE_SIZE_ENV) + .ok() + .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) +} + +fn print_host_mount_info() { + let cache_mode = std::env::var("A3S_VIRTIOFS_CACHE") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "none".to_string()); + println!("VirtioFS cache mode: {cache_mode}"); +} + +#[cfg(not(windows))] +async fn print_warm_pool_info() { + let sockets = warm_pool_info_sockets(); + for socket in &sockets { + match a3s_box_runtime::pool::client::status_client(socket).await { + Ok(status) => { + print_warm_pool_status(socket, &status.images); + return; + } + Err(_) => continue, + } + } + + println!( + "Warm pool daemon: not running (checked {})", + sockets.join(", ") + ); +} + +#[cfg(windows)] +async fn print_warm_pool_info() { + println!("Warm pool daemon: unsupported on Windows"); +} + +#[cfg(not(windows))] +fn warm_pool_info_sockets() -> Vec { + warm_pool_info_sockets_from( + std::env::var(RUN_POOL_SOCKET_ENV).ok().as_deref(), + std::env::var(BUILD_RUN_POOL_SOCKET_ENV).ok().as_deref(), + DEFAULT_POOL_SOCKET, + ) +} + +#[cfg(not(windows))] +fn warm_pool_info_sockets_from( + run_socket: Option<&str>, + build_socket: Option<&str>, + default_socket: &str, +) -> Vec { + let mut sockets = Vec::new(); + for socket in [run_socket, build_socket, Some(default_socket)] + .into_iter() + .flatten() + .map(str::trim) + { + if !socket.is_empty() && !sockets.iter().any(|existing| existing == socket) { + sockets.push(socket.to_string()); + } + } + sockets +} + +#[cfg(not(windows))] +fn print_warm_pool_status(socket: &str, images: &[a3s_box_runtime::pool::PoolImageStat]) { + if images.is_empty() { + println!("Warm pool daemon: running at {socket} (no warm pools yet)"); + return; + } + + let max: usize = images.iter().map(|image| image.max).sum(); + let idle: usize = images.iter().map(|image| image.idle).sum(); + let active: usize = images.iter().map(|image| image.active).sum(); + let leased: usize = images.iter().map(|image| image.leased).sum(); + println!( + "Warm pool daemon: running at {socket} ({} pools, max {}, {} idle, {} active, {} leased)", + images.len(), + max, + idle, + active, + leased + ); +} + +#[cfg(test)] +fn directory_size(path: &Path) -> std::io::Result { + match directory_size_inner(path, None)? { + DirectorySize::Complete(size) | DirectorySize::TimedOut(size) => Ok(size), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DirectorySize { + Complete(u64), + TimedOut(u64), +} + +fn directory_size_bounded(path: &Path, budget: Duration) -> std::io::Result { + let deadline = Instant::now() + .checked_add(budget) + .unwrap_or_else(Instant::now); + directory_size_inner(path, Some(deadline)) +} + +fn directory_size_inner(path: &Path, deadline: Option) -> std::io::Result { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return Ok(DirectorySize::TimedOut(0)); + } + + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(DirectorySize::Complete(0)); + } + Err(error) => return Err(error), + }; + if metadata.is_file() { + return Ok(DirectorySize::Complete(metadata.len())); + } + if !metadata.is_dir() { + return Ok(DirectorySize::Complete(0)); + } + + let mut total = 0_u64; + for entry in std::fs::read_dir(path)? { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return Ok(DirectorySize::TimedOut(total)); + } + + let entry = entry?; + match directory_size_inner(&entry.path(), deadline)? { + DirectorySize::Complete(size) => { + total = total.saturating_add(size); + } + DirectorySize::TimedOut(size) => { + return Ok(DirectorySize::TimedOut(total.saturating_add(size))); + } + } + } + Ok(DirectorySize::Complete(total)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct BoxCounts { total: usize, @@ -168,4 +373,51 @@ mod tests { assert_eq!(availability(true), "available"); assert_eq!(availability(false), "unavailable"); } + + #[test] + fn test_directory_size_sums_regular_files_without_following_missing_paths() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("one"), b"1234").unwrap(); + std::fs::create_dir(tmp.path().join("nested")).unwrap(); + std::fs::write(tmp.path().join("nested").join("two"), b"12").unwrap(); + + assert_eq!(directory_size(tmp.path()).unwrap(), 6); + assert_eq!(directory_size(&tmp.path().join("missing")).unwrap(), 0); + } + + #[test] + fn test_directory_size_bounded_reports_timeout() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("one"), b"1234").unwrap(); + + assert_eq!( + directory_size_bounded(tmp.path(), Duration::ZERO).unwrap(), + DirectorySize::TimedOut(0) + ); + } + + #[cfg(not(windows))] + #[test] + fn test_warm_pool_info_sockets_prefers_env_and_deduplicates() { + assert_eq!( + warm_pool_info_sockets_from( + Some(" /tmp/runtime.sock "), + Some("/tmp/build.sock"), + DEFAULT_POOL_SOCKET, + ), + vec![ + "/tmp/runtime.sock".to_string(), + "/tmp/build.sock".to_string(), + DEFAULT_POOL_SOCKET.to_string(), + ] + ); + assert_eq!( + warm_pool_info_sockets_from( + Some(" /tmp/runtime.sock "), + Some("/tmp/runtime.sock"), + "/tmp/runtime.sock", + ), + vec!["/tmp/runtime.sock".to_string()] + ); + } } diff --git a/src/cli/src/commands/kill.rs b/src/cli/src/commands/kill.rs index 1c440168..c5f229af 100644 --- a/src/cli/src/commands/kill.rs +++ b/src/cli/src/commands/kill.rs @@ -169,13 +169,13 @@ async fn kill_one( // clobber a concurrent run/monitor/compose write with our pre-await snapshot. if is_stopping_signal(signal) { if record.auto_remove { - cleanup::cleanup_removed_box(&record); + cleanup::cleanup_removed_box(&record)?; StateFile::remove_record(&box_id)?; println!("{name} (auto-removed)"); return Ok(()); } - cleanup::cleanup_stopped_box(&record); + cleanup::cleanup_stopped_box(&record)?; let exit_code = signaled_exit_code(signal); StateFile::modify(|s| { diff --git a/src/cli/src/commands/logs.rs b/src/cli/src/commands/logs.rs index fd085b79..0ed5b3c5 100644 --- a/src/cli/src/commands/logs.rs +++ b/src/cli/src/commands/logs.rs @@ -48,32 +48,48 @@ struct LogSource { pub async fn execute(args: LogsArgs) -> Result<(), Box> { let state = StateFile::load_default()?; - let record = resolve::resolve(&state, &args.r#box)?; - let box_id = record.id.clone(); - - // If logging is disabled, tell the user - if record.log_config.driver == LogDriver::None { - return Err(format!( - "Logging is disabled for box {} (log-driver=none)", - record.name - ) - .into()); - } - let since = args.since.as_deref().map(parse_time_filter).transpose()?; let until = args.until.as_deref().map(parse_time_filter).transpose()?; - let Some(log_source) = resolve_log_source(record) else { - if args.follow && record.status == "running" { - match wait_for_log_source(&box_id).await? { - Some(source) => return stream_logs(&box_id, source, args, since, until).await, - None => return Ok(()), + match resolve::resolve(&state, &args.r#box) { + Ok(record) => { + let box_id = record.id.clone(); + + // If logging is disabled, tell the user + if record.log_config.driver == LogDriver::None { + return Err(format!( + "Logging is disabled for box {} (log-driver=none)", + record.name + ) + .into()); } - } - return Ok(()); - }; - stream_logs(&box_id, log_source, args, since, until).await + let Some(log_source) = resolve_log_source(record) else { + if args.follow && record.status == "running" { + match wait_for_log_source(&box_id).await? { + Some(source) => { + return stream_logs(&box_id, source, args, since, until).await; + } + None => return Ok(()), + } + } + return Ok(()); + }; + + stream_logs(&box_id, log_source, args, since, until).await + } + Err(resolve_error) => { + let Some(archive) = crate::log_archive::resolve_archive(&args.r#box)? else { + return Err(resolve_error.into()); + }; + let Some(log_source) = resolve_archived_log_source(&archive) else { + return Ok(()); + }; + let mut args = args; + args.follow = false; + stream_logs(&archive.id, log_source, args, since, until).await + } + } } async fn stream_logs( @@ -304,6 +320,29 @@ fn resolve_log_source(record: &BoxRecord) -> Option { None } +fn resolve_archived_log_source( + archive: &crate::log_archive::RemovedLogArchive, +) -> Option { + let log_dir = archive.log_dir(); + let json_log = a3s_box_runtime::log::json_log_path(&log_dir); + if json_log.exists() { + return Some(LogSource { + path: json_log, + structured: true, + }); + } + + let console_log = archive.console_log(); + if console_log.exists() { + return Some(LogSource { + path: console_log, + structured: false, + }); + } + + None +} + async fn wait_for_log_source( box_id: &str, ) -> Result, Box> { @@ -449,6 +488,36 @@ fn extract_line_timestamp(line: &str) -> Option> { #[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + struct EnvGuard { + _lock: MutexGuard<'static, ()>, + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &Path) -> Self { + static LOCK: OnceLock> = OnceLock::new(); + let lock = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap(); + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { + _lock: lock, + key, + previous, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } #[test] #[cfg(unix)] @@ -621,4 +690,29 @@ mod tests { assert!(resolve_log_source(&record).is_none()); } + + #[test] + fn test_resolve_archived_log_source_prefers_structured_json() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::set("A3S_HOME", tmp.path()); + let archive = crate::log_archive::RemovedLogArchive { + id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + short_id: "550e8400e29b".to_string(), + name: "web".to_string(), + image: "alpine:latest".to_string(), + removed_at: Utc::now(), + created_at: Utc::now(), + started_at: None, + exit_code: Some(1), + log_config: a3s_box_core::log::LogConfig::default(), + }; + std::fs::create_dir_all(archive.log_dir()).unwrap(); + let json_log = a3s_box_runtime::log::json_log_path(&archive.log_dir()); + std::fs::write(&json_log, "{}\n").unwrap(); + std::fs::write(archive.console_log(), "console\n").unwrap(); + + let source = resolve_archived_log_source(&archive).unwrap(); + assert!(source.structured); + assert_eq!(source.path, json_log); + } } diff --git a/src/cli/src/commands/mod.rs b/src/cli/src/commands/mod.rs index c5ab0a6c..a84322f1 100644 --- a/src/cli/src/commands/mod.rs +++ b/src/cli/src/commands/mod.rs @@ -84,7 +84,7 @@ pub enum Command { Run(run::RunArgs), /// Create a new box without starting it Create(create::CreateArgs), - /// Start one or more stopped or created boxes + /// Start one or more eligible boxes Start(start::StartArgs), /// Gracefully stop one or more running boxes Stop(stop::StopArgs), @@ -236,6 +236,10 @@ pub(crate) fn resolve_box_rootfs(box_dir: &std::path::Path) -> Option { return Some(merged); } let rootfs = box_dir.join("rootfs"); + let apfs_data = rootfs.join(".a3s-rootfs"); + if apfs_data.is_dir() { + return Some(apfs_data); + } if rootfs.is_dir() { return Some(rootfs); } @@ -266,6 +270,16 @@ pub(crate) async fn tail_file_stream_positioned( use std::sync::atomic::Ordering; use tokio::io::{AsyncReadExt, AsyncSeekExt}; + // Foreground `run` supplies a position tracker so it can wait until the + // terminal has consumed the final console bytes. Poll that latency-sensitive + // path promptly; long-lived `logs -f` / `attach` tails retain the lower-rate + // idle polling cadence. + let eof_poll = if position.is_some() { + tokio::time::Duration::from_millis(20) + } else { + tokio::time::Duration::from_millis(200) + }; + // Wait for file to exist loop { if path.exists() { @@ -297,7 +311,7 @@ pub(crate) async fn tail_file_stream_positioned( } } } - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + tokio::time::sleep(eof_poll).await; } Ok(n) => { pos += n as u64; @@ -381,3 +395,59 @@ pub async fn dispatch(cli: Cli) -> Result<(), Box> { Command::Shell(args) => shell::execute(args).await, } } + +#[cfg(test)] +mod isolation_cli_tests { + use super::*; + use clap::Parser; + + #[test] + fn run_accepts_explicit_sandbox_isolation() { + let cli = + Cli::try_parse_from(["a3s-box", "run", "--isolation", "sandbox", "alpine:latest"]) + .unwrap(); + + let Command::Run(args) = cli.command else { + panic!("expected run command"); + }; + assert_eq!(args.common.isolation, Some(common::IsolationArg::Sandbox)); + } + + #[test] + fn run_omission_preserves_microvm_default() { + let cli = Cli::try_parse_from(["a3s-box", "run", "alpine:latest"]).unwrap(); + + let Command::Run(args) = cli.command else { + panic!("expected run command"); + }; + assert_eq!( + common::execution_isolation(&args.common), + a3s_box_core::ExecutionIsolation::Microvm + ); + } + + #[test] + fn cli_rejects_explicit_microvm_spelling() { + let error = + Cli::try_parse_from(["a3s-box", "run", "--isolation", "microvm", "alpine:latest"]) + .err() + .expect("explicit microvm spelling must be rejected"); + + assert!(error.to_string().contains("invalid value 'microvm'")); + } + + #[test] + fn compose_up_accepts_sandbox_isolation() { + let cli = + Cli::try_parse_from(["a3s-box", "compose", "up", "--isolation", "sandbox"]).unwrap(); + + let Command::Compose(args) = cli.command else { + panic!("expected compose command"); + }; + let compose::ComposeCommand::Up(args) = args.command else { + panic!("expected compose up command"); + }; + assert_eq!(args.isolation, Some(common::IsolationArg::Sandbox)); + assert!(args.services.is_empty()); + } +} diff --git a/src/cli/src/commands/monitor.rs b/src/cli/src/commands/monitor.rs index d6c0d39a..fbee629f 100644 --- a/src/cli/src/commands/monitor.rs +++ b/src/cli/src/commands/monitor.rs @@ -47,6 +47,14 @@ pub struct MonitorArgs { /// `127.0.0.1:9100`). Off when unset. Bind loopback — there is no auth. #[arg(long)] pub metrics_addr: Option, + + /// Internal: run the process-owned health checker for one box. + #[arg(long, hide = true, requires = "health_generation")] + pub health_worker: Option, + + /// Internal: identify the exact boot generation owned by --health-worker. + #[arg(long, hide = true, requires = "health_worker")] + pub health_generation: Option, } /// Per-box backoff state for restart attempts. @@ -149,6 +157,10 @@ impl BackoffTracker { } pub async fn execute(args: MonitorArgs) -> Result<(), Box> { + if let (Some(box_id), Some(generation)) = (args.health_worker.as_ref(), args.health_generation) + { + return crate::health::run_detached_health_worker(box_id.clone(), generation).await; + } if args.install { return super::monitor_service::install(args.interval); } @@ -427,6 +439,7 @@ async fn run_due_health_checks(state: &StateFile) -> Result<(), Box String { format!( "[Unit]\n\ Description=a3s-box monitor — restarts dead/unhealthy detached boxes\n\ - Documentation=https://github.com/AI45Lab/Box\n\ + Documentation=https://github.com/A3S-Lab/Box\n\ After=network.target\n\ \n\ [Service]\n\ diff --git a/src/cli/src/commands/network.rs b/src/cli/src/commands/network.rs index e6e64e63..596281aa 100644 --- a/src/cli/src/commands/network.rs +++ b/src/cli/src/commands/network.rs @@ -286,12 +286,7 @@ fn subnets_overlap(a: &str, b: &str) -> bool { } pub(crate) fn validate_attachable_network(config: &NetworkConfig) -> Result<(), String> { - validate_network_driver(&config.driver)?; - config - .policy - .validate() - .map_err(|e| format!("Unsupported network isolation mode: {e}"))?; - Ok(()) + config.validate_runtime() } pub(crate) fn validate_network_driver(driver: &str) -> Result<(), String> { diff --git a/src/cli/src/commands/pool.rs b/src/cli/src/commands/pool.rs index e6a15fac..ebc3c2e7 100644 --- a/src/cli/src/commands/pool.rs +++ b/src/cli/src/commands/pool.rs @@ -14,14 +14,155 @@ //! pool stop / pool status Discoverability helpers use clap::{Parser, Subcommand}; -use serde::{Deserialize, Serialize}; -use a3s_box_core::config::{BoxConfig, PoolConfig}; +use a3s_box_core::config::{BoxConfig, PoolConfig, ResourceConfig}; use a3s_box_core::event::EventEmitter; -use a3s_box_runtime::pool::{PoolStats, WarmPool}; +#[cfg(not(windows))] +use a3s_box_runtime::pool::client::{read_frame, run_client, stop_client, write_frame}; +use a3s_box_runtime::pool::{ + PoolClientRun, PoolImageStat, PoolLeaseExecRequest, PoolLeaseReleaseRequest, + PoolLeaseReleaseResponse, PoolLeaseRequest, PoolLeaseResponse, PoolRequest, PoolRunRequest, + PoolRunResponse, PoolStats, PoolStatusResponse, PoolStopResponse, WarmPool, +}; /// Default Unix socket the `pool` daemon listens on. -const DEFAULT_SOCKET: &str = "/tmp/a3s-box-pool.sock"; +pub(crate) const DEFAULT_SOCKET: &str = "/tmp/a3s-box-pool.sock"; +const DEFAULT_POOL_VCPUS: u32 = 2; +const DEFAULT_POOL_MEMORY: &str = "512m"; +const DEFAULT_POOL_MEMORY_MB: u32 = 512; +const DEFAULT_POOL_LEASE_TTL_SECS: u64 = 3600; +pub(crate) const DEFAULT_AUTOSTART_POOL_SIZE: usize = 1; +pub(crate) const DEFAULT_AUTOSTART_POOL_MAX: usize = 8; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PoolAutoStartConfig { + pub socket: String, + pub image: Option, + pub size: usize, + pub max: usize, +} + +impl PoolAutoStartConfig { + fn start_args(&self) -> Vec { + let mut args = vec![ + "pool".to_string(), + "start".to_string(), + "--socket".to_string(), + self.socket.clone(), + "--size".to_string(), + self.size.to_string(), + "--max".to_string(), + self.max.to_string(), + ]; + if let Some(image) = &self.image { + args.push("--image".to_string()); + args.push(image.clone()); + } + args + } +} + +#[cfg(unix)] +struct PoolAutoStartLock { + _file: std::fs::File, +} + +#[cfg(unix)] +impl PoolAutoStartLock { + fn acquire(socket: &str) -> std::io::Result { + use std::os::unix::io::AsRawFd; + + let lock_path = pool_autostart_lock_path(socket); + if let Some(parent) = lock_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(Self { _file: file }) + } +} + +#[cfg(unix)] +fn pool_autostart_lock_path(socket: &str) -> std::path::PathBuf { + let mut path = std::ffi::OsString::from(socket); + path.push(".autostart.lock"); + std::path::PathBuf::from(path) +} + +#[cfg(not(windows))] +pub(crate) async fn ensure_pool_daemon_running( + config: &PoolAutoStartConfig, +) -> Result<(), Box> { + if a3s_box_runtime::pool::client::status_client(&config.socket) + .await + .is_ok() + { + return Ok(()); + } + + #[cfg(unix)] + let _autostart_lock = PoolAutoStartLock::acquire(&config.socket)?; + + if a3s_box_runtime::pool::client::status_client(&config.socket) + .await + .is_ok() + { + return Ok(()); + } + + if let Some(parent) = std::path::Path::new(&config.socket) + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + + let exe = std::env::current_exe()?; + let mut child = std::process::Command::new(exe) + .args(config.start_args()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to auto-start warm-pool daemon: {e}"))?; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while std::time::Instant::now() < deadline { + if a3s_box_runtime::pool::client::status_client(&config.socket) + .await + .is_ok() + { + return Ok(()); + } + if let Some(status) = child.try_wait()? { + return Err(format!("Auto-started warm-pool daemon exited early: {status}").into()); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + Err(format!( + "Timed out waiting for auto-started warm-pool daemon at {}", + config.socket + ) + .into()) +} + +#[cfg(windows)] +pub(crate) async fn ensure_pool_daemon_running( + _config: &PoolAutoStartConfig, +) -> Result<(), Box> { + Err("warm-pool daemon auto-start is not supported on Windows".into()) +} /// Manage the warm VM pool. #[derive(Parser)] @@ -63,6 +204,13 @@ pub struct PoolStartArgs { #[arg(long, default_value = "300")] pub ttl: u64, + /// Idle TTL before reclaiming an unreleased lease (0 = unlimited). + /// + /// This protects the daemon when an internal lease client exits before it can + /// send release. Running lease exec requests are never reclaimed mid-command. + #[arg(long = "lease-ttl", default_value_t = DEFAULT_POOL_LEASE_TTL_SECS, value_parser = crate::output::parse_duration_secs)] + pub lease_ttl: u64, + /// Unix socket to serve `pool run` requests on #[arg(long, default_value = DEFAULT_SOCKET)] pub socket: String, @@ -122,6 +270,21 @@ pub struct PoolRunArgs { #[arg(long, short = 'e')] pub env: Vec, + /// Bind mount a host path into the pre-warmed sandbox, HOST:CONTAINER[:ro|rw]. + /// + /// Volumes are part of the warm-pool key because virtio-fs mounts must exist + /// before the VM boots; requests with different mounts use different pools. + #[arg(long = "volume", short = 'v')] + pub volumes: Vec, + + /// Number of vCPUs for lazily-created pools. + #[arg(long, default_value_t = DEFAULT_POOL_VCPUS)] + pub cpus: u32, + + /// Memory for lazily-created pools. + #[arg(long, default_value = DEFAULT_POOL_MEMORY)] + pub memory: String, + /// On a --deferred daemon: run via exec instead of as the box's main — /// faster (the VM survives and is returned to use), output via the exec /// stream rather than the json-file logs. @@ -136,6 +299,10 @@ pub struct PoolRunArgs { /// Arguments for `pool stop`. #[derive(Parser)] pub struct PoolStopArgs { + /// Unix socket of the `pool start` daemon + #[arg(long, default_value = DEFAULT_SOCKET)] + pub socket: String, + /// Output as JSON #[arg(long)] pub json: bool, @@ -153,61 +320,6 @@ pub struct PoolStatusArgs { pub json: bool, } -/// Wire protocol for the `pool` Unix socket (length-prefixed JSON). -/// -/// Client→daemon request: run a command, or query status. Tagged so the daemon -/// can dispatch; the client parses the response type matching what it sent. -#[derive(Serialize, Deserialize)] -#[serde(tag = "op", rename_all = "snake_case")] -enum Request { - Run(RunRequest), - Status, -} - -#[derive(Serialize, Deserialize)] -struct RunRequest { - /// Image to run in; `None` means use the daemon's default image. - #[serde(default)] - image: Option, - /// User to run as (uid[:gid] or name); `None` runs as the image default. - #[serde(default)] - user: Option, - /// Working directory inside the sandbox. - #[serde(default)] - workdir: Option, - /// Extra KEY=VALUE environment entries. - #[serde(default)] - env: Vec, - /// Force exec mode for this request (valid on a --deferred daemon, whose - /// IDLE VMs still serve exec; a keepalive daemon is always exec). - #[serde(default)] - exec: bool, - cmd: Vec, -} - -#[derive(Serialize, Deserialize)] -struct RunResponse { - stdout: Vec, - stderr: Vec, - exit_code: i32, - error: Option, -} - -/// Live stats for one image's warm pool. -#[derive(Serialize, Deserialize)] -struct ImageStat { - image: String, - idle: usize, - total_created: u64, - total_acquired: u64, - total_evicted: u64, -} - -#[derive(Serialize, Deserialize)] -struct StatusResponse { - images: Vec, -} - /// Execute a pool command. pub async fn execute(args: PoolArgs) -> Result<(), Box> { match args.action { @@ -231,7 +343,7 @@ fn keepalive_cmd() -> Vec { /// Build the `spawn-main` JSON spec for a deferred-mode pool command (executable + /// args + a standard PATH so the binary resolves like a normal container main, /// plus optional user/workdir and extra env from the request). -fn deferred_spec_json(req: &RunRequest) -> Vec { +fn deferred_spec_json(req: &PoolRunRequest) -> Vec { let mut env: Vec<(String, String)> = vec![( "PATH".to_string(), "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(), @@ -277,16 +389,118 @@ fn parse_warm_spec(entry: &str, default_size: usize) -> Result<(String, usize), struct PoolEntry { pool: std::sync::Arc, sem: std::sync::Arc, + max_size: usize, +} + +/// Boot-time dimensions that define whether a pre-warmed VM can satisfy a run. +/// +/// Image alone is not enough: virtio-fs mounts, vCPUs, and memory are fixed in +/// the VM spec at boot. Keep those in the key so a request with a workspace bind +/// mount does not accidentally acquire a sandbox that lacks it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct PoolKey { + image: String, + volumes: Vec, + vcpus: u32, + memory_mb: u32, +} + +impl PoolKey { + fn default_for_image(image: impl Into) -> Self { + Self { + image: image.into(), + volumes: Vec::new(), + vcpus: DEFAULT_POOL_VCPUS, + memory_mb: DEFAULT_POOL_MEMORY_MB, + } + } + + fn from_request(image: String, req: &PoolRunRequest) -> Self { + Self { + image, + volumes: req.volumes.clone(), + vcpus: req.vcpus.unwrap_or(DEFAULT_POOL_VCPUS), + memory_mb: req.memory_mb.unwrap_or(DEFAULT_POOL_MEMORY_MB), + } + } + + fn from_lease(image: String, req: &PoolLeaseRequest) -> Self { + Self { + image, + volumes: req.volumes.clone(), + vcpus: req.vcpus.unwrap_or(DEFAULT_POOL_VCPUS), + memory_mb: req.memory_mb.unwrap_or(DEFAULT_POOL_MEMORY_MB), + } + } + + fn label(&self) -> String { + if self.volumes.is_empty() + && self.vcpus == DEFAULT_POOL_VCPUS + && self.memory_mb == DEFAULT_POOL_MEMORY_MB + { + return self.image.clone(); + } + + format!( + "{} [vcpus={}, memory={}m, volumes={}]", + self.image, + self.vcpus, + self.memory_mb, + self.volumes.len() + ) + } +} + +#[cfg(not(windows))] +struct LeasedVm { + key: PoolKey, + vm: std::sync::Arc>, + last_used_ms: std::sync::Arc, + active_execs: std::sync::Arc, + _permit: tokio::sync::OwnedSemaphorePermit, +} + +#[cfg(not(windows))] +struct LeaseExecGuard { + last_used_ms: std::sync::Arc, + active_execs: std::sync::Arc, +} + +#[cfg(not(windows))] +impl LeaseExecGuard { + fn new(leased: &LeasedVm) -> Self { + leased + .active_execs + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Self { + last_used_ms: leased.last_used_ms.clone(), + active_execs: leased.active_execs.clone(), + } + } +} + +#[cfg(not(windows))] +impl Drop for LeaseExecGuard { + fn drop(&mut self) { + self.last_used_ms + .store(now_millis(), std::sync::atomic::Ordering::SeqCst); + self.active_execs + .fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } } /// A registry of warm pools keyed by image, created lazily on first use, so one /// daemon can serve sandboxes of different images. struct PoolRegistry { - pools: tokio::sync::Mutex>, + pools: tokio::sync::Mutex>, + #[cfg(not(windows))] + leases: tokio::sync::Mutex>, default_image: Option, size: usize, max: usize, ttl: u64, + #[cfg(not(windows))] + lease_ttl: u64, /// When true, pooled VMs boot IDLE and `pool run` spawns the command as the /// box's real MAIN (full box semantics), instead of exec-into-keepalive. deferred: bool, @@ -305,9 +519,13 @@ impl PoolRegistry { /// on first use, with `min_idle = size`. `WarmPool::start` returns once the /// replenisher is spawned, so holding the map lock across it is brief. The /// concurrency semaphore is sized to the pool's `max_size`. - async fn get_or_create_with_size(&self, image: &str, size: usize) -> Result { + async fn get_or_create_with_size( + &self, + key: PoolKey, + size: usize, + ) -> Result { let mut pools = self.pools.lock().await; - if let Some(entry) = pools.get(image) { + if let Some(entry) = pools.get(&key) { return Ok(entry.clone()); } let max_size = self.max.max(size); @@ -320,7 +538,13 @@ impl PoolRegistry { ..Default::default() }; let box_config = BoxConfig { - image: image.to_string(), + image: key.image.clone(), + resources: ResourceConfig { + vcpus: key.vcpus, + memory_mb: key.memory_mb, + ..Default::default() + }, + volumes: key.volumes.clone(), // In deferred mode the VM boots IDLE (keepalive cmd is stashed but // unused — the per-request command arrives via spawn-main). cmd: keepalive_cmd(), @@ -342,14 +566,26 @@ impl PoolRegistry { let entry = PoolEntry { pool, sem: std::sync::Arc::new(tokio::sync::Semaphore::new(max_size)), + max_size, }; - pools.insert(image.to_string(), entry.clone()); + pools.insert(key, entry.clone()); Ok(entry) } - /// Lazy pool for `image` at the daemon's default size. - async fn get_or_create(&self, image: &str) -> Result { - self.get_or_create_with_size(image, self.size).await + /// Lazy pool for `key` at the daemon's default size. + async fn get_or_create(&self, key: PoolKey) -> Result { + self.get_or_create_with_size(key, self.size).await + } + + /// Lease pools with boot-time volumes are usually build-stage rootfs mounts: + /// unique, short-lived, and useful only to the single holder. Do not pre-warm + /// a whole pool for those keys; acquire will cold-fill exactly the VM needed. + fn lease_min_idle(&self, key: &PoolKey) -> usize { + if key.volumes.is_empty() { + self.size + } else { + 0 + } } /// Resolve the image for a request: the requested one, else the daemon default. @@ -359,6 +595,14 @@ impl PoolRegistry { /// Stop replenishment and destroy idle VMs across all pools (shutdown). async fn drain_all(&self) { + #[cfg(not(windows))] + { + let mut leases = self.leases.lock().await; + for (_, leased) in leases.drain() { + let _ = leased.vm.lock().await.destroy().await; + } + } + let pools = self.pools.lock().await; for entry in pools.values() { entry.pool.signal_shutdown(); @@ -367,22 +611,174 @@ impl PoolRegistry { } /// Snapshot live per-image stats, sorted by image name. - async fn stats(&self) -> Vec { - let pools = self.pools.lock().await; + async fn stats(&self) -> Vec { + let pools = { + let pools = self.pools.lock().await; + pools + .iter() + .map(|(key, entry)| (key.clone(), entry.clone())) + .collect::>() + }; + #[cfg(not(windows))] + let leased_by_key = { + let leases = self.leases.lock().await; + let mut counts = std::collections::HashMap::::new(); + for leased in leases.values() { + *counts.entry(leased.key.clone()).or_default() += 1; + } + counts + }; let mut out = Vec::with_capacity(pools.len()); - for (image, entry) in pools.iter() { + for (key, entry) in pools { let s = entry.pool.stats().await; - out.push(ImageStat { - image: image.clone(), + let active = entry.max_size.saturating_sub(entry.sem.available_permits()); + #[cfg(not(windows))] + let leased = leased_by_key.get(&key).copied().unwrap_or(0); + #[cfg(windows)] + let leased = 0; + out.push(PoolImageStat { + image: key.image.clone(), + pool: key.label(), + max: entry.max_size, idle: s.idle_count, + active, + leased, total_created: s.total_created, total_acquired: s.total_acquired, total_evicted: s.total_evicted, }); } - out.sort_by(|a, b| a.image.cmp(&b.image)); + out.sort_by(|a, b| a.image.cmp(&b.image).then_with(|| a.pool.cmp(&b.pool))); out } + + #[cfg(not(windows))] + async fn lease_vm(&self, req: PoolLeaseRequest) -> Result { + let image = self.resolve_image(req.image.clone()).ok_or_else(|| { + "no image: pass an image or start the daemon with --image".to_string() + })?; + let key = PoolKey::from_lease(image.clone(), &req); + let entry = self + .get_or_create_with_size(key.clone(), self.lease_min_idle(&key)) + .await + .map_err(|e| format!("pool for {image}: {e}"))?; + let permit = entry + .sem + .clone() + .acquire_owned() + .await + .map_err(|_| "pool semaphore closed".to_string())?; + let vm = entry + .pool + .acquire() + .await + .map_err(|e| format!("acquire failed: {e}"))?; + let lease_id = uuid::Uuid::new_v4().to_string(); + self.leases.lock().await.insert( + lease_id.clone(), + LeasedVm { + key, + vm: std::sync::Arc::new(tokio::sync::Mutex::new(vm)), + last_used_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(now_millis())), + active_execs: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), + _permit: permit, + }, + ); + Ok(lease_id) + } + + #[cfg(not(windows))] + async fn exec_lease(&self, req: PoolLeaseExecRequest) -> PoolRunResponse { + let (vm, _guard) = { + let leases = self.leases.lock().await; + let Some(leased) = leases.get(&req.lease_id) else { + return err_resp(format!("unknown pool lease '{}'", req.lease_id)); + }; + (leased.vm.clone(), LeaseExecGuard::new(leased)) + }; + let output = vm + .lock() + .await + .exec_request(&a3s_box_core::exec::ExecRequest { + cmd: req.cmd, + timeout_ns: req.timeout_ns.unwrap_or(60_000_000_000), + env: req.env, + working_dir: req.working_dir, + rootfs: req.rootfs, + stdin: req.stdin, + stdin_streaming: false, + user: req.user, + streaming: false, + }) + .await; + match output { + Ok(o) => PoolRunResponse { + stdout: o.stdout, + stderr: o.stderr, + exit_code: o.exit_code, + error: None, + }, + Err(e) => err_resp(e.to_string()), + } + } + + #[cfg(not(windows))] + async fn release_lease(&self, req: PoolLeaseReleaseRequest) -> Option { + let leased = match self.leases.lock().await.remove(&req.lease_id) { + Some(leased) => leased, + None => return Some(format!("unknown pool lease '{}'", req.lease_id)), + }; + let result = { + let mut vm = leased.vm.lock().await; + vm.destroy().await.err().map(|e| e.to_string()) + }; + result + } + + #[cfg(not(windows))] + async fn expired_lease_ids(&self, now_ms: u64) -> Vec { + if self.lease_ttl == 0 { + return Vec::new(); + } + let leases = self.leases.lock().await; + let mut ids = leases + .iter() + .filter(|(_, leased)| lease_is_expired(leased, self.lease_ttl, now_ms)) + .map(|(lease_id, _)| lease_id.clone()) + .collect::>(); + ids.sort(); + ids + } + + #[cfg(not(windows))] + async fn reap_expired_leases(&self) -> usize { + let expired_ids = self.expired_lease_ids(now_millis()).await; + if expired_ids.is_empty() { + return 0; + } + + let mut expired = Vec::new(); + { + let mut leases = self.leases.lock().await; + for lease_id in expired_ids { + if let Some(leased) = leases.remove(&lease_id) { + expired.push((lease_id, leased)); + } + } + } + + let mut count = 0; + for (lease_id, leased) in expired { + if !lease_is_expired(&leased, self.lease_ttl, now_millis()) { + self.leases.lock().await.insert(lease_id, leased); + continue; + } + tracing::warn!(lease_id = %lease_id, "Reclaiming expired warm-pool lease"); + let _ = leased.vm.lock().await.destroy().await; + count += 1; + } + count + } } async fn execute_start(args: PoolStartArgs) -> Result<(), Box> { @@ -405,10 +801,14 @@ async fn execute_start(args: PoolStartArgs) -> Result<(), Box Result<(), Box 0 { + tokio::spawn(reap_expired_leases_task(registry.clone(), args.lease_ttl)); + } + + // Bind the control socket before pre-warming. Large images can take longer + // than the autostart client's safety cap to cold boot; keeping the daemon + // undiscoverable until that work completed made a healthy startup look like + // a timeout. Requests may connect immediately and naturally wait on the + // per-pool creation lock until the first VM is truly exec-ready. + #[cfg(not(windows))] + let serve_task = { + let serve_registry = registry.clone(); + let serve_socket = args.socket.clone(); + let serve_json = args.json; + tokio::spawn(async move { + serve(serve_registry, &serve_socket, serve_json) + .await + .map_err(|error| error.to_string()) + }) + }; + // Pre-warm the default image, if one was given. let default_stats = if let Some(ref image) = args.image { - let entry = registry.get_or_create(image).await?; + let entry = registry + .get_or_create(PoolKey::default_for_image(image.clone())) + .await?; Some((image.clone(), entry.pool.stats().await)) } else { None @@ -435,7 +859,9 @@ async fn execute_start(args: PoolStartArgs) -> Result<(), Box 0 (in '{entry}')").into()); } - registry.get_or_create_with_size(&image, count).await?; + registry + .get_or_create_with_size(PoolKey::default_for_image(&image), count) + .await?; warmed_extra.push((image, count)); } @@ -458,9 +884,13 @@ async fn execute_start(args: PoolStartArgs) -> Result<(), Box Result<(), Box bool { + if lease_ttl_secs == 0 { + return false; + } + if leased + .active_execs + .load(std::sync::atomic::Ordering::SeqCst) + != 0 + { + return false; + } + let ttl_ms = lease_ttl_secs.saturating_mul(1000); + let cutoff = now_ms.saturating_sub(ttl_ms); + leased + .last_used_ms + .load(std::sync::atomic::Ordering::SeqCst) + <= cutoff +} + +#[cfg(not(windows))] +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(not(windows))] +fn lease_reaper_interval(lease_ttl_secs: u64) -> std::time::Duration { + let secs = if lease_ttl_secs <= 4 { + 1 + } else { + (lease_ttl_secs / 4).clamp(1, 60) + }; + std::time::Duration::from_secs(secs) +} + +#[cfg(not(windows))] +async fn reap_expired_leases_task(registry: std::sync::Arc, lease_ttl_secs: u64) { + let mut interval = tokio::time::interval(lease_reaper_interval(lease_ttl_secs)); + loop { + interval.tick().await; + let reaped = registry.reap_expired_leases().await; + if reaped > 0 { + tracing::warn!(reaped, "Reaped expired warm-pool leases"); + } + } +} + /// Serve a Prometheus `/metrics` endpoint exposing the pool daemon's runtime /// metrics (warm_pool hit/miss, vm_boot, cache). Minimal raw-HTTP server, /// mirroring the monitor's metrics endpoint. @@ -529,17 +1009,28 @@ async fn serve( println!("Listening on {} (Ctrl-C to drain and stop)", socket); } + let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + loop { tokio::select! { accepted = listener.accept() => { let (mut stream, _) = accepted?; let registry = registry.clone(); + let shutdown_tx = shutdown_tx.clone(); tokio::spawn(async move { - if let Err(e) = handle_conn(®istry, &mut stream).await { + if let Err(e) = handle_conn(®istry, &shutdown_tx, &mut stream).await { tracing::warn!(error = %e, "pool connection failed"); } }); } + _ = shutdown_rx.recv() => { + let _ = std::fs::remove_file(socket); + if !json { + println!("Draining warm pools..."); + } + registry.drain_all().await; + break; + } _ = tokio::signal::ctrl_c() => { let _ = std::fs::remove_file(socket); if !json { @@ -554,8 +1045,8 @@ async fn serve( } #[cfg(not(windows))] -fn err_resp(msg: impl Into) -> RunResponse { - RunResponse { +fn err_resp(msg: impl Into) -> PoolRunResponse { + PoolRunResponse { stdout: vec![], stderr: vec![], exit_code: -1, @@ -563,28 +1054,69 @@ fn err_resp(msg: impl Into) -> RunResponse { } } +#[cfg(not(windows))] +fn timeout_duration(timeout_ns: Option, default_ns: u64) -> std::time::Duration { + std::time::Duration::from_nanos(timeout_ns.unwrap_or(default_ns)) +} + #[cfg(not(windows))] async fn handle_conn( registry: &PoolRegistry, + shutdown_tx: &tokio::sync::mpsc::UnboundedSender<()>, stream: &mut tokio::net::UnixStream, ) -> std::io::Result<()> { // 60s exec cap — generous for a sandbox command. const EXEC_TIMEOUT_NS: u64 = 60_000_000_000; - let req: Request = serde_json::from_slice(&read_frame(stream).await?) + let req: PoolRequest = serde_json::from_slice(&read_frame(stream).await?) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - // `status` is a simple query — answer and return. let run = match req { - Request::Status => { - let resp = StatusResponse { + PoolRequest::Status => { + let resp = PoolStatusResponse { images: registry.stats().await, }; let bytes = serde_json::to_vec(&resp) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; return write_frame(stream, &bytes).await; } - Request::Run(run) => run, + PoolRequest::Stop => { + let bytes = serde_json::to_vec(&PoolStopResponse { error: None }) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + write_frame(stream, &bytes).await?; + let _ = shutdown_tx.send(()); + return Ok(()); + } + PoolRequest::Lease(lease) => { + let resp = match registry.lease_vm(lease).await { + Ok(lease_id) => PoolLeaseResponse { + lease_id: Some(lease_id), + error: None, + }, + Err(error) => PoolLeaseResponse { + lease_id: None, + error: Some(error), + }, + }; + let bytes = serde_json::to_vec(&resp) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + return write_frame(stream, &bytes).await; + } + PoolRequest::Exec(exec) => { + let resp = registry.exec_lease(exec).await; + let bytes = serde_json::to_vec(&resp) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + return write_frame(stream, &bytes).await; + } + PoolRequest::Release(release) => { + let resp = PoolLeaseReleaseResponse { + error: registry.release_lease(release).await, + }; + let bytes = serde_json::to_vec(&resp) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + return write_frame(stream, &bytes).await; + } + PoolRequest::Run(run) => run, }; // Resolve the image, get-or-create its pool, acquire a warm VM, run the @@ -596,7 +1128,10 @@ async fn handle_conn( let mut used = None; let resp = match registry.resolve_image(run.image.clone()) { None => err_resp("no image: pass --image or start the daemon with --image"), - Some(image) => match registry.get_or_create(&image).await { + Some(image) => match registry + .get_or_create(PoolKey::from_request(image.clone(), &run)) + .await + { Err(e) => err_resp(format!("pool for {image}: {e}")), Ok(entry) => { // Backpressure: wait for a slot so a burst doesn't boot unbounded VMs. @@ -618,16 +1153,16 @@ async fn handle_conn( let result = if registry.deferred && !run.exec { vm.run_deferred_main( &deferred_spec_json(&run), - std::time::Duration::from_secs(60), + timeout_duration(run.timeout_ns, EXEC_TIMEOUT_NS), ) .await } else { vm.exec_request(&a3s_box_core::exec::ExecRequest { cmd: run.cmd, - timeout_ns: EXEC_TIMEOUT_NS, + timeout_ns: run.timeout_ns.unwrap_or(EXEC_TIMEOUT_NS), env: run.env, working_dir: run.workdir, - rootfs: None, + rootfs: run.rootfs, stdin: None, stdin_streaming: false, user: run.user, @@ -636,7 +1171,7 @@ async fn handle_conn( .await }; let resp = match result { - Ok(o) => RunResponse { + Ok(o) => PoolRunResponse { stdout: o.stdout, stderr: o.stderr, exit_code: o.exit_code, @@ -670,36 +1205,29 @@ async fn handle_conn( #[cfg(not(windows))] async fn execute_run(args: PoolRunArgs) -> Result<(), Box> { use std::io::Write; - use tokio::net::UnixStream; - let mut stream = UnixStream::connect(&args.socket).await.map_err(|e| { - format!( - "Failed to connect to pool daemon at {} ({}). Is `a3s-box pool start` running?", - args.socket, e - ) - })?; - - write_frame( - &mut stream, - &serde_json::to_vec(&Request::Run(RunRequest { - image: args.image, - user: args.user, - workdir: args.workdir, - env: args.env, - exec: args.exec, - cmd: args.cmd, - }))?, - ) + let memory_mb = + crate::output::parse_memory(&args.memory).map_err(|e| format!("Invalid --memory: {e}"))?; + + let output = run_client(PoolClientRun { + socket: args.socket, + image: args.image, + user: args.user, + workdir: args.workdir, + rootfs: None, + env: args.env, + volumes: args.volumes, + vcpus: args.cpus, + memory_mb, + exec: args.exec, + timeout_ns: None, + cmd: args.cmd, + }) .await?; - let resp: RunResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?; - if let Some(err) = resp.error { - eprintln!("pool error: {err}"); - std::process::exit(1); - } - std::io::stdout().write_all(&resp.stdout)?; - std::io::stderr().write_all(&resp.stderr)?; - std::process::exit(resp.exit_code); + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + std::process::exit(output.exit_code); } #[cfg(windows)] @@ -720,30 +1248,30 @@ async fn execute_run(_args: PoolRunArgs) -> Result<(), Box( - w: &mut W, - data: &[u8], -) -> std::io::Result<()> { - w.write_all(&(data.len() as u32).to_le_bytes()).await?; - w.write_all(data).await?; - w.flush().await -} - #[cfg(not(windows))] -async fn read_frame(r: &mut R) -> std::io::Result> { - let mut len = [0u8; 4]; - r.read_exact(&mut len).await?; - let mut buf = vec![0u8; u32::from_le_bytes(len) as usize]; - r.read_exact(&mut buf).await?; - Ok(buf) +async fn execute_stop(args: PoolStopArgs) -> Result<(), Box> { + match stop_client(&args.socket).await { + Ok(()) => { + if args.json { + println!(r#"{{"stopped":true}}"#); + } else { + println!("Warm pool daemon stopped."); + } + } + Err(_) => { + if args.json { + println!(r#"{{"stopped":false,"reason":"not_running"}}"#); + } else { + println!("No pool daemon running."); + } + } + } + Ok(()) } +#[cfg(windows)] async fn execute_stop(_args: PoolStopArgs) -> Result<(), Box> { - // Pool stop is handled by sending SIGINT to the `pool start` process. - eprintln!("Send SIGINT (Ctrl-C) to the running `a3s-box pool start` process to drain and stop the pool."); - Ok(()) + Err("`pool stop` is not supported on Windows".into()) } #[cfg(not(windows))] @@ -764,8 +1292,8 @@ async fn execute_status(args: PoolStatusArgs) -> Result<(), Box Result<(), Box5} {:>8} {:>9} {:>8}", - "IMAGE", "IDLE", "CREATED", "ACQUIRED", "EVICTED" + "{:<60} {:>5} {:>5} {:>5} {:>6} {:>8} {:>9} {:>8}", + "POOL", "MAX", "IDLE", "ACT", "LEASED", "CREATED", "ACQUIRED", "EVICTED" ); for s in &resp.images { println!( - "{:<40} {:>5} {:>8} {:>9} {:>8}", - s.image, s.idle, s.total_created, s.total_acquired, s.total_evicted + "{:<60} {:>5} {:>5} {:>5} {:>6} {:>8} {:>9} {:>8}", + s.pool, + s.max, + s.idle, + s.active, + s.leased, + s.total_created, + s.total_acquired, + s.total_evicted ); } } @@ -867,6 +1402,67 @@ mod tests { assert!(c.last().unwrap().contains("sleep")); } + #[test] + fn test_pool_autostart_start_args() { + let config = PoolAutoStartConfig { + socket: "/tmp/a3s-pool.sock".to_string(), + image: Some("alpine:latest".to_string()), + size: 1, + max: 4, + }; + + assert_eq!( + config.start_args(), + vec![ + "pool", + "start", + "--socket", + "/tmp/a3s-pool.sock", + "--size", + "1", + "--max", + "4", + "--image", + "alpine:latest" + ] + ); + + let lazy = PoolAutoStartConfig { + image: None, + ..config + }; + assert!(!lazy.start_args().contains(&"--image".to_string())); + } + + #[cfg(unix)] + #[test] + fn test_pool_autostart_lock_serializes_same_socket() { + use std::sync::mpsc; + use std::time::Duration; + + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("pool.sock").display().to_string(); + let lock_path = pool_autostart_lock_path(&socket); + let guard = PoolAutoStartLock::acquire(&socket).unwrap(); + assert!(lock_path.exists()); + + let thread_socket = socket.clone(); + let (tx, rx) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let _guard = PoolAutoStartLock::acquire(&thread_socket).unwrap(); + tx.send(()).unwrap(); + }); + + assert!( + rx.recv_timeout(Duration::from_millis(100)).is_err(), + "second auto-start lock should block while the first guard is alive" + ); + drop(guard); + rx.recv_timeout(Duration::from_secs(2)) + .expect("second auto-start lock should proceed after drop"); + waiter.join().unwrap(); + } + #[test] fn test_parse_warm_spec() { // image=count @@ -889,17 +1485,50 @@ mod tests { assert!(parse_warm_spec("=4", 2).is_err()); } + #[test] + fn test_pool_key_includes_boot_time_dimensions() { + let base = PoolKey::default_for_image("node:24-bookworm"); + let mounted = PoolKey::from_request( + "node:24-bookworm".to_string(), + &PoolRunRequest { + image: None, + user: None, + workdir: None, + rootfs: None, + env: vec![], + volumes: vec!["/host/work:/workspace:ro".into()], + vcpus: Some(4), + memory_mb: Some(8192), + exec: false, + timeout_ns: None, + cmd: vec!["node".into(), "--version".into()], + }, + ); + + assert_ne!(base, mounted); + assert_eq!(mounted.image, "node:24-bookworm"); + assert_eq!(mounted.volumes, vec!["/host/work:/workspace:ro"]); + assert_eq!(mounted.vcpus, 4); + assert_eq!(mounted.memory_mb, 8192); + assert!(mounted.label().contains("volumes=1")); + } + #[test] fn test_deferred_spec_json() { // The spawn-main spec for a deferred pool run: executable + args + a PATH // so the binary resolves like a normal container main, plus per-request // user/workdir and extra env. - let req = RunRequest { + let req = PoolRunRequest { image: None, user: Some("1000".into()), workdir: Some("/work".into()), + rootfs: None, env: vec!["FOO=bar".into(), "not-a-pair".into()], + volumes: vec![], + vcpus: None, + memory_mb: None, exec: false, + timeout_ns: None, cmd: vec!["sh".into(), "-c".into(), "echo hi".into()], }; let json = deferred_spec_json(&req); @@ -915,12 +1544,17 @@ mod tests { assert_eq!(v["user"], "1000"); assert_eq!(v["workdir"], "/work"); // Empty cmd falls back to a shell rather than panicking. - let req2 = RunRequest { + let req2 = PoolRunRequest { image: None, user: None, workdir: None, + rootfs: None, env: vec![], + volumes: vec![], + vcpus: None, + memory_mb: None, exec: false, + timeout_ns: None, cmd: vec![], }; let v2: serde_json::Value = serde_json::from_slice(&deferred_spec_json(&req2)).unwrap(); @@ -928,6 +1562,156 @@ mod tests { assert!(v2["user"].is_null()); } + #[test] + fn test_timeout_duration_uses_request_or_default() { + assert_eq!( + timeout_duration(Some(7_000_000_000), 60_000_000_000), + std::time::Duration::from_secs(7) + ); + assert_eq!( + timeout_duration(None, 60_000_000_000), + std::time::Duration::from_secs(60) + ); + } + + #[cfg(not(windows))] + fn test_registry_with_lease_ttl(lease_ttl: u64) -> std::sync::Arc { + std::sync::Arc::new(PoolRegistry { + pools: tokio::sync::Mutex::new(std::collections::HashMap::new()), + leases: tokio::sync::Mutex::new(std::collections::HashMap::new()), + default_image: Some("alpine:latest".to_string()), + size: 1, + max: 4, + ttl: 0, + lease_ttl, + deferred: false, + ksm: false, + snapshot_fork: false, + metrics: None, + }) + } + + #[cfg(not(windows))] + async fn insert_test_lease( + registry: &PoolRegistry, + lease_id: &str, + last_used_ms: u64, + active_execs: usize, + ) { + let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); + let permit = sem.acquire_owned().await.unwrap(); + let config = BoxConfig { + image: "alpine:latest".to_string(), + ..Default::default() + }; + let vm = a3s_box_runtime::VmManager::with_box_id( + config, + EventEmitter::new(16), + format!("test-lease-{lease_id}"), + ); + registry.leases.lock().await.insert( + lease_id.to_string(), + LeasedVm { + key: PoolKey::default_for_image("alpine:latest"), + vm: std::sync::Arc::new(tokio::sync::Mutex::new(vm)), + last_used_ms: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(last_used_ms)), + active_execs: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new( + active_execs, + )), + _permit: permit, + }, + ); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn test_expired_lease_ids_only_reports_idle_stale_leases() { + let registry = test_registry_with_lease_ttl(60); + let now = 1_000_000; + insert_test_lease(®istry, "busy", now - 120_000, 1).await; + insert_test_lease(®istry, "fresh", now - 10_000, 0).await; + insert_test_lease(®istry, "stale", now - 120_000, 0).await; + + let expired = registry.expired_lease_ids(now).await; + + assert_eq!(expired, vec!["stale".to_string()]); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn test_expired_lease_ids_disabled_when_ttl_zero() { + let registry = test_registry_with_lease_ttl(0); + let now = 1_000_000; + insert_test_lease(®istry, "stale", now - 120_000, 0).await; + + assert!(registry.expired_lease_ids(now).await.is_empty()); + } + + #[cfg(not(windows))] + #[test] + fn test_lease_min_idle_skips_prewarm_for_volume_bound_leases() { + let registry = test_registry_with_lease_ttl(60); + let plain_key = PoolKey::default_for_image("alpine:latest"); + let volume_key = PoolKey { + image: "alpine:latest".to_string(), + volumes: vec!["/host/stage:/run/a3s/build-rootfs:rw".to_string()], + vcpus: DEFAULT_POOL_VCPUS, + memory_mb: DEFAULT_POOL_MEMORY_MB, + }; + + assert_eq!(registry.lease_min_idle(&plain_key), registry.size); + assert_eq!(registry.lease_min_idle(&volume_key), 0); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn test_lease_exec_guard_marks_busy_and_refreshes_activity() { + let registry = test_registry_with_lease_ttl(60); + let now = now_millis(); + insert_test_lease(®istry, "lease", now.saturating_sub(120_000), 0).await; + + let (last_used, active_execs) = { + let leases = registry.leases.lock().await; + let leased = leases.get("lease").unwrap(); + let guard = LeaseExecGuard::new(leased); + assert_eq!( + leased + .active_execs + .load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + assert!(!lease_is_expired( + leased, + registry.lease_ttl, + now.saturating_add(120_000) + )); + let last_used = leased.last_used_ms.clone(); + let active_execs = leased.active_execs.clone(); + drop(guard); + (last_used, active_execs) + }; + + assert_eq!(active_execs.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!( + last_used.load(std::sync::atomic::Ordering::SeqCst) >= now, + "dropping the guard should refresh lease activity" + ); + } + + #[cfg(not(windows))] + #[test] + fn test_lease_reaper_interval_is_bounded() { + assert_eq!(lease_reaper_interval(1), std::time::Duration::from_secs(1)); + assert_eq!( + lease_reaper_interval(60), + std::time::Duration::from_secs(15) + ); + assert_eq!( + lease_reaper_interval(3600), + std::time::Duration::from_secs(60) + ); + } + #[tokio::test] async fn test_backpressure_bounds_concurrency() { // The contract PoolEntry relies on: a permit (held until teardown) caps @@ -962,35 +1746,46 @@ mod tests { #[test] fn test_run_request_response_roundtrip() { - let req = RunRequest { + let req = PoolRunRequest { image: Some("alpine:latest".into()), user: Some("1000".into()), workdir: Some("/tmp".into()), + rootfs: None, env: vec!["FOO=bar".into()], + volumes: vec!["/host:/work:ro".into()], + vcpus: Some(4), + memory_mb: Some(2048), exec: false, + timeout_ns: None, cmd: vec!["echo".into(), "hi".into()], }; let bytes = serde_json::to_vec(&req).unwrap(); - let parsed: RunRequest = serde_json::from_slice(&bytes).unwrap(); + let parsed: PoolRunRequest = serde_json::from_slice(&bytes).unwrap(); assert_eq!(parsed.cmd, vec!["echo", "hi"]); assert_eq!(parsed.image.as_deref(), Some("alpine:latest")); assert_eq!(parsed.user.as_deref(), Some("1000")); assert_eq!(parsed.workdir.as_deref(), Some("/tmp")); assert_eq!(parsed.env, vec!["FOO=bar"]); + assert_eq!(parsed.volumes, vec!["/host:/work:ro"]); + assert_eq!(parsed.vcpus, Some(4)); + assert_eq!(parsed.memory_mb, Some(2048)); // image/user/workdir/env are optional on the wire (older clients). - let no_img: RunRequest = serde_json::from_slice(br#"{"cmd":["ls"]}"#).unwrap(); + let no_img: PoolRunRequest = serde_json::from_slice(br#"{"cmd":["ls"]}"#).unwrap(); assert!(no_img.image.is_none()); assert!(no_img.user.is_none() && no_img.workdir.is_none() && no_img.env.is_empty()); + assert!(no_img.volumes.is_empty()); + assert!(no_img.vcpus.is_none()); + assert!(no_img.memory_mb.is_none()); - let resp = RunResponse { + let resp = PoolRunResponse { stdout: b"hi\n".to_vec(), stderr: vec![], exit_code: 0, error: None, }; let rb = serde_json::to_vec(&resp).unwrap(); - let rp: RunResponse = serde_json::from_slice(&rb).unwrap(); + let rp: PoolRunResponse = serde_json::from_slice(&rb).unwrap(); assert_eq!(rp.stdout, b"hi\n"); assert_eq!(rp.exit_code, 0); assert!(rp.error.is_none()); @@ -1003,6 +1798,7 @@ mod tests { size: 0, max: 5, ttl: 300, + lease_ttl: DEFAULT_POOL_LEASE_TTL_SECS, socket: DEFAULT_SOCKET.to_string(), warm: vec![], deferred: false, @@ -1023,6 +1819,7 @@ mod tests { size: 10, max: 5, ttl: 300, + lease_ttl: DEFAULT_POOL_LEASE_TTL_SECS, socket: DEFAULT_SOCKET.to_string(), warm: vec![], deferred: false, @@ -1041,10 +1838,57 @@ mod tests { #[tokio::test] async fn test_execute_stop_is_ok() { - let result = execute_stop(PoolStopArgs { json: false }).await; + let result = execute_stop(PoolStopArgs { + socket: "/tmp/a3s-box-pool-does-not-exist.sock".to_string(), + json: false, + }) + .await; assert!(result.is_ok()); } + #[cfg(not(windows))] + #[tokio::test] + async fn test_stop_request_shuts_down_daemon() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("pool.sock"); + let socket_arg = socket.display().to_string(); + let server_socket = socket_arg.clone(); + let registry = std::sync::Arc::new(PoolRegistry { + pools: tokio::sync::Mutex::new(std::collections::HashMap::new()), + leases: tokio::sync::Mutex::new(std::collections::HashMap::new()), + default_image: None, + size: 1, + max: 1, + ttl: 0, + lease_ttl: DEFAULT_POOL_LEASE_TTL_SECS, + deferred: false, + ksm: false, + snapshot_fork: false, + metrics: None, + }); + + let server = tokio::spawn(async move { + serve(registry, &server_socket, true) + .await + .expect("pool server should stop cleanly"); + }); + + for _ in 0..50 { + if socket.exists() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(socket.exists(), "pool socket should be bound before stop"); + + stop_client(&socket_arg).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), server) + .await + .expect("pool server should exit after stop") + .unwrap(); + assert!(!socket.exists(), "pool socket should be removed on stop"); + } + #[cfg(not(windows))] #[tokio::test] async fn test_execute_status_no_daemon_succeeds_empty() { @@ -1060,36 +1904,84 @@ mod tests { #[test] fn test_request_envelope_tagging() { - // Run carries an op tag + the flattened RunRequest; Status is a bare tag. - let run = serde_json::to_string(&Request::Run(RunRequest { + // Run carries an op tag + the flattened PoolRunRequest; Status is a bare tag. + let run = serde_json::to_string(&PoolRequest::Run(PoolRunRequest { image: Some("alpine".into()), user: None, workdir: None, + rootfs: None, env: vec![], + volumes: vec![], + vcpus: None, + memory_mb: None, exec: false, + timeout_ns: None, cmd: vec!["echo".into(), "hi".into()], })) .unwrap(); assert!(run.contains(r#""op":"run""#)); assert!(run.contains(r#""cmd":["echo","hi"]"#)); - let status = serde_json::to_string(&Request::Status).unwrap(); + let status = serde_json::to_string(&PoolRequest::Status).unwrap(); assert_eq!(status, r#"{"op":"status"}"#); - // StatusResponse round-trips. - let sr = StatusResponse { - images: vec![ImageStat { + let stop = serde_json::to_string(&PoolRequest::Stop).unwrap(); + assert_eq!(stop, r#"{"op":"stop"}"#); + + let lease = serde_json::to_string(&PoolRequest::Lease(PoolLeaseRequest { + image: Some("alpine".into()), + volumes: vec!["/host/rootfs:/run/a3s/build-rootfs:rw".into()], + vcpus: Some(2), + memory_mb: Some(512), + })) + .unwrap(); + assert!(lease.contains(r#""op":"lease""#)); + assert!(lease.contains("/run/a3s/build-rootfs")); + + let exec = serde_json::to_string(&PoolRequest::Exec(PoolLeaseExecRequest { + lease_id: "lease-1".into(), + cmd: vec!["/bin/sh".into(), "-c".into(), "echo hi".into()], + timeout_ns: Some(5_000_000_000), + env: vec!["FOO=bar".into()], + working_dir: Some("/".into()), + rootfs: Some("/run/a3s/build-rootfs".into()), + stdin: None, + user: None, + })) + .unwrap(); + assert!(exec.contains(r#""op":"exec""#)); + assert!(exec.contains(r#""lease_id":"lease-1""#)); + assert!(exec.contains(r#""rootfs":"/run/a3s/build-rootfs""#)); + + // PoolStatusResponse round-trips. + let sr = PoolStatusResponse { + images: vec![PoolImageStat { image: "alpine".into(), + pool: "alpine".into(), + max: 4, idle: 2, + active: 1, + leased: 1, total_created: 5, total_acquired: 3, total_evicted: 1, }], }; - let parsed: StatusResponse = + let parsed: PoolStatusResponse = serde_json::from_slice(&serde_json::to_vec(&sr).unwrap()).unwrap(); assert_eq!(parsed.images[0].image, "alpine"); assert_eq!(parsed.images[0].idle, 2); + assert_eq!(parsed.images[0].max, 4); + assert_eq!(parsed.images[0].active, 1); + assert_eq!(parsed.images[0].leased, 1); + + let legacy: PoolStatusResponse = serde_json::from_str( + r#"{"images":[{"image":"alpine","pool":"alpine","idle":2,"total_created":5,"total_acquired":3,"total_evicted":1}]}"#, + ) + .unwrap(); + assert_eq!(legacy.images[0].max, 0); + assert_eq!(legacy.images[0].active, 0); + assert_eq!(legacy.images[0].leased, 0); } #[cfg(not(windows))] @@ -1097,18 +1989,23 @@ mod tests { async fn test_frame_roundtrip() { // write_frame then read_frame must return the exact bytes. let (mut a, mut b) = tokio::io::duplex(4096); - let payload = serde_json::to_vec(&RunRequest { + let payload = serde_json::to_vec(&PoolRunRequest { image: None, user: None, workdir: None, + rootfs: None, env: vec![], + volumes: vec![], + vcpus: None, + memory_mb: None, exec: false, + timeout_ns: None, cmd: vec!["echo".into(), "hi there".into()], }) .unwrap(); write_frame(&mut a, &payload).await.unwrap(); let got = read_frame(&mut b).await.unwrap(); - let parsed: RunRequest = serde_json::from_slice(&got).unwrap(); + let parsed: PoolRunRequest = serde_json::from_slice(&got).unwrap(); assert_eq!(parsed.cmd, vec!["echo", "hi there"]); } @@ -1118,7 +2015,7 @@ mod tests { // Exercise the full client/server wire protocol over a real Unix socket // (the exact framing `serve` and `pool run` use), with a stub server // standing in for the VM pool's acquire+exec. - use tokio::net::{UnixListener, UnixStream}; + use tokio::net::UnixListener; let dir = tempfile::tempdir().unwrap(); let sock = dir.path().join("pool.sock"); @@ -1126,9 +2023,12 @@ mod tests { let server = tokio::spawn(async move { let (mut s, _) = listener.accept().await.unwrap(); - let req: RunRequest = + let req: PoolRequest = serde_json::from_slice(&read_frame(&mut s).await.unwrap()).unwrap(); - let resp = RunResponse { + let PoolRequest::Run(req) = req else { + panic!("expected run request"); + }; + let resp = PoolRunResponse { stdout: format!("ran {:?}", req.cmd).into_bytes(), stderr: vec![], exit_code: 0, @@ -1139,24 +2039,25 @@ mod tests { .unwrap(); }); - let mut client = UnixStream::connect(&sock).await.unwrap(); - let req = RunRequest { + let output = run_client(PoolClientRun { + socket: sock.display().to_string(), image: Some("alpine:latest".into()), user: None, workdir: None, + rootfs: None, env: vec![], + volumes: vec![], + vcpus: 2, + memory_mb: 512, exec: false, + timeout_ns: None, cmd: vec!["ls".into(), "-la".into()], - }; - write_frame(&mut client, &serde_json::to_vec(&req).unwrap()) - .await - .unwrap(); - let resp: RunResponse = - serde_json::from_slice(&read_frame(&mut client).await.unwrap()).unwrap(); + }) + .await + .unwrap(); - assert_eq!(resp.exit_code, 0); - assert!(resp.error.is_none()); - assert!(String::from_utf8_lossy(&resp.stdout).contains("ls")); + assert_eq!(output.exit_code, 0); + assert!(String::from_utf8_lossy(&output.stdout).contains("ls")); server.await.unwrap(); } diff --git a/src/cli/src/commands/ps.rs b/src/cli/src/commands/ps.rs index 41d404da..051bdc7a 100644 --- a/src/cli/src/commands/ps.rs +++ b/src/cli/src/commands/ps.rs @@ -199,12 +199,15 @@ mod tests { short_id, name: name.to_string(), image: "alpine:latest".to_string(), + isolation: Default::default(), + managed_execution: None, status: status.to_string(), pid: None, pid_start_time: None, cpus: 2, memory_mb: 512, volumes: vec![], + virtiofs_cache: None, env: HashMap::new(), cmd: vec![], entrypoint: None, diff --git a/src/cli/src/commands/push.rs b/src/cli/src/commands/push.rs index 73638fc9..c4badeed 100644 --- a/src/cli/src/commands/push.rs +++ b/src/cli/src/commands/push.rs @@ -16,6 +16,14 @@ pub struct PushArgs { #[arg(short, long)] pub quiet: bool, + /// Use plain HTTP for a trusted private registry + #[arg(long, alias = "insecure")] + pub plain_http: bool, + + /// Verify TLS for registry HTTPS connections; use `--tls-verify=false` for plain HTTP + #[arg(long, default_value_t = true, value_parser = clap::builder::BoolishValueParser::new(), num_args = 0..=1, require_equals = true, default_missing_value = "true")] + pub tls_verify: bool, + /// Sign the image after push with a cosign-compatible ECDSA P-256 private key #[arg(long)] pub sign_key: Option, @@ -41,7 +49,8 @@ pub async fn execute(args: PushArgs) -> Result<(), Box> { // Load auth from credential store (falls back to env vars, then anonymous) let auth = a3s_box_runtime::RegistryAuth::from_credential_store(&reference.registry); - let pusher = a3s_box_runtime::RegistryPusher::with_auth(auth); + let protocol = registry_protocol_from_args(args.plain_http, args.tls_verify); + let pusher = a3s_box_runtime::RegistryPusher::with_auth_and_protocol(auth, protocol); let result = pusher.push(&reference, &stored.path).await?; @@ -89,6 +98,17 @@ fn push_reference_for_query(query: &str, resolved_reference: &str) -> Result a3s_box_runtime::RegistryProtocol { + if plain_http || !tls_verify { + a3s_box_runtime::RegistryProtocol::Http + } else { + a3s_box_runtime::RegistryProtocol::Https + } +} + #[cfg(test)] mod tests { use super::*; @@ -98,9 +118,13 @@ mod tests { let args = PushArgs { image: "ghcr.io/org/app:latest".to_string(), quiet: false, + plain_http: false, + tls_verify: true, sign_key: None, }; assert!(!args.quiet); + assert!(!args.plain_http); + assert!(args.tls_verify); assert!(args.sign_key.is_none()); } @@ -109,6 +133,8 @@ mod tests { let args = PushArgs { image: "ghcr.io/org/app:latest".to_string(), quiet: false, + plain_http: false, + tls_verify: true, sign_key: Some("/path/to/cosign.key".to_string()), }; assert_eq!(args.sign_key.as_deref(), Some("/path/to/cosign.key")); @@ -136,4 +162,28 @@ mod tests { assert!(error.contains("Tag it first")); } + + #[test] + fn test_registry_protocol_from_args_defaults_to_https() { + assert_eq!( + registry_protocol_from_args(false, true), + a3s_box_runtime::RegistryProtocol::Https + ); + } + + #[test] + fn test_registry_protocol_from_plain_http_flag() { + assert_eq!( + registry_protocol_from_args(true, true), + a3s_box_runtime::RegistryProtocol::Http + ); + } + + #[test] + fn test_registry_protocol_from_tls_verify_false() { + assert_eq!( + registry_protocol_from_args(false, false), + a3s_box_runtime::RegistryProtocol::Http + ); + } } diff --git a/src/cli/src/commands/rename.rs b/src/cli/src/commands/rename.rs index 8f80d5aa..9b76c91f 100644 --- a/src/cli/src/commands/rename.rs +++ b/src/cli/src/commands/rename.rs @@ -46,12 +46,15 @@ mod tests { short_id, name: name.to_string(), image: "alpine:latest".to_string(), + isolation: Default::default(), + managed_execution: None, status: "created".to_string(), pid: None, pid_start_time: None, cpus: 2, memory_mb: 512, volumes: vec![], + virtiofs_cache: None, env: HashMap::new(), cmd: vec![], entrypoint: None, diff --git a/src/cli/src/commands/restart.rs b/src/cli/src/commands/restart.rs index b63d53a9..d7179931 100644 --- a/src/cli/src/commands/restart.rs +++ b/src/cli/src/commands/restart.rs @@ -1,7 +1,12 @@ //! `a3s-box restart` command — Restart one or more boxes. //! -//! Equivalent to `a3s-box stop` followed by `a3s-box start`. +//! Managed records use the durable lifecycle manager. Legacy records retain +//! the equivalent of `a3s-box stop` followed by `a3s-box start`. +use a3s_box_core::{ + ExecutionGeneration, ExecutionId, ExecutionManager, OperationId, RestartExecutionOptions, +}; +use a3s_box_runtime::{LocalExecutionManager, ManagedExecutionOperation, ManagedExecutionState}; use clap::Args; use crate::boot; @@ -48,12 +53,45 @@ async fn restart_one( let box_id = record.id.clone(); let name = record.name.clone(); - let restart_plan = restart_plan(record)?; + let restart_plan = restart_plan(record, timeout)?; let box_dir = record.box_dir.clone(); let exec_socket_path = record.exec_socket_path.clone(); + if let RestartPlan::Managed { + execution_id, + generation, + operation_id, + stop_timeout_secs, + } = restart_plan + { + let operation_id = match operation_id { + Some(operation_id) => operation_id, + None => OperationId::new(format!("cli-restart-{}", uuid::Uuid::new_v4()))?, + }; + let home = a3s_box_core::dirs_home(); + let manager = LocalExecutionManager::with_vm_backend(home.join("boxes.json"), &home); + manager + .restart_with_options( + &execution_id, + generation, + &operation_id, + RestartExecutionOptions { stop_timeout_secs }, + ) + .await?; + create_baseline_snapshot(&box_id, &box_dir).await; + + let current = StateFile::load_default()?; + let record = current + .find_by_id(&box_id) + .ok_or_else(|| format!("{name} was removed during restart"))?; + crate::health::spawn_detached_health_checker(record) + .map_err(|error| -> Box { error.into() })?; + println!("{name}"); + return Ok(()); + } + // Phase 1: Stop the box if it is active. - if restart_plan == RestartPlan::StopThenStart { + if restart_plan == RestartPlan::LegacyStopThenStart { let pid = lifecycle::require_live_pid(record, "restart") .map_err(|error| -> Box { error.into() })?; let stop_signal = record @@ -99,22 +137,100 @@ async fn restart_one( return Err(format!("{name} was removed during restart").into()); } } + let current = StateFile::load_default()?; + if let Some(record) = current.find_by_id(&box_id) { + crate::health::spawn_detached_health_checker(record) + .map_err(|error| -> Box { error.into() })?; + } Ok(()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +async fn create_baseline_snapshot(box_id: &str, box_dir: &std::path::Path) { + let baseline_box_dir = box_dir.to_path_buf(); + let baseline_box_id = box_id.to_string(); + match tokio::task::spawn_blocking(move || { + crate::commands::diff::create_box_baseline_snapshot(&baseline_box_dir) + .map_err(|error| error.to_string()) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!( + box_id = %baseline_box_id, + %error, + "Failed to create rootfs diff baseline snapshot after restart" + ); + } + Err(error) => { + tracing::warn!( + box_id = %baseline_box_id, + %error, + "Rootfs diff baseline task failed after restart" + ); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] enum RestartPlan { - StopThenStart, - StartOnly, + LegacyStopThenStart, + LegacyStartOnly, + Managed { + execution_id: ExecutionId, + generation: ExecutionGeneration, + operation_id: Option, + stop_timeout_secs: Option, + }, } -fn restart_plan(record: &crate::state::BoxRecord) -> Result { +fn restart_plan(record: &crate::state::BoxRecord, timeout: u64) -> Result { + if let Some(metadata) = record.managed_execution.as_ref() { + let execution_id = + ExecutionId::new(record.id.clone()).map_err(|error| error.to_string())?; + let state = record + .managed_state() + .map_err(|error| format!("Invalid managed state for box {}: {error}", record.name))? + .ok_or_else(|| format!("Box {} lost managed lifecycle metadata", record.name))?; + return match state { + ManagedExecutionState::Created + | ManagedExecutionState::Running + | ManagedExecutionState::Paused + | ManagedExecutionState::Stopped + | ManagedExecutionState::Failed => Ok(RestartPlan::Managed { + execution_id, + generation: metadata.generation, + operation_id: None, + stop_timeout_secs: Some(record.stop_timeout.unwrap_or(timeout)), + }), + ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting => { + match metadata.pending_operation.as_ref() { + Some(ManagedExecutionOperation::Restart { + operation_id, + source_generation, + stop_timeout_secs, + .. + }) => Ok(RestartPlan::Managed { + execution_id, + generation: *source_generation, + operation_id: Some(operation_id.clone()), + stop_timeout_secs: *stop_timeout_secs, + }), + _ => Err(format!( + "Box {} has no persisted managed restart intent", + record.name + )), + } + } + other => Err(format!("Cannot restart box in state: {other}")), + }; + } if status::is_active(record) { - return Ok(RestartPlan::StopThenStart); + return Ok(RestartPlan::LegacyStopThenStart); } match record.status.as_str() { - "created" | "stopped" | "dead" => Ok(RestartPlan::StartOnly), + "created" | "stopped" | "dead" => Ok(RestartPlan::LegacyStartOnly), other => Err(format!("Cannot restart box in state: {other}")), } } @@ -122,33 +238,128 @@ fn restart_plan(record: &crate::state::BoxRecord) -> Result #[cfg(test)] mod tests { use super::*; + use a3s_box_core::{BoxConfig, CreateExecutionRequest, ExecutionIsolation}; + use a3s_box_runtime::ManagedExecutionMetadata; + use std::collections::BTreeMap; + use crate::test_helpers::fixtures::make_record; + fn managed_record(state: ManagedExecutionState) -> crate::state::BoxRecord { + let id = "11111111-1111-4111-8111-111111111111"; + let mut record = make_record(id, "managed", state.as_status(), None); + record.isolation = ExecutionIsolation::Sandbox; + let mut metadata = ManagedExecutionMetadata::new( + OperationId::new("operation-create").unwrap(), + ExecutionGeneration::INITIAL, + CreateExecutionRequest { + external_sandbox_id: "external-1".to_string(), + config: BoxConfig { + isolation: ExecutionIsolation::Sandbox, + image: record.image.clone(), + ..Default::default() + }, + labels: BTreeMap::new(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + ) + .unwrap(); + if matches!( + state, + ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting + ) { + metadata.pending_operation = Some(ManagedExecutionOperation::Restart { + operation_id: OperationId::new("operation-restart").unwrap(), + source_generation: ExecutionGeneration::INITIAL, + source_state: ManagedExecutionState::Running, + stop_timeout_secs: Some(7), + }); + } + if state == ManagedExecutionState::RestartStarting { + metadata.generation = ExecutionGeneration::new(2).unwrap(); + } + record.managed_execution = Some(metadata); + record + } + #[test] fn test_restart_plan_stops_running_and_paused_first() { assert_eq!( - restart_plan(&make_record("id-1", "running", "running", Some(1))).unwrap(), - RestartPlan::StopThenStart + restart_plan(&make_record("id-1", "running", "running", Some(1)), 10).unwrap(), + RestartPlan::LegacyStopThenStart ); assert_eq!( - restart_plan(&make_record("id-2", "paused", "paused", Some(1))).unwrap(), - RestartPlan::StopThenStart + restart_plan(&make_record("id-2", "paused", "paused", Some(1)), 10).unwrap(), + RestartPlan::LegacyStopThenStart ); } #[test] fn test_restart_plan_starts_inactive_boxes_directly() { assert_eq!( - restart_plan(&make_record("id-1", "created", "created", None)).unwrap(), - RestartPlan::StartOnly + restart_plan(&make_record("id-1", "created", "created", None), 10).unwrap(), + RestartPlan::LegacyStartOnly ); assert_eq!( - restart_plan(&make_record("id-2", "stopped", "stopped", None)).unwrap(), - RestartPlan::StartOnly + restart_plan(&make_record("id-2", "stopped", "stopped", None), 10).unwrap(), + RestartPlan::LegacyStartOnly ); assert_eq!( - restart_plan(&make_record("id-3", "dead", "dead", None)).unwrap(), - RestartPlan::StartOnly + restart_plan(&make_record("id-3", "dead", "dead", None), 10).unwrap(), + RestartPlan::LegacyStartOnly ); } + + #[test] + fn managed_restart_plan_uses_the_current_generation_for_stable_states() { + for state in [ + ManagedExecutionState::Created, + ManagedExecutionState::Running, + ManagedExecutionState::Paused, + ManagedExecutionState::Stopped, + ManagedExecutionState::Failed, + ] { + assert_eq!( + restart_plan(&managed_record(state), 10).unwrap(), + RestartPlan::Managed { + execution_id: ExecutionId::new("11111111-1111-4111-8111-111111111111").unwrap(), + generation: ExecutionGeneration::INITIAL, + operation_id: None, + stop_timeout_secs: Some(10), + } + ); + } + } + + #[test] + fn managed_restart_plan_recovers_the_persisted_operation() { + for state in [ + ManagedExecutionState::RestartStopping, + ManagedExecutionState::RestartStarting, + ] { + assert_eq!( + restart_plan(&managed_record(state), 10).unwrap(), + RestartPlan::Managed { + execution_id: ExecutionId::new("11111111-1111-4111-8111-111111111111").unwrap(), + generation: ExecutionGeneration::INITIAL, + operation_id: Some(OperationId::new("operation-restart").unwrap()), + stop_timeout_secs: Some(7), + } + ); + } + } + + #[test] + fn managed_restart_plan_uses_record_stop_timeout_before_cli_fallback() { + let mut record = managed_record(ManagedExecutionState::Running); + record.stop_timeout = Some(3); + + let RestartPlan::Managed { + stop_timeout_secs, .. + } = restart_plan(&record, 10).unwrap() + else { + panic!("expected managed restart plan"); + }; + assert_eq!(stop_timeout_secs, Some(3)); + } } diff --git a/src/cli/src/commands/rm.rs b/src/cli/src/commands/rm.rs index 03d2f0cf..60c1ce5c 100644 --- a/src/cli/src/commands/rm.rs +++ b/src/cli/src/commands/rm.rs @@ -64,7 +64,7 @@ fn rm_one( let box_id = record.id.clone(); let name = record.name.clone(); - cleanup::cleanup_removed_box(&record); + cleanup::cleanup_removed_box(&record)?; // Remove from state atomically under the lock (avoids clobbering concurrent // monitor/CLI writers that rewrite the whole record vector), then keep this diff --git a/src/cli/src/commands/run.rs b/src/cli/src/commands/run.rs index 6361557e..02257e72 100644 --- a/src/cli/src/commands/run.rs +++ b/src/cli/src/commands/run.rs @@ -4,16 +4,50 @@ use std::io::IsTerminal; use std::path::PathBuf; use a3s_box_core::config::{BoxConfig, ResourceConfig, SidecarConfig, TeeConfig}; -use a3s_box_core::event::EventEmitter; -use a3s_box_core::vmm::{parse_signal_name, DEFAULT_SHUTDOWN_TIMEOUT_MS}; -use a3s_box_runtime::VmManager; -use clap::Args; +use a3s_box_core::{ + CreateExecutionRequest, ExecutionGeneration, ExecutionId, ExecutionManager, + ExecutionRecordPolicy, ExecutionRestartPolicy, ExecutionState, OperationId, +}; +use a3s_box_runtime::{LocalExecutionManager, VmLocalExecutionBackend}; +use clap::{Args, ValueEnum}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use super::common::{self, CommonBoxArgs}; +use super::pool::{ + PoolAutoStartConfig, DEFAULT_AUTOSTART_POOL_MAX, DEFAULT_AUTOSTART_POOL_SIZE, DEFAULT_SOCKET, +}; use crate::output::parse_memory; use crate::state::{generate_name, BoxRecord, StateFile}; +use a3s_box_runtime::pool::PoolClientRun; + +const PNPM_CACHE_VOLUME_SPEC: &str = "a3s-cache-pnpm:/a3s-cache/pnpm"; +const PNPM_CONFIG_STORE_ENV: &str = "PNPM_CONFIG_STORE_DIR"; +const PNPM_STORE_ENV: &str = "npm_config_store_dir"; +const PNPM_STORE_DIR: &str = "/a3s-cache/pnpm/store"; +const PNPM_COREPACK_HOME_ENV: &str = "COREPACK_HOME"; +const PNPM_COREPACK_HOME_DIR: &str = "/a3s-cache/pnpm/corepack"; +const PNPM_HOME_ENV: &str = "PNPM_HOME"; +const PNPM_HOME_DIR: &str = "/a3s-cache/pnpm/home"; +const PNPM_NPM_CACHE_ENV: &str = NPM_CACHE_ENV; +const PNPM_NPM_CACHE_DIR: &str = "/a3s-cache/pnpm/npm-cache"; +const PNPM_CONFIG_PREFER_OFFLINE_ENV: &str = "PNPM_CONFIG_PREFER_OFFLINE"; +const PNPM_PREFER_OFFLINE_ENV: &str = NPM_PREFER_OFFLINE_ENV; +const PNPM_PREFER_OFFLINE_VALUE: &str = NPM_PREFER_OFFLINE_VALUE; +const NPM_CACHE_VOLUME_SPEC: &str = "a3s-cache-npm:/a3s-cache/npm"; +const NPM_CACHE_ENV: &str = "npm_config_cache"; +const NPM_CACHE_DIR: &str = "/a3s-cache/npm/cache"; +const NPM_PREFER_OFFLINE_ENV: &str = "npm_config_prefer_offline"; +const NPM_PREFER_OFFLINE_VALUE: &str = "true"; +const COREPACK_DOWNLOAD_PROMPT_ENV: &str = "COREPACK_ENABLE_DOWNLOAD_PROMPT"; +const COREPACK_DOWNLOAD_PROMPT_VALUE: &str = "0"; +const RUN_POOL_SOCKET_ENV: &str = "A3S_BOX_RUN_POOL_SOCKET"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum PackageCache { + Pnpm, + Npm, +} #[derive(Args)] pub struct RunArgs { @@ -28,14 +62,45 @@ pub struct RunArgs { #[arg(short = 'i', long = "interactive")] pub interactive: bool, + /// Close STDIN for the guest command + #[arg(long)] + pub no_stdin: bool, + /// Allocate a pseudo-TTY #[arg(short = 't', long = "tty")] pub tty: bool, + /// Stop the box if the foreground run exceeds this many seconds + #[arg(long, value_name = "SECONDS")] + pub timeout: Option, + /// Automatically remove the box when it stops #[arg(long)] pub rm: bool, + /// Run the command through the warm-pool daemon instead of cold-starting a box. + /// + /// Pool mode is currently for foreground one-shot commands (`--rm`) and + /// supports image/user/workdir/env/volumes/resources/package-cache/timeout. + #[arg(long)] + pub pool: bool, + + /// Unix socket of the warm-pool daemon used by `--pool`. + #[arg(long = "pool-socket", default_value = DEFAULT_SOCKET)] + pub pool_socket: String, + + /// Start a warm-pool daemon on --pool-socket when one is not already running. + #[arg(long = "pool-autostart")] + pub pool_autostart: bool, + + /// Force exec mode against a deferred pool daemon. + #[arg(long = "pool-exec")] + pub pool_exec: bool, + + /// Mount a persistent package-manager cache (pnpm or npm) + #[arg(long = "package-cache", value_enum)] + pub package_cache: Vec, + /// Command to run (override entrypoint) #[arg(last = true)] pub cmd: Vec, @@ -74,25 +139,37 @@ pub struct RunArgs { /// Intermediate state produced by the setup phase, consumed by the run phase. struct RunContext { - vm: VmManager, + manager: LocalExecutionManager, + execution_id: ExecutionId, + generation: ExecutionGeneration, box_id: String, box_dir: PathBuf, name: String, + record: BoxRecord, exec_socket_path: PathBuf, #[cfg_attr(windows, allow(dead_code))] pty_socket_path: PathBuf, - volume_names: Vec, anonymous_volumes: Vec, health_checker: Option>, - stop_signal: i32, - stop_timeout_ms: u64, } pub async fn execute(args: RunArgs) -> Result<(), Box> { validate_run_mode(&args, std::io::stdin().is_terminal()) .map_err(|e| -> Box { e.into() })?; - let ctx = setup_and_boot(&args).await?; + let env_pool_socket = std::env::var(RUN_POOL_SOCKET_ENV).ok(); + if let Some(pool_socket) = selected_pool_socket(&args, env_pool_socket.as_deref()) { + if args.pool_autostart { + super::pool::ensure_pool_daemon_running(&pool_autostart_config_for_run( + &args, + &pool_socket, + )?) + .await?; + } + return execute_pool_run(&args, &pool_socket).await; + } + + let mut ctx = setup_and_boot(&args).await?; crate::audit::record( a3s_box_core::audit::AuditAction::BoxStart, a3s_box_core::audit::AuditOutcome::Success, @@ -100,10 +177,20 @@ pub async fn execute(args: RunArgs) -> Result<(), Box> { &format!("started box from image {}", args.common.image), ); if args.detach { + crate::health::spawn_detached_health_checker(&ctx.record) + .map_err(|error| -> Box { error.into() })?; println!("{}", ctx.box_id); return Ok(()); } + ctx.health_checker = ctx.record.health_check.as_ref().map(|health_check| { + crate::health::spawn_health_checker( + ctx.box_id.clone(), + ctx.exec_socket_path.clone(), + health_check.clone(), + ) + }); + if args.tty { return run_tty(ctx, &args).await; } @@ -115,449 +202,203 @@ fn validate_run_mode(args: &RunArgs, stdin_is_terminal: bool) -> Result<(), &'st if args.detach && args.tty { return Err("Cannot use -t (tty) with -d (detach)"); } + if args.interactive && args.no_stdin { + return Err("Cannot use --interactive with --no-stdin"); + } + if args.timeout.is_some() && args.detach { + return Err("Cannot use --timeout with -d (detach)"); + } + if args.timeout.is_some() && args.tty { + return Err("Cannot use --timeout with -t (tty)"); + } + if matches!(args.timeout, Some(0)) { + return Err("--timeout must be greater than zero seconds"); + } if args.tty && !stdin_is_terminal { return Err("The -t flag requires a terminal (stdin is not a TTY)"); } + if args.pool || args.pool_autostart { + validate_pool_run_mode(args)?; + } Ok(()) } -// ============================================================================ -// Phase 1: Parse args, build config, boot VM, save state -// ============================================================================ - -async fn setup_and_boot(args: &RunArgs) -> Result> { - common::validate_runtime_options(&args.common) - .map_err(|e| -> Box { e.into() })?; - let (restart_policy, max_restart_count) = - crate::state::parse_restart_policy(&args.common.restart) - .map_err(|e| -> Box { e.into() })?; - - let memory_mb = - parse_memory(&args.common.memory).map_err(|e| format!("Invalid --memory: {e}"))?; - let resource_limits = common::build_resource_limits(&args.common)?; - - let log_driver: a3s_box_core::log::LogDriver = args - .log_driver - .parse() - .map_err(|e: String| format!("Invalid --log-driver: {e}"))?; - let log_opts = common::parse_env_vars(&args.log_opts) - .map_err(|e| e.replace("environment variable", "log option"))?; - let log_config = a3s_box_core::log::LogConfig { - driver: log_driver, - options: log_opts, - }; - - let name = args.common.name.clone().unwrap_or_else(generate_name); - let env = common::build_env_map(&args.common)?; - let port_map = common::normalize_port_maps(&args.common.publish) - .map_err(|e| -> Box { e.into() })?; - let labels = common::parse_env_vars(&args.common.labels) - .map_err(|e| e.replace("environment variable", "label"))?; - let entrypoint_override = args - .common - .entrypoint - .as_ref() - .map(|ep| ep.split_whitespace().map(String::from).collect::>()); - let (resolved_volumes, volume_names) = resolve_volumes(&args.common.volumes)?; - - // Parse --shm-size once; reuse for both tmpfs entry and the box record. - let shm_size = match &args.common.shm_size { - Some(s) => { - Some(common::parse_memory_bytes(s).map_err(|e| format!("Invalid --shm-size: {e}"))?) - } - None => None, - }; - let mut tmpfs = args.common.tmpfs.clone(); - if let Some(size_bytes) = shm_size { - tmpfs.push(format!("/dev/shm:size={}", size_bytes)); +fn validate_pool_run_mode(args: &RunArgs) -> Result<(), &'static str> { + match pool_run_mode_error(args) { + Some(error) => Err(error), + None => Ok(()), } +} - let network_mode = match &args.common.network { - Some(name) => a3s_box_core::NetworkMode::Bridge { - network: name.clone(), - }, - None => a3s_box_core::NetworkMode::Tsi, - }; - - // Default (TSI) networking proxies guest sockets to the host, so a container - // cannot reach its own services over the guest loopback. A health check that - // probes localhost would always fail — point the user at bridge networking. - if matches!(network_mode, a3s_box_core::NetworkMode::Tsi) { - if let Some(cmd) = &args.common.health_cmd { - let lc = cmd.to_lowercase(); - if lc.contains("localhost") || lc.contains("127.0.0.1") { - eprintln!( - "warning: the health check probes localhost, but default (TSI) networking \ - cannot reach a container's own services over loopback, so the check will fail. \ - For a working localhost, create and attach a bridge network: \ - `a3s-box network create mynet` then run with `--network mynet`." - ); - } - } +fn pool_run_mode_error(args: &RunArgs) -> Option<&'static str> { + if !args.rm { + return Some("--pool currently requires --rm"); } - - let tee = build_tee_config(args); - - let config = build_box_config( - args, - memory_mb, - resource_limits.clone(), - entrypoint_override.clone(), - resolved_volumes.clone(), - env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), - port_map.clone(), - network_mode.clone(), - tmpfs, - tee, - ) - .map_err(|e| -> Box { e.into() })?; - - let emitter = EventEmitter::new(256); - let mut vm = VmManager::new(config, emitter); - // The shim runs the log processor for the box's lifetime (so detached boxes - // keep logging after this CLI exits). - vm.set_log_config(log_config.clone()); - let box_id = vm.box_id().to_string(); - println!( - "Creating box {} ({})...", - name, - &BoxRecord::make_short_id(&box_id) - ); - - let image_name = args.common.image.clone(); - vm.set_pull_progress_fn(std::sync::Arc::new(move |current, total, digest, size| { - if current == 1 && size > 0 { - println!("Pulling {}...", image_name); - } - let short = &digest[digest.len().saturating_sub(12)..]; - if size < 0 { - // Negative size signals completion - let actual_size = -size; - let size_str = if actual_size >= 1_048_576 { - format!("{:.1} MB", actual_size as f64 / 1_048_576.0) - } else if actual_size >= 1024 { - format!("{:.1} KB", actual_size as f64 / 1024.0) - } else { - format!("{} B", actual_size) - }; - println!(" [{current}/{total}] {short}: {size_str} ✓"); - } else { - // Positive size means downloading - just show once - let size_str = if size >= 1_048_576 { - format!("{:.1} MB", size as f64 / 1_048_576.0) - } else if size >= 1024 { - format!("{:.1} KB", size as f64 / 1024.0) - } else { - format!("{} B", size) - }; - println!(" [{current}/{total}] {short}: Pulling {size_str}..."); - } - })); - - connect_network(args.common.network.as_deref(), &box_id, &name)?; - if let Err(error) = vm.boot().await { - crate::cleanup::cleanup_box_resources( - &box_id, - &volume_names, - args.common.network.as_deref(), - ); - return Err(error.into()); + if args.detach { + return Some("Cannot use --pool with -d (detach)"); } - - let image_health_check = vm - .image_config() - .and_then(|config| config.health_check.clone()); - let image_stop_signal = vm - .image_config() - .and_then(|config| config.stop_signal.clone()); - let health_check = common::effective_health_check(&args.common, image_health_check.as_ref()); - let effective_stop_signal = common::effective_stop_signal( - args.common.stop_signal.as_deref(), - image_stop_signal.as_deref(), - ); - - let pid = vm.pid().await; - let box_dir = a3s_box_core::dirs_home().join("boxes").join(&box_id); - let exec_socket_path = vm - .exec_socket_path() - .map(std::path::PathBuf::from) - .unwrap_or_else(|| box_dir.join("sockets").join("exec.sock")); - let pty_socket_path = vm - .pty_socket_path() - .map(std::path::PathBuf::from) - .unwrap_or_else(|| box_dir.join("sockets").join("pty.sock")); - - let health_status = if health_check.is_some() { - "starting" - } else { - "none" - }; - let record = BoxRecord { - id: box_id.clone(), - short_id: BoxRecord::make_short_id(&box_id), - name: name.clone(), - image: args.common.image.clone(), - status: "running".to_string(), - pid, - pid_start_time: pid.and_then(crate::process::pid_start_time), - cpus: args.common.cpus, - memory_mb, - volumes: resolved_volumes, - env, - cmd: args.cmd.clone(), - entrypoint: entrypoint_override.clone(), - box_dir: box_dir.clone(), - exec_socket_path: exec_socket_path.clone(), - console_log: box_dir.join("logs").join("console.log"), - created_at: chrono::Utc::now(), - started_at: Some(chrono::Utc::now()), - auto_remove: args.rm, - hostname: args.common.hostname.clone(), - user: args.common.user.clone(), - workdir: args.common.workdir.clone(), - restart_policy, - port_map: port_map.clone(), - labels, - stopped_by_user: false, - restart_count: 0, - health_check: health_check.clone(), - healthcheck_disabled: args.common.no_healthcheck, - health_status: health_status.to_string(), - health_retries: 0, - health_last_check: None, - network_mode: network_mode.clone(), - network_name: args.common.network.clone(), - volume_names: volume_names.clone(), - tmpfs: args.common.tmpfs.clone(), - anonymous_volumes: vm.anonymous_volumes().to_vec(), - resource_limits, - log_config: log_config.clone(), - add_host: args.common.add_host.clone(), - platform: args.common.platform.clone(), - init: args.common.init, - read_only: args.common.read_only, - cap_add: args.common.cap_add.clone(), - cap_drop: args.common.cap_drop.clone(), - security_opt: args.common.security_opt.clone(), - privileged: args.common.privileged, - devices: args.common.device.clone(), - gpus: args.common.gpus.clone(), - shm_size, - stop_signal: effective_stop_signal.clone(), - stop_timeout: args.common.stop_timeout, - oom_kill_disable: args.common.oom_kill_disable, - oom_score_adj: args.common.oom_score_adj, - max_restart_count, - exit_code: None, - }; - - let stop_signal = effective_stop_signal - .as_deref() - .map(parse_signal_name) - .unwrap_or(15); // SIGTERM = 15 - let stop_timeout_ms = args - .common - .stop_timeout - .map(|secs| secs.saturating_mul(1000)) // absurd --stop-timeout must not overflow ms - .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT_MS); - let anonymous_volumes = vm.anonymous_volumes().to_vec(); - // Register atomically (load-fresh-under-lock → push → write). A stale - // `load_default()` + `state.add()` (save the in-memory snapshot) is a - // lost-update race: concurrent fork registrations clobber each other's records, - // so a burst of N forks would leave only a fraction registered. - if let Err(error) = StateFile::add_record(record.clone()) { - rollback_booted_setup(&mut vm, &record, stop_signal, stop_timeout_ms, None).await; - return Err(error.into()); + if args.tty { + return Some("Cannot use --pool with -t (tty)"); } - - if let Err(error) = super::diff::create_box_baseline_snapshot(&box_dir) { - tracing::warn!( - box_id = %box_id, - error = %error, - "Failed to create rootfs diff baseline snapshot" - ); + if args.interactive { + return Some("Cannot use --pool with --interactive"); } - - if let Err(error) = super::volume::attach_volumes(&volume_names, &box_id) { - // The record was registered atomically above; un-register it the same way. - let _ = StateFile::remove_record(&record.id); - rollback_booted_setup(&mut vm, &record, stop_signal, stop_timeout_ms, None).await; - return Err(error); + if args.cmd.is_empty() { + return Some("--pool currently requires an explicit command"); } - - let log_dir = box_dir.join("logs"); - if let Err(error) = std::fs::create_dir_all(&log_dir) { - let _ = StateFile::remove_record(&record.id); - rollback_booted_setup(&mut vm, &record, stop_signal, stop_timeout_ms, None).await; - return Err(error.into()); + if has_unsupported_pool_common_options(&args.common) + || args.log_driver != "json-file" + || !args.log_opts.is_empty() + || args.tee + || args.tee_simulate + || args.tee_workload_id.is_some() + || args.sidecar.is_some() + { + return Some("--pool currently supports only image, --rm, command, --user, --workdir, --env, --env-file, --volume, --cpus, --memory, --timeout, and --package-cache"); } - // Log processing now runs in the shim for the box's lifetime; see - // VmManager::set_log_config above. (log_dir is still created so the shim's - // container.json has a home.) - let _ = &log_dir; - - let health_checker = health_check.as_ref().map(|hc| { - crate::health::spawn_health_checker(box_id.clone(), exec_socket_path.clone(), hc.clone()) - }); - - Ok(RunContext { - vm, - box_id, - box_dir, - name, - exec_socket_path, - pty_socket_path, - volume_names, - anonymous_volumes, - health_checker, - stop_signal, - stop_timeout_ms, - }) + None } -/// Build TeeConfig from run args. -fn build_tee_config(args: &RunArgs) -> TeeConfig { - if args.tee || args.tee_simulate { - TeeConfig::SevSnp { - workload_id: args - .tee_workload_id - .clone() - .unwrap_or_else(|| args.common.image.clone()), - generation: Default::default(), - simulate: args.tee_simulate, - } +fn selected_pool_socket(args: &RunArgs, env_socket: Option<&str>) -> Option { + if args.pool || args.pool_autostart { + return Some(args.pool_socket.clone()); + } + let socket = env_socket + .map(str::trim) + .filter(|value| !value.is_empty())?; + if pool_run_mode_error(args).is_none() { + Some(socket.to_string()) } else { - TeeConfig::None + None } } -/// Build BoxConfig from parsed run arguments. -#[allow(clippy::too_many_arguments)] -fn build_box_config( +fn pool_autostart_config_for_run( args: &RunArgs, - memory_mb: u32, - resource_limits: a3s_box_core::config::ResourceLimits, - entrypoint_override: Option>, - resolved_volumes: Vec, - extra_env: Vec<(String, String)>, - port_map: Vec, - network: a3s_box_core::NetworkMode, - tmpfs: Vec, - tee: TeeConfig, -) -> Result { - let (cmd, entrypoint_override) = if args.tty { - ( - vec!["a3s-box-pty-keepalive".to_string()], - Some(interactive_keepalive_entrypoint()), - ) + socket: &str, +) -> Result> { + let memory_mb = + parse_memory(&args.common.memory).map_err(|e| format!("Invalid --memory: {e}"))?; + let prewarm_image = if args.common.volumes.is_empty() + && args.package_cache.is_empty() + && args.common.cpus == 2 + && memory_mb == 512 + { + Some(args.common.image.clone()) } else { - (args.cmd.clone(), entrypoint_override) + None }; - Ok(BoxConfig { - image: args.common.image.clone(), - resources: ResourceConfig { - vcpus: args.common.cpus, - memory_mb, - ..Default::default() - }, - cmd, - entrypoint_override, - user: common::normalize_user_option(args.common.user.as_deref())?, - workdir: args.common.workdir.clone(), - hostname: args.common.hostname.clone(), - volumes: resolved_volumes, - extra_env, - port_map, - dns: args.common.dns.clone(), - add_hosts: args.common.add_host.clone(), - network, - tmpfs, - resource_limits, - tee, - read_only: args.common.read_only, - cap_add: args.common.cap_add.clone(), - cap_drop: args.common.cap_drop.clone(), - security_opt: args.common.security_opt.clone(), - privileged: args.common.privileged, - sidecar: args.sidecar.as_ref().map(|image| SidecarConfig { - image: image.clone(), - vsock_port: args.sidecar_vsock_port, - env: vec![], - }), - // A box without `--rm` survives its stop like a Docker stopped - // container: keep its dir (logs + overlay upper) so `logs`/`start` work - // afterwards. `--rm` boxes and CRI pods stay non-persistent (removed on - // teardown). `rm` force-removes either way (cleanup_removed_box). - persistent: args.common.persistent || !args.rm, - ..Default::default() + Ok(PoolAutoStartConfig { + socket: socket.to_string(), + image: prewarm_image, + size: DEFAULT_AUTOSTART_POOL_SIZE, + max: DEFAULT_AUTOSTART_POOL_MAX, }) } -/// Initial process used only to keep the guest init alive for `run -it`. -/// -/// The actual user command is executed over the PTY after guest control sockets -/// are ready, so short-lived interactive commands do not race the VM shutdown. -fn interactive_keepalive_entrypoint() -> Vec { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "trap 'exit 0' TERM INT; while :; do sleep 3600; done".to_string(), - ] +fn has_unsupported_pool_common_options(common: &CommonBoxArgs) -> bool { + common.name.is_some() + || !common.publish.is_empty() + || !common.dns.is_empty() + || common.entrypoint.is_some() + || common.hostname.is_some() + || common.restart != "no" + || !common.labels.is_empty() + || !common.tmpfs.is_empty() + || common.virtiofs_cache.is_some() + || common.network.is_some() + || common.health_cmd.is_some() + || common.health_interval != 30 + || common.health_timeout != 5 + || common.health_retries != 3 + || common.health_start_period != 0 + || common.pids_limit.is_some() + || common.cpuset_cpus.is_some() + || !common.ulimits.is_empty() + || common.cpu_shares.is_some() + || common.cpu_quota.is_some() + || common.cpu_period.is_some() + || common.memory_reservation.is_some() + || common.memory_swap.is_some() + || !common.add_host.is_empty() + || common.platform.is_some() + || common.init + || common.read_only + || !common.cap_add.is_empty() + || !common.cap_drop.is_empty() + || !common.security_opt.is_empty() + || common.privileged + || !common.device.is_empty() + || common.gpus.is_some() + || common.shm_size.is_some() + || common.stop_signal.is_some() + || common.stop_timeout.is_some() + || common.no_healthcheck + || common.oom_kill_disable + || common.oom_score_adj.is_some() + || common.persistent } -/// Register a network endpoint for the box before booting. -fn connect_network( - net_name: Option<&str>, - box_id: &str, - name: &str, -) -> Result<(), Box> { - let Some(net_name) = net_name else { - return Ok(()); - }; - let net_store = a3s_box_runtime::NetworkStore::default_path()?; - let endpoint = connect_box_to_network(&net_store, net_name, box_id, name)?; - println!( - "Connected to network {} (IP: {})", - net_name, endpoint.ip_address - ); +async fn execute_pool_run(args: &RunArgs, socket: &str) -> Result<(), Box> { + use std::io::Write; + + let output = + a3s_box_runtime::pool::client::run_client(build_pool_client_run(args, socket)?).await?; + + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + if output.exit_code != 0 { + std::process::exit(output.exit_code); + } Ok(()) } -/// Allocate a network endpoint for a box atomically under the store's -/// cross-process lock. -/// -/// A plain `get → connect → update` reads the network outside the lock, so two -/// concurrent `run --network` could allocate the same IP and drop one endpoint -/// (#70 fixed the `network`/`start` paths but not this one). Split out from -/// [`connect_network`] so the lost-update behavior is unit-testable with an -/// explicit store — `NetworkStore::default_path` reads the process-global -/// `A3S_HOME`, which cannot be raced safely across tests. -fn connect_box_to_network( - net_store: &a3s_box_runtime::NetworkStore, - net_name: &str, - box_id: &str, - name: &str, -) -> Result> { - net_store.with_write_lock( - |networks| -> Result> { - let net_config = - networks - .get_mut(net_name) - .ok_or_else(|| -> Box { - format!("network '{}' not found", net_name).into() - })?; - super::network::validate_attachable_network(net_config) - .map_err(|e| -> Box { e.into() })?; - net_config - .connect(box_id, name) - .map_err(|e| -> Box { - format!("Failed to connect to network: {e}").into() - }) - }, - ) +fn build_pool_client_run( + args: &RunArgs, + socket: &str, +) -> Result> { + common::validate_runtime_options(&args.common) + .map_err(|e| -> Box { e.into() })?; + + let memory_mb = + parse_memory(&args.common.memory).map_err(|e| format!("Invalid --memory: {e}"))?; + let mut env = common::build_env_map(&args.common)?; + let mut volume_specs = args.common.volumes.clone(); + apply_package_caches(&args.package_cache, &mut volume_specs, &mut env); + let (resolved_volumes, _) = resolve_volumes(&volume_specs)?; + let mut env_entries: Vec = env + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); + env_entries.sort(); + + Ok(PoolClientRun { + socket: socket.to_string(), + image: Some(args.common.image.clone()), + user: common::normalize_user_option(args.common.user.as_deref()) + .map_err(|e| -> Box { e.into() })?, + workdir: args.common.workdir.clone(), + rootfs: None, + env: env_entries, + volumes: resolved_volumes, + vcpus: args.common.cpus, + memory_mb, + exec: args.pool_exec, + timeout_ns: args.timeout.map(|secs| secs.saturating_mul(1_000_000_000)), + cmd: args.cmd.clone(), + }) } +mod setup; + +use setup::setup_and_boot; +#[cfg(test)] +use setup::{ + build_box_config, build_execution_request, interactive_keepalive_entrypoint, + should_create_diff_baseline, RunRecordPolicy, +}; + // ============================================================================ // Phase 2a: Interactive PTY mode // ============================================================================ @@ -612,13 +453,7 @@ async fn run_tty(mut ctx: RunContext, args: &RunArgs) -> Result<(), Box Result<(), Box; +#[cfg(not(unix))] +type ForegroundTerminateSignal = (); + +#[cfg(unix)] +const FOREGROUND_SIGINT: i32 = libc::SIGINT; +#[cfg(not(unix))] +const FOREGROUND_SIGINT: i32 = 2; +#[cfg(unix)] +const FOREGROUND_SIGTERM: i32 = libc::SIGTERM; +#[cfg(not(unix))] +const FOREGROUND_SIGTERM: i32 = 15; + const FOREGROUND_LOG_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); -const FOREGROUND_LOG_DRAIN_QUIET: std::time::Duration = std::time::Duration::from_millis(300); -const FOREGROUND_LOG_DRAIN_POLL: std::time::Duration = std::time::Duration::from_millis(50); +const FOREGROUND_EXIT_POLL: std::time::Duration = std::time::Duration::from_millis(20); +const FOREGROUND_HEALTH_POLL: std::time::Duration = std::time::Duration::from_millis(500); +const FOREGROUND_LOG_DRAIN_QUIET: std::time::Duration = std::time::Duration::from_millis(50); +const FOREGROUND_LOG_DRAIN_POLL: std::time::Duration = std::time::Duration::from_millis(10); impl ForegroundStopReason { fn stopped_by_user(self) -> bool { - matches!(self, Self::UserInterrupted) + matches!(self, Self::UserInterrupted(_)) + } +} + +#[cfg(unix)] +fn foreground_terminate_signal() -> ForegroundTerminateSignal { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok() +} + +#[cfg(not(unix))] +fn foreground_terminate_signal() -> ForegroundTerminateSignal {} + +#[cfg(unix)] +async fn recv_foreground_terminate(signal: &mut ForegroundTerminateSignal) { + if let Some(signal) = signal { + let _ = signal.recv().await; + } else { + std::future::pending::<()>().await; } } +#[cfg(not(unix))] +async fn recv_foreground_terminate(_signal: &mut ForegroundTerminateSignal) { + std::future::pending::<()>().await; +} + async fn run_foreground( mut ctx: RunContext, args: &RunArgs, ) -> Result<(), Box> { + let foreground_start = std::time::Instant::now(); println!( "Box {} ({}) started. Press Ctrl-C to stop.", ctx.name, @@ -683,48 +558,104 @@ async fn run_foreground( }); let name = ctx.name.clone(); + let mut terminate_signal = foreground_terminate_signal(); + let timeout_at = args + .timeout + .map(|secs| tokio::time::Instant::now() + std::time::Duration::from_secs(secs)); + // Process exit is latency-sensitive for short foreground commands, while a + // VM health check is comparatively expensive and only needs the existing + // 500 ms cadence. Keeping independent timers avoids adding a fixed half + // second to every no-op without polling health more aggressively. + let mut exit_poll = tokio::time::interval(FOREGROUND_EXIT_POLL); + exit_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut health_poll = tokio::time::interval_at( + tokio::time::Instant::now() + FOREGROUND_HEALTH_POLL, + FOREGROUND_HEALTH_POLL, + ); + health_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let stop_reason = loop { tokio::select! { _ = tokio::signal::ctrl_c() => { println!("\nStopping box {}...", name); - break ForegroundStopReason::UserInterrupted; + break ForegroundStopReason::UserInterrupted(FOREGROUND_SIGINT); + } + _ = recv_foreground_terminate(&mut terminate_signal) => { + println!("\nStopping box {} after SIGTERM...", name); + break ForegroundStopReason::UserInterrupted(FOREGROUND_SIGTERM); + } + _ = recv_foreground_timeout(timeout_at) => { + println!("\nStopping box {} after --timeout expired...", name); + break ForegroundStopReason::TimedOut; } - _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => { - if ctx.vm.try_wait_exit().await?.is_some() { + _ = exit_poll.tick() => { + if !managed_process_alive(&ctx) { break ForegroundStopReason::ProcessExited; } - if !ctx.vm.health_check().await.unwrap_or(false) { + } + _ = health_poll.tick() => { + if !managed_runtime_healthy(&ctx).await { break ForegroundStopReason::VmUnhealthy; } } } }; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "foreground.command_execution", + foreground_start.elapsed(), + ); + + let sandbox_natural_exit = + stop_reason == ForegroundStopReason::ProcessExited && ctx.record.isolation.is_sandbox(); + if sandbox_natural_exit { + // The generation-owned worker exits only after crun has closed both + // raw console streams and projected their final records. Once it is + // gone, the terminal tailers can catch up to immutable file lengths + // without an additional writer-quiet grace period. + let structured_log_drain_start = std::time::Instant::now(); + wait_for_sandbox_structured_log_drain(&ctx).await?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "foreground.structured_log_drain", + structured_log_drain_start.elapsed(), + ); + } - wait_for_foreground_log_drain(&[(&console_log, &stdout_pos), (&console_err, &stderr_pos)]) - .await; + let raw_log_drain_start = std::time::Instant::now(); + wait_for_foreground_log_drain( + &[(&console_log, &stdout_pos), (&console_err, &stderr_pos)], + sandbox_natural_exit, + ) + .await; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "foreground.raw_log_drain", + raw_log_drain_start.elapsed(), + ); log_handle.abort(); - // Best-effort teardown: a stop failure (wedged VM, transient store error) - // must not orphan the box's other resources, so every step runs regardless - // of whether an earlier one failed. - if let Some(ref handle) = ctx.health_checker { - handle.abort(); - } - if let Err(error) = ctx - .vm - .destroy_with_options(ctx.stop_signal, ctx.stop_timeout_ms) - .await - { - tracing::warn!(box_id = %ctx.box_id, %error, "Failed to destroy VM on stop; continuing teardown"); + if stop_reason == ForegroundStopReason::ProcessExited && !sandbox_natural_exit { + let structured_log_drain_start = std::time::Instant::now(); + wait_for_sandbox_structured_log_drain(&ctx).await?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "foreground.structured_log_drain", + structured_log_drain_start.elapsed(), + ); } - let exit_code = foreground_exit_code(stop_reason, ctx.vm.exit_code()); - teardown_box_resources( - &ctx, - args.common.network.as_deref(), + + let persisted_exit_code = a3s_box_runtime::rootfs::read_persisted_exit_code(&ctx.box_dir); + let exit_code = foreground_exit_code(stop_reason, persisted_exit_code); + let archive_start = std::time::Instant::now(); + archive_auto_removed_logs(&ctx, args.rm, exit_code, stop_reason.stopped_by_user()); + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "foreground.archive", + archive_start.elapsed(), + ); + cleanup_managed_execution( + &mut ctx, args.rm, exit_code, stop_reason.stopped_by_user(), - ); + stop_reason == ForegroundStopReason::ProcessExited, + ) + .await?; println!( "{}", foreground_completion_message(stop_reason, args.rm, &ctx.name) @@ -739,7 +670,45 @@ async fn run_foreground( Ok(()) } -async fn wait_for_foreground_log_drain(paths: &[(&std::path::Path, &AtomicU64)]) { +async fn wait_for_sandbox_structured_log_drain( + ctx: &RunContext, +) -> Result<(), Box> { + if !ctx.record.isolation.is_sandbox() { + return Ok(()); + } + let box_dir = ctx.box_dir.clone(); + let box_id = ctx.box_id.clone(); + let drained = tokio::task::spawn_blocking(move || { + a3s_box_runtime::vm::reap::wait_for_recorded_sandbox_log_drain( + &box_dir, + &box_id, + std::time::Duration::from_secs(3), + ) + }) + .await + .map_err(|error| format!("Sandbox log drain task failed for {}: {error}", ctx.box_id))??; + if !drained { + return Err(format!( + "Sandbox logs did not finish draining for {}; state was preserved for recovery", + ctx.box_id + ) + .into()); + } + Ok(()) +} + +async fn recv_foreground_timeout(deadline: Option) { + if let Some(deadline) = deadline { + tokio::time::sleep_until(deadline).await; + } else { + std::future::pending::<()>().await; + } +} + +async fn wait_for_foreground_log_drain( + paths: &[(&std::path::Path, &AtomicU64)], + writers_finished: bool, +) { let start = std::time::Instant::now(); let mut last_lens = foreground_log_lengths(paths); let mut quiet_since = None; @@ -752,6 +721,10 @@ async fn wait_for_foreground_log_drain(paths: &[(&std::path::Path, &AtomicU64)]) .zip(lens.iter()) .all(|((_, pos), len)| pos.load(Ordering::Relaxed) >= *len); + if writers_finished && tails_caught_up { + break; + } + if lengths_stable && tails_caught_up { let now = std::time::Instant::now(); match quiet_since { @@ -780,13 +753,43 @@ fn foreground_log_lengths(paths: &[(&std::path::Path, &AtomicU64)]) -> Vec } fn foreground_exit_code(reason: ForegroundStopReason, vm_exit_code: Option) -> Option { - vm_exit_code.or(match reason { - ForegroundStopReason::ProcessExited => None, - ForegroundStopReason::UserInterrupted => Some(130), - ForegroundStopReason::VmUnhealthy => Some(1), + match reason { + ForegroundStopReason::ProcessExited => vm_exit_code, + ForegroundStopReason::UserInterrupted(signal) => vm_exit_code.or(Some(128 + signal)), + ForegroundStopReason::VmUnhealthy => vm_exit_code.or(Some(1)), + ForegroundStopReason::TimedOut => Some(124), + } +} + +fn managed_process_alive(ctx: &RunContext) -> bool { + ctx.record.pid.is_some_and(|pid| { + a3s_box_runtime::is_process_alive_with_identity(pid, ctx.record.pid_start_time) }) } +#[cfg(unix)] +async fn managed_runtime_healthy(ctx: &RunContext) -> bool { + if !managed_process_alive(ctx) { + return false; + } + let probe = async { + let client = a3s_box_runtime::ExecClient::connect(&ctx.exec_socket_path) + .await + .ok()?; + client.heartbeat().await.ok().filter(|ready| *ready) + }; + tokio::time::timeout(std::time::Duration::from_millis(500), probe) + .await + .ok() + .flatten() + .is_some() +} + +#[cfg(not(unix))] +async fn managed_runtime_healthy(ctx: &RunContext) -> bool { + managed_process_alive(ctx) +} + fn foreground_completion_message( reason: ForegroundStopReason, auto_remove: bool, @@ -797,14 +800,20 @@ fn foreground_completion_message( format!("Box {name} exited and was removed.") } (ForegroundStopReason::ProcessExited, false) => format!("Box {name} exited."), - (ForegroundStopReason::UserInterrupted, true) => format!("Box {name} removed."), - (ForegroundStopReason::UserInterrupted, false) => format!("Box {name} stopped."), + (ForegroundStopReason::UserInterrupted(_), true) => format!("Box {name} removed."), + (ForegroundStopReason::UserInterrupted(_), false) => format!("Box {name} stopped."), (ForegroundStopReason::VmUnhealthy, true) => { format!("Box {name} stopped after VM health check failed and was removed.") } (ForegroundStopReason::VmUnhealthy, false) => { format!("Box {name} stopped after VM health check failed.") } + (ForegroundStopReason::TimedOut, true) => { + format!("Box {name} stopped after --timeout expired and was removed.") + } + (ForegroundStopReason::TimedOut, false) => { + format!("Box {name} stopped after --timeout expired.") + } } } @@ -812,24 +821,6 @@ fn foreground_completion_message( // Shared helpers // ============================================================================ -async fn rollback_booted_setup( - vm: &mut VmManager, - record: &BoxRecord, - stop_signal: i32, - stop_timeout_ms: u64, - state: Option<&mut StateFile>, -) { - if let Err(error) = vm.destroy_with_options(stop_signal, stop_timeout_ms).await { - tracing::debug!( - box_id = %record.id, - error = %error, - "Failed to destroy VM while rolling back run setup" - ); - } - - crate::cleanup::cleanup_partial_box_record(record, state); -} - /// Parse health check config from common args. #[cfg(test)] fn parse_health_check(common: &common::CommonBoxArgs) -> Option { @@ -852,81 +843,195 @@ fn resolve_volumes( Ok((resolved, names)) } -/// Disconnect from network if connected. -fn disconnect_network( - box_id: &str, - net_name: Option<&str>, -) -> Result<(), Box> { - if let Some(net_name) = net_name { - let net_store = a3s_box_runtime::NetworkStore::default_path()?; - // Release the endpoint under the lock with a fresh read, so a - // concurrent connect to the same network is not clobbered. - net_store.with_write_lock(|networks| -> Result<(), Box> { - if let Some(net_config) = networks.get_mut(net_name) { - net_config.disconnect(box_id).ok(); +fn apply_package_caches( + caches: &[PackageCache], + volume_specs: &mut Vec, + env: &mut std::collections::HashMap, +) { + for cache in caches { + match cache { + PackageCache::Pnpm => { + ensure_package_cache_volume(volume_specs, PNPM_CACHE_VOLUME_SPEC); + env.entry(PNPM_CONFIG_STORE_ENV.to_string()) + .or_insert_with(|| PNPM_STORE_DIR.to_string()); + env.entry(PNPM_STORE_ENV.to_string()) + .or_insert_with(|| PNPM_STORE_DIR.to_string()); + env.entry(PNPM_COREPACK_HOME_ENV.to_string()) + .or_insert_with(|| PNPM_COREPACK_HOME_DIR.to_string()); + env.entry(PNPM_HOME_ENV.to_string()) + .or_insert_with(|| PNPM_HOME_DIR.to_string()); + env.entry(PNPM_NPM_CACHE_ENV.to_string()) + .or_insert_with(|| PNPM_NPM_CACHE_DIR.to_string()); + env.entry(PNPM_CONFIG_PREFER_OFFLINE_ENV.to_string()) + .or_insert_with(|| PNPM_PREFER_OFFLINE_VALUE.to_string()); + env.entry(PNPM_PREFER_OFFLINE_ENV.to_string()) + .or_insert_with(|| PNPM_PREFER_OFFLINE_VALUE.to_string()); + env.entry(COREPACK_DOWNLOAD_PROMPT_ENV.to_string()) + .or_insert_with(|| COREPACK_DOWNLOAD_PROMPT_VALUE.to_string()); } - Ok(()) - })?; + PackageCache::Npm => { + ensure_package_cache_volume(volume_specs, NPM_CACHE_VOLUME_SPEC); + env.entry(NPM_CACHE_ENV.to_string()) + .or_insert_with(|| NPM_CACHE_DIR.to_string()); + env.entry(NPM_PREFER_OFFLINE_ENV.to_string()) + .or_insert_with(|| NPM_PREFER_OFFLINE_VALUE.to_string()); + } + } + } +} + +fn ensure_package_cache_volume(volume_specs: &mut Vec, volume_spec: &str) { + if !volume_specs.iter().any(|spec| spec == volume_spec) { + volume_specs.push(volume_spec.to_string()); } - Ok(()) } -/// Shared cleanup: abort health checker, destroy VM, detach volumes, disconnect network, update state. +/// Shared cleanup: stop the managed execution and update retained state. #[cfg(not(windows))] async fn cleanup_box( ctx: &mut RunContext, - net_name: Option<&str>, auto_remove: bool, exit_code: Option, -) { +) -> Result<(), Box> { + archive_auto_removed_logs(ctx, auto_remove, exit_code, false); + cleanup_managed_execution(ctx, auto_remove, exit_code, false, false).await +} + +async fn cleanup_managed_execution( + ctx: &mut RunContext, + auto_remove: bool, + exit_code: Option, + stopped_by_user: bool, + natural_exit: bool, +) -> Result<(), Box> { if let Some(ref handle) = ctx.health_checker { handle.abort(); } - if let Err(error) = ctx - .vm - .destroy_with_options(ctx.stop_signal, ctx.stop_timeout_ms) - .await - { - tracing::warn!(box_id = %ctx.box_id, %error, "Failed to destroy VM on stop; continuing teardown"); + + let manager_reconcile_start = std::time::Instant::now(); + let cleanup_result = if natural_exit { + match ctx.manager.inspect(&ctx.execution_id).await { + Ok(status) + if matches!( + status.state, + ExecutionState::Stopped | ExecutionState::Failed + ) => + { + Ok(()) + } + Ok(_) => ctx + .manager + .kill(&ctx.execution_id, ctx.generation) + .await + .map(|_| ()), + Err(_) => ctx + .manager + .kill(&ctx.execution_id, ctx.generation) + .await + .map(|_| ()), + } + } else { + ctx.manager + .kill(&ctx.execution_id, ctx.generation) + .await + .map(|_| ()) + }; + + cleanup_result.map_err(|error| { + format!( + "failed to stop managed execution {}; state was preserved for recovery: {error}", + ctx.box_id + ) + })?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "foreground.manager_reconcile", + manager_reconcile_start.elapsed(), + ); + + let removal_start = std::time::Instant::now(); + if auto_remove { + StateFile::remove_record(&ctx.box_id) + .map_err(|error| format!("failed to remove box {} state: {error}", ctx.box_id))?; + if natural_exit { + // Explicit managed kills remove auto-remove anonymous volumes in the + // backend. Natural exit has no kill path, so the CLI owns cleanup. + crate::cleanup::cleanup_anonymous_volumes(&ctx.anonymous_volumes); + } + if let Err(error) = std::fs::remove_dir_all(&ctx.box_dir) { + if error.kind() != std::io::ErrorKind::NotFound { + return Err(format!( + "removed box {} state but failed to remove {}: {error}", + ctx.box_id, + ctx.box_dir.display() + ) + .into()); + } + } + } else { + StateFile::modify(|s| { + mark_record_stopped(s, &ctx.box_id, exit_code, stopped_by_user); + Ok::<(), std::io::Error>(()) + }) + .map_err(|error| format!("failed to mark box {} stopped: {error}", ctx.box_id))?; } - teardown_box_resources(ctx, net_name, auto_remove, exit_code, false); + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "foreground.removal", + removal_start.elapsed(), + ); + + Ok(()) } -/// Best-effort teardown of a box's host + state resources after its VM has been -/// destroyed. Every step runs even if an earlier one failed, so a wedged VM or a -/// transient store error never orphans the box's other resources — volume -/// attachment, network IP, the `boxes.json` record, and the box directory. -/// -/// State writes go through the atomic primitives (`remove_record`/`modify`) so a -/// concurrent writer (the monitor, or another box's stop) is not clobbered by a -/// stale load → mutate → full-vector save. -fn teardown_box_resources( +fn archive_auto_removed_logs( ctx: &RunContext, - net_name: Option<&str>, auto_remove: bool, exit_code: Option, stopped_by_user: bool, ) { - super::volume::detach_volumes(&ctx.volume_names, &ctx.box_id); - if let Err(error) = disconnect_network(&ctx.box_id, net_name) { - tracing::warn!(box_id = %ctx.box_id, %error, "Failed to disconnect network during teardown"); + if !auto_remove { + return; } - crate::cleanup::cleanup_external_socket_dir(&ctx.box_dir, &ctx.exec_socket_path); - if auto_remove { - crate::cleanup::cleanup_anonymous_volumes(&ctx.anonymous_volumes); - if let Err(error) = StateFile::remove_record(&ctx.box_id) { - tracing::warn!(box_id = %ctx.box_id, %error, "Failed to remove box record during teardown"); + let archive_record = stopped_record_for_archive(&ctx.record, exit_code, stopped_by_user); + match crate::log_archive::archive_removed_logs(&archive_record) { + Ok(Some(path)) => { + if should_print_retained_log_hint(exit_code, stopped_by_user) { + eprintln!( + "Retained logs for removed box {} at {}. View with: a3s-box logs {}", + ctx.name, + path.display(), + ctx.name + ); + } + } + Ok(None) => {} + Err(error) => { + tracing::debug!( + box_id = %ctx.box_id, + error = %error, + "Failed to archive auto-removed box logs" + ); } - let _ = std::fs::remove_dir_all(&ctx.box_dir); - } else if let Err(error) = StateFile::modify(|s| { - mark_record_stopped(s, &ctx.box_id, exit_code, stopped_by_user); - Ok::<(), std::io::Error>(()) - }) { - tracing::warn!(box_id = %ctx.box_id, %error, "Failed to mark box stopped during teardown"); } } +fn should_print_retained_log_hint(exit_code: Option, stopped_by_user: bool) -> bool { + matches!(exit_code, Some(code) if code != 0) && !stopped_by_user +} + +fn stopped_record_for_archive( + record: &BoxRecord, + exit_code: Option, + stopped_by_user: bool, +) -> BoxRecord { + let mut record = record.clone(); + record.status = "stopped".to_string(); + record.pid = None; + record.exit_code = exit_code; + record.stopped_by_user = stopped_by_user; + record +} + fn mark_record_stopped( state: &mut StateFile, box_id: &str, @@ -942,416 +1047,5 @@ fn mark_record_stopped( } #[cfg(test)] -mod tests { - use super::*; - - // Regression for the `run --network` lost-update race: connect_box_to_network - // must allocate a distinct IP per concurrent caller. A get → connect → update - // (the pre-fix code) would dup IPs and drop endpoints. The advisory lock is - // per-open-file-description, so separate FileLock::acquire calls serialize - // even across threads in one process — which exercises the fix in-process. - #[test] - fn concurrent_connect_box_to_network_allocates_distinct_ips() { - use a3s_box_core::network::NetworkConfig; - use a3s_box_runtime::NetworkStore; - use std::collections::HashSet; - use std::sync::Arc; - - let dir = tempfile::tempdir().unwrap(); - let store = Arc::new(NetworkStore::new(dir.path().join("networks.json"))); - store - .create(NetworkConfig::new("dev", "10.88.0.0/24").unwrap()) - .unwrap(); - - let handles: Vec<_> = (0..16) - .map(|i| { - let store = Arc::clone(&store); - std::thread::spawn(move || { - connect_box_to_network(&store, "dev", &format!("box-{i}"), &format!("name-{i}")) - .unwrap() - .ip_address - }) - }) - .collect(); - let ips: HashSet<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); - assert_eq!( - ips.len(), - 16, - "every concurrent connect must get a distinct IP (no lost update)" - ); - } - - // --- build_resource_limits tests (using new struct layout) --- - - fn default_run_args() -> RunArgs { - RunArgs { - common: common::CommonBoxArgs { - image: "test".to_string(), - name: None, - cpus: 2, - memory: "512m".to_string(), - volumes: vec![], - env: vec![], - publish: vec![], - dns: vec![], - entrypoint: None, - hostname: None, - user: None, - workdir: None, - restart: "no".to_string(), - labels: vec![], - tmpfs: vec![], - network: None, - health_cmd: None, - health_interval: 30, - health_timeout: 5, - health_retries: 3, - health_start_period: 0, - pids_limit: None, - cpuset_cpus: None, - ulimits: vec![], - cpu_shares: None, - cpu_quota: None, - cpu_period: None, - memory_reservation: None, - memory_swap: None, - env_file: vec![], - add_host: vec![], - platform: None, - init: false, - read_only: false, - cap_add: vec![], - cap_drop: vec![], - security_opt: vec![], - privileged: false, - device: vec![], - gpus: None, - shm_size: None, - stop_signal: None, - stop_timeout: None, - no_healthcheck: false, - oom_kill_disable: false, - oom_score_adj: None, - persistent: false, - }, - detach: false, - interactive: false, - tty: false, - rm: false, - cmd: vec![], - log_driver: "json-file".to_string(), - log_opts: vec![], - tee: false, - tee_workload_id: None, - tee_simulate: false, - sidecar: None, - sidecar_vsock_port: 4092, - } - } - - #[test] - fn test_build_resource_limits_defaults() { - let args = default_run_args(); - let limits = common::build_resource_limits(&args.common).unwrap(); - assert!(limits.pids_limit.is_none()); - assert!(limits.cpuset_cpus.is_none()); - assert!(limits.cpu_shares.is_none()); - assert!(limits.memory_reservation.is_none()); - assert!(limits.memory_swap.is_none()); - } - - #[test] - fn test_build_resource_limits_with_values() { - let mut args = default_run_args(); - args.common.pids_limit = Some(100); - args.common.cpuset_cpus = Some("0-3".to_string()); - args.common.ulimits = vec!["nofile=1024:4096".to_string()]; - args.common.cpu_shares = Some(512); - args.common.cpu_quota = Some(50000); - args.common.cpu_period = Some(100000); - args.common.memory_reservation = Some("256m".to_string()); - args.common.memory_swap = Some("-1".to_string()); - - let limits = common::build_resource_limits(&args.common).unwrap(); - assert_eq!(limits.pids_limit, Some(100)); - assert_eq!(limits.cpuset_cpus, Some("0-3".to_string())); - assert_eq!(limits.cpu_shares, Some(512)); - assert_eq!(limits.cpu_quota, Some(50000)); - assert_eq!(limits.cpu_period, Some(100000)); - assert_eq!(limits.memory_reservation, Some(256 * 1024 * 1024)); - assert_eq!(limits.memory_swap, Some(-1)); - } - - #[test] - fn test_build_resource_limits_memory_swap_value() { - let mut args = default_run_args(); - args.common.memory_swap = Some("1g".to_string()); - - let limits = common::build_resource_limits(&args.common).unwrap(); - assert_eq!(limits.memory_swap, Some(1024 * 1024 * 1024)); - } - - #[test] - fn test_parse_health_check_none() { - let args = default_run_args(); - assert!(parse_health_check(&args.common).is_none()); - } - - #[test] - fn test_parse_health_check_disabled() { - let mut args = default_run_args(); - args.common.health_cmd = Some("curl localhost".to_string()); - args.common.no_healthcheck = true; - assert!(parse_health_check(&args.common).is_none()); - } - - #[test] - fn test_parse_health_check_configured() { - let mut args = default_run_args(); - args.common.health_cmd = Some("curl localhost".to_string()); - args.common.health_interval = 10; - args.common.health_retries = 5; - let hc = parse_health_check(&args.common).unwrap(); - assert_eq!(hc.cmd, vec!["sh", "-c", "curl localhost"]); - assert_eq!(hc.interval_secs, 10); - assert_eq!(hc.retries, 5); - } - - #[test] - fn test_validate_run_mode_rejects_detached_tty_before_boot() { - let mut args = default_run_args(); - args.detach = true; - args.tty = true; - - let err = validate_run_mode(&args, true).unwrap_err(); - assert!(err.contains("Cannot use -t")); - } - - #[test] - fn test_validate_run_mode_rejects_tty_without_terminal_before_boot() { - let mut args = default_run_args(); - args.tty = true; - - let err = validate_run_mode(&args, false).unwrap_err(); - assert!(err.contains("requires a terminal")); - } - - #[test] - fn test_validate_run_mode_allows_detached_without_tty() { - let mut args = default_run_args(); - args.detach = true; - - assert!(validate_run_mode(&args, false).is_ok()); - } - - #[test] - fn test_build_box_config_uses_keepalive_for_interactive_tty_boot() { - let mut args = default_run_args(); - args.tty = true; - args.cmd = vec!["/bin/echo".to_string(), "hello".to_string()]; - - let config = build_box_config( - &args, - 512, - Default::default(), - None, - vec![], - vec![], - vec![], - a3s_box_core::NetworkMode::Tsi, - vec![], - TeeConfig::None, - ) - .unwrap(); - - assert_eq!(config.cmd, vec!["a3s-box-pty-keepalive"]); - assert_eq!( - config.entrypoint_override, - Some(interactive_keepalive_entrypoint()) - ); - } - - #[test] - fn test_build_box_config_preserves_non_tty_command() { - let mut args = default_run_args(); - args.cmd = vec!["/bin/echo".to_string(), "hello".to_string()]; - let entrypoint = Some(vec!["/custom-entrypoint".to_string()]); - - let config = build_box_config( - &args, - 512, - Default::default(), - entrypoint.clone(), - vec![], - vec![], - vec![], - a3s_box_core::NetworkMode::Tsi, - vec![], - TeeConfig::None, - ) - .unwrap(); - - assert_eq!(config.cmd, args.cmd); - assert_eq!(config.entrypoint_override, entrypoint); - } - - #[test] - fn test_mark_record_stopped_persists_exit_context() { - let record = crate::test_helpers::fixtures::make_record( - "550e8400-e29b-41d4-a716-446655440000", - "run-exit", - "running", - Some(1234), - ); - let (_tmp, mut state) = crate::test_helpers::fixtures::setup_state(vec![record]); - - mark_record_stopped( - &mut state, - "550e8400-e29b-41d4-a716-446655440000", - Some(42), - true, - ); - - let record = state - .find_by_id("550e8400-e29b-41d4-a716-446655440000") - .unwrap(); - assert_eq!(record.status, "stopped"); - assert_eq!(record.pid, None); - assert_eq!(record.exit_code, Some(42)); - assert!(record.stopped_by_user); - } - - #[test] - fn test_foreground_exit_code_preserves_vm_code() { - assert_eq!( - foreground_exit_code(ForegroundStopReason::UserInterrupted, Some(143)), - Some(143) - ); - assert_eq!( - foreground_exit_code(ForegroundStopReason::VmUnhealthy, Some(2)), - Some(2) - ); - } - - #[test] - fn test_foreground_exit_code_has_deterministic_fallbacks() { - assert_eq!( - foreground_exit_code(ForegroundStopReason::ProcessExited, None), - None - ); - assert_eq!( - foreground_exit_code(ForegroundStopReason::UserInterrupted, None), - Some(130) - ); - assert_eq!( - foreground_exit_code(ForegroundStopReason::VmUnhealthy, None), - Some(1) - ); - } - - #[test] - fn test_foreground_stop_reason_user_flag() { - assert!(ForegroundStopReason::UserInterrupted.stopped_by_user()); - assert!(!ForegroundStopReason::ProcessExited.stopped_by_user()); - assert!(!ForegroundStopReason::VmUnhealthy.stopped_by_user()); - } - - #[test] - fn test_foreground_completion_messages() { - assert_eq!( - foreground_completion_message(ForegroundStopReason::ProcessExited, true, "box"), - "Box box exited and was removed." - ); - assert_eq!( - foreground_completion_message(ForegroundStopReason::UserInterrupted, false, "box"), - "Box box stopped." - ); - assert_eq!( - foreground_completion_message(ForegroundStopReason::VmUnhealthy, true, "box"), - "Box box stopped after VM health check failed and was removed." - ); - } - - #[test] - fn test_build_box_config_passes_security_options() { - let mut args = default_run_args(); - args.common.cap_add = vec!["NET_ADMIN".to_string()]; - args.common.cap_drop = vec!["NET_RAW".to_string()]; - args.common.security_opt = vec!["seccomp=unconfined".to_string()]; - args.common.privileged = true; - - let config = build_box_config( - &args, - 512, - a3s_box_core::config::ResourceLimits::default(), - None, - vec![], - vec![], - vec![], - a3s_box_core::NetworkMode::Tsi, - vec![], - TeeConfig::None, - ) - .unwrap(); - - assert_eq!(config.cap_add, vec!["NET_ADMIN"]); - assert_eq!(config.cap_drop, vec!["NET_RAW"]); - assert_eq!(config.security_opt, vec!["seccomp=unconfined"]); - assert!(config.privileged); - } - - #[test] - fn test_build_box_config_passes_user_and_workdir() { - let mut args = default_run_args(); - args.common.user = Some("root:root".to_string()); - args.common.workdir = Some("/app".to_string()); - - let config = build_box_config( - &args, - 512, - a3s_box_core::config::ResourceLimits::default(), - None, - vec![], - vec![], - vec![], - a3s_box_core::NetworkMode::Tsi, - vec![], - TeeConfig::None, - ) - .unwrap(); - - assert_eq!(config.user.as_deref(), Some("0:0")); - assert_eq!(config.workdir.as_deref(), Some("/app")); - } - - #[test] - fn test_build_box_config_passes_hostname_and_add_hosts() { - let mut args = default_run_args(); - args.common.hostname = Some("web".to_string()); - args.common.add_host = vec!["db.local:10.88.0.10".to_string()]; - - let config = build_box_config( - &args, - 512, - a3s_box_core::config::ResourceLimits::default(), - None, - vec![], - vec![], - vec![], - a3s_box_core::NetworkMode::Tsi, - vec![], - TeeConfig::None, - ) - .unwrap(); - - assert_eq!(config.hostname.as_deref(), Some("web")); - assert_eq!(config.add_hosts, vec!["db.local:10.88.0.10"]); - } - - #[test] - fn test_resolve_volumes_empty() { - let (resolved, names) = resolve_volumes(&[]).unwrap(); - assert!(resolved.is_empty()); - assert!(names.is_empty()); - } -} +#[path = "run/tests.rs"] +mod tests; diff --git a/src/cli/src/commands/run/request_tests.rs b/src/cli/src/commands/run/request_tests.rs new file mode 100644 index 00000000..f056e034 --- /dev/null +++ b/src/cli/src/commands/run/request_tests.rs @@ -0,0 +1,163 @@ +use super::*; + +#[test] +fn test_build_box_config_selects_requested_sandbox_isolation() { + let mut args = default_run_args(); + args.common.isolation = Some(common::IsolationArg::Sandbox); + + let config = build_box_config( + &args, + 512, + Default::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + + assert_eq!(config.isolation, a3s_box_core::ExecutionIsolation::Sandbox); +} + +#[test] +fn test_managed_run_request_preserves_complete_caller_intent() { + let mut args = default_run_args(); + args.common.image = "registry.example/worker:v2".to_string(); + args.common.cpus = 6; + args.common.dns = vec!["1.1.1.1".to_string(), "8.8.8.8".to_string()]; + args.common.hostname = Some("worker".to_string()); + args.common.user = Some("root".to_string()); + args.common.workdir = Some("/workspace".to_string()); + args.common.virtiofs_cache = Some(common::VirtiofsCacheMode::Always); + args.common.tmpfs = vec!["/tmp:size=16m".to_string()]; + args.common.add_host = vec!["db.internal:10.0.0.2".to_string()]; + args.common.read_only = true; + args.common.cap_add = vec!["NET_ADMIN".to_string()]; + args.common.cap_drop = vec!["NET_RAW".to_string()]; + args.common.security_opt = vec!["no-new-privileges".to_string()]; + args.common.privileged = true; + args.common.pids_limit = Some(128); + args.common.cpuset_cpus = Some("0-2".to_string()); + args.common.cpu_shares = Some(1024); + args.common.memory_reservation = Some("512m".to_string()); + args.common.memory_swap = Some("2g".to_string()); + args.common.platform = Some("linux/arm64".to_string()); + args.common.init = true; + args.common.device = vec!["/dev/fuse:/dev/fuse".to_string()]; + args.common.gpus = Some("all".to_string()); + args.common.stop_timeout = Some(9); + args.common.oom_kill_disable = true; + args.common.oom_score_adj = Some(125); + args.common.persistent = true; + args.rm = true; + args.cmd = vec!["python".to_string(), "worker.py".to_string()]; + args.sidecar = Some("registry.example/proxy:v1".to_string()); + args.sidecar_vsock_port = 5001; + + let resource_limits = common::build_resource_limits(&args.common).unwrap(); + let tee = TeeConfig::SevSnp { + workload_id: "worker-v2".to_string(), + generation: Default::default(), + simulate: true, + }; + let config = build_box_config( + &args, + 4096, + resource_limits.clone(), + Some(vec!["/entrypoint".to_string()]), + vec!["/host/workspace:/workspace:rw".to_string()], + vec![("MODE".to_string(), "test".to_string())], + vec!["8080:80".to_string()], + a3s_box_core::NetworkMode::Bridge { + network: "dev".to_string(), + }, + args.common.tmpfs.clone(), + tee.clone(), + ) + .unwrap(); + let health_check = crate::state::HealthCheck { + cmd: vec!["test".to_string(), "-f".to_string(), "/ready".to_string()], + interval_secs: 11, + timeout_secs: 3, + retries: 7, + start_period_secs: 5, + }; + let log_config = a3s_box_core::log::LogConfig { + driver: a3s_box_core::log::LogDriver::None, + options: std::collections::HashMap::from([("tag".to_string(), "worker".to_string())]), + }; + let operation_id = OperationId::new("cli-run-request-test").unwrap(); + let labels = std::collections::BTreeMap::from([("team".to_string(), "sandbox".to_string())]); + let request = build_execution_request( + &args, + &operation_id, + config, + labels.clone(), + RunRecordPolicy { + name: "managed-worker".to_string(), + restart_policy: ExecutionRestartPolicy::OnFailure, + max_restart_count: 4, + health_check: Some(health_check.clone()), + log_config: log_config.clone(), + volume_names: vec!["workspace".to_string()], + shm_size: Some(64 * 1024 * 1024), + stop_signal: Some("SIGINT".to_string()), + }, + ); + + assert_eq!(request.external_sandbox_id, operation_id.as_str()); + assert_eq!(request.labels, labels); + assert_eq!(request.config.image, "registry.example/worker:v2"); + assert_eq!(request.config.resources.vcpus, 6); + assert_eq!(request.config.resources.memory_mb, 4096); + assert_eq!( + serde_json::to_value(&request.config.resource_limits).unwrap(), + serde_json::to_value(&resource_limits).unwrap() + ); + assert_eq!(request.config.cmd, vec!["python", "worker.py"]); + assert_eq!( + request.config.entrypoint_override, + Some(vec!["/entrypoint".to_string()]) + ); + assert_eq!( + request.config.extra_env, + vec![("MODE".to_string(), "test".to_string())] + ); + assert_eq!(request.config.dns, vec!["1.1.1.1", "8.8.8.8"]); + assert_eq!(request.config.cap_add, vec!["NET_ADMIN"]); + assert_eq!(request.config.cap_drop, vec!["NET_RAW"]); + assert_eq!(request.config.security_opt, vec!["no-new-privileges"]); + assert!(request.config.privileged); + assert_eq!(request.config.tee, tee); + assert_eq!( + request + .config + .sidecar + .as_ref() + .map(|sidecar| (sidecar.image.as_str(), sidecar.vsock_port)), + Some(("registry.example/proxy:v1", 5001)) + ); + assert!(request.config.persistent); + assert_eq!(request.policy.name.as_deref(), Some("managed-worker")); + assert!(request.policy.auto_remove); + assert_eq!( + request.policy.restart_policy, + ExecutionRestartPolicy::OnFailure + ); + assert_eq!(request.policy.max_restart_count, 4); + assert_eq!(request.policy.health_check, Some(health_check)); + assert_eq!(request.policy.log_config, log_config); + assert_eq!(request.policy.volume_names, vec!["workspace"]); + assert_eq!(request.policy.platform.as_deref(), Some("linux/arm64")); + assert!(request.policy.init); + assert_eq!(request.policy.devices, vec!["/dev/fuse:/dev/fuse"]); + assert_eq!(request.policy.gpus.as_deref(), Some("all")); + assert_eq!(request.policy.shm_size, Some(64 * 1024 * 1024)); + assert_eq!(request.policy.stop_signal.as_deref(), Some("SIGINT")); + assert_eq!(request.policy.stop_timeout, Some(9)); + assert!(request.policy.oom_kill_disable); + assert_eq!(request.policy.oom_score_adj, Some(125)); +} diff --git a/src/cli/src/commands/run/setup.rs b/src/cli/src/commands/run/setup.rs new file mode 100644 index 00000000..045c5042 --- /dev/null +++ b/src/cli/src/commands/run/setup.rs @@ -0,0 +1,423 @@ +use super::*; + +pub(super) struct RunRecordPolicy { + pub(super) name: String, + pub(super) restart_policy: ExecutionRestartPolicy, + pub(super) max_restart_count: u32, + pub(super) health_check: Option, + pub(super) log_config: a3s_box_core::log::LogConfig, + pub(super) volume_names: Vec, + pub(super) shm_size: Option, + pub(super) stop_signal: Option, +} + +// ============================================================================ +// Phase 1: Parse args, build config, boot VM, save state +// ============================================================================ + +pub(super) async fn setup_and_boot( + args: &RunArgs, +) -> Result> { + let create_start = std::time::Instant::now(); + common::validate_runtime_options(&args.common) + .map_err(|e| -> Box { e.into() })?; + let (restart_policy, max_restart_count) = + crate::state::parse_restart_policy(&args.common.restart) + .map_err(|e| -> Box { e.into() })?; + let restart_policy = execution_restart_policy(&restart_policy)?; + + let memory_mb = + parse_memory(&args.common.memory).map_err(|e| format!("Invalid --memory: {e}"))?; + let resource_limits = common::build_resource_limits(&args.common)?; + + let log_driver: a3s_box_core::log::LogDriver = args + .log_driver + .parse() + .map_err(|e: String| format!("Invalid --log-driver: {e}"))?; + let log_opts = common::parse_env_vars(&args.log_opts) + .map_err(|e| e.replace("environment variable", "log option"))?; + let log_config = a3s_box_core::log::LogConfig { + driver: log_driver, + options: log_opts, + }; + + let name = args.common.name.clone().unwrap_or_else(generate_name); + let mut env = common::build_env_map(&args.common)?; + let port_map = common::normalize_port_maps(&args.common.publish) + .map_err(|e| -> Box { e.into() })?; + let labels = common::parse_env_vars(&args.common.labels) + .map_err(|e| e.replace("environment variable", "label"))? + .into_iter() + .collect(); + let entrypoint_override = args + .common + .entrypoint + .as_ref() + .map(|ep| ep.split_whitespace().map(String::from).collect::>()); + let mut volume_specs = args.common.volumes.clone(); + apply_package_caches(&args.package_cache, &mut volume_specs, &mut env); + let (resolved_volumes, volume_names) = resolve_volumes(&volume_specs)?; + + // Parse --shm-size once; reuse for both tmpfs entry and the box record. + let shm_size = match &args.common.shm_size { + Some(s) => { + Some(common::parse_memory_bytes(s).map_err(|e| format!("Invalid --shm-size: {e}"))?) + } + None => None, + }; + let network_mode = match &args.common.network { + Some(name) => a3s_box_core::NetworkMode::Bridge { + network: name.clone(), + }, + None => a3s_box_core::NetworkMode::Tsi, + }; + + // Default (TSI) networking proxies guest sockets to the host, so a container + // cannot reach its own services over the guest loopback. A health check that + // probes localhost would always fail — point the user at bridge networking. + if matches!(network_mode, a3s_box_core::NetworkMode::Tsi) { + if let Some(cmd) = &args.common.health_cmd { + let lc = cmd.to_lowercase(); + if lc.contains("localhost") || lc.contains("127.0.0.1") { + eprintln!( + "warning: the health check probes localhost, but default (TSI) networking \ + cannot reach a container's own services over loopback, so the check will fail. \ + For a working localhost, create and attach a bridge network: \ + `a3s-box network create mynet` then run with `--network mynet`." + ); + } + } + } + + let tee = build_tee_config(args); + + let config = build_box_config( + args, + memory_mb, + resource_limits.clone(), + entrypoint_override.clone(), + resolved_volumes.clone(), + env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + port_map.clone(), + network_mode.clone(), + args.common.tmpfs.clone(), + tee, + ) + .map_err(|e| -> Box { e.into() })?; + a3s_box_core::resolve_execution(&config)?; + + // Freeze image-defined lifecycle defaults into the managed creation + // request. Pulling is cache-first, and happens only after the pure backend + // compatibility check above, so an invalid Sandbox request has no registry + // or runtime side effects. + let pull_progress_fn = pull_progress_callback(args.common.image.clone()); + let image_config_start = std::time::Instant::now(); + let image_config = pull_image_config(args, std::sync::Arc::clone(&pull_progress_fn)).await?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "cli.image_config", + image_config_start.elapsed(), + ); + let health_check = + common::effective_health_check(&args.common, image_config.health_check.as_ref()); + let effective_stop_signal = common::effective_stop_signal( + args.common.stop_signal.as_deref(), + image_config.stop_signal.as_deref(), + ); + + let operation_id = OperationId::new(format!("cli-run-{}", uuid::Uuid::new_v4()))?; + let request = build_execution_request( + args, + &operation_id, + config, + labels, + RunRecordPolicy { + name: name.clone(), + restart_policy, + max_restart_count, + health_check, + log_config, + volume_names, + shm_size, + stop_signal: effective_stop_signal, + }, + ); + let home = a3s_box_core::dirs_home(); + let backend = VmLocalExecutionBackend::new(&home).with_pull_progress_fn(pull_progress_fn); + let manager = + LocalExecutionManager::new(home.join("boxes.json"), &home, std::sync::Arc::new(backend)); + let reserve_start = std::time::Instant::now(); + let reservation = manager.create(request, &operation_id).await?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase("cli.reserve", reserve_start.elapsed()); + let execution_id = reservation.execution_id.clone(); + let box_id = execution_id.to_string(); + println!( + "Creating box {} ({})...", + name, + BoxRecord::make_short_id(&box_id) + ); + let runtime_start = std::time::Instant::now(); + let lease = match manager.start(&execution_id, reservation.generation).await { + Ok(lease) => lease, + Err(error) => { + cleanup_failed_managed_run(&box_id); + return Err(error.into()); + } + }; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "cli.runtime_start", + runtime_start.elapsed(), + ); + // A short-lived command can exit between `start` and this reload. Use the + // side-effect-free snapshot so legacy PID reconciliation cannot auto-remove + // the just-created managed record before foreground cleanup observes it. + let record = StateFile::load_readonly()? + .find_by_id(&box_id) + .cloned() + .ok_or_else(|| format!("managed run {box_id} disappeared after startup"))?; + let box_dir = record.box_dir.clone(); + let exec_socket_path = record.exec_socket_path.clone(); + let pty_socket_path = exec_socket_path + .parent() + .map(|parent| parent.join("pty.sock")) + .unwrap_or_else(|| box_dir.join("sockets/pty.sock")); + let anonymous_volumes = record.anonymous_volumes.clone(); + + if should_create_diff_baseline(args) { + if let Err(error) = crate::commands::diff::create_box_baseline_snapshot(&box_dir) { + tracing::warn!( + box_id = %box_id, + error = %error, + "Failed to create rootfs diff baseline snapshot" + ); + } + } else { + tracing::debug!( + box_id = %box_id, + "Skipping rootfs diff baseline snapshot for foreground --rm box" + ); + } + + let context = RunContext { + manager, + execution_id, + generation: lease.generation, + box_id, + box_dir, + name, + record, + exec_socket_path, + pty_socket_path, + anonymous_volumes, + health_checker: None, + }; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "cli.create_start", + create_start.elapsed(), + ); + Ok(context) +} + +fn pull_progress_callback(image_name: String) -> a3s_box_runtime::PullProgressFn { + std::sync::Arc::new(move |current, total, digest, size| { + if current == 1 && size > 0 { + println!("Pulling {}...", image_name); + } + let short = &digest[digest.len().saturating_sub(12)..]; + if size < 0 { + // Negative size signals completion + let actual_size = -size; + let size_str = if actual_size >= 1_048_576 { + format!("{:.1} MB", actual_size as f64 / 1_048_576.0) + } else if actual_size >= 1024 { + format!("{:.1} KB", actual_size as f64 / 1024.0) + } else { + format!("{} B", actual_size) + }; + println!(" [{current}/{total}] {short}: {size_str} ✓"); + } else { + // Positive size means downloading - just show once + let size_str = if size >= 1_048_576 { + format!("{:.1} MB", size as f64 / 1_048_576.0) + } else if size >= 1024 { + format!("{:.1} KB", size as f64 / 1024.0) + } else { + format!("{} B", size) + }; + println!(" [{current}/{total}] {short}: Pulling {size_str}..."); + } + }) +} + +async fn pull_image_config( + args: &RunArgs, + progress: a3s_box_runtime::PullProgressFn, +) -> Result> { + let store = std::sync::Arc::new(crate::commands::open_image_store()?); + let reference = a3s_box_runtime::ImageReference::parse(&args.common.image)?; + let auth = a3s_box_runtime::RegistryAuth::from_credential_store(&reference.registry); + let puller = + a3s_box_runtime::ImagePuller::with_platform(store, auth, args.common.platform.clone()) + .with_progress_fn(progress); + Ok(puller.pull(&args.common.image).await?.config().clone()) +} + +fn cleanup_failed_managed_run(box_id: &str) { + let Ok(state) = StateFile::load_default() else { + return; + }; + let Some(record) = state.find_by_id(box_id).cloned() else { + return; + }; + if let Err(error) = crate::cleanup::cleanup_removed_box(&record) { + tracing::warn!(box_id, %error, "Failed to roll back managed run startup"); + return; + } + if let Err(error) = StateFile::remove_record(box_id) { + tracing::warn!(box_id, %error, "Failed to remove rolled-back managed run record"); + } +} + +fn execution_restart_policy(value: &str) -> Result { + match value { + "no" => Ok(ExecutionRestartPolicy::No), + "always" => Ok(ExecutionRestartPolicy::Always), + "on-failure" => Ok(ExecutionRestartPolicy::OnFailure), + "unless-stopped" => Ok(ExecutionRestartPolicy::UnlessStopped), + other => Err(format!("Invalid normalized restart policy: {other}")), + } +} + +pub(super) fn build_execution_request( + args: &RunArgs, + operation_id: &OperationId, + config: BoxConfig, + labels: std::collections::BTreeMap, + record: RunRecordPolicy, +) -> CreateExecutionRequest { + CreateExecutionRequest { + external_sandbox_id: operation_id.as_str().to_string(), + config, + labels, + policy: ExecutionRecordPolicy { + name: Some(record.name), + auto_remove: args.rm, + restart_policy: record.restart_policy, + max_restart_count: record.max_restart_count, + health_check: record.health_check, + healthcheck_disabled: args.common.no_healthcheck, + log_config: record.log_config, + volume_names: record.volume_names, + platform: args.common.platform.clone(), + init: args.common.init, + devices: args.common.device.clone(), + gpus: args.common.gpus.clone(), + shm_size: record.shm_size, + stop_signal: record.stop_signal, + stop_timeout: args.common.stop_timeout, + oom_kill_disable: args.common.oom_kill_disable, + oom_score_adj: args.common.oom_score_adj, + }, + rootfs_snapshot_id: None, + } +} + +/// Build TeeConfig from run args. +fn build_tee_config(args: &RunArgs) -> TeeConfig { + if args.tee || args.tee_simulate { + TeeConfig::SevSnp { + workload_id: args + .tee_workload_id + .clone() + .unwrap_or_else(|| args.common.image.clone()), + generation: Default::default(), + simulate: args.tee_simulate, + } + } else { + TeeConfig::None + } +} + +/// Build BoxConfig from parsed run arguments. +#[allow(clippy::too_many_arguments)] +pub(super) fn build_box_config( + args: &RunArgs, + memory_mb: u32, + resource_limits: a3s_box_core::config::ResourceLimits, + entrypoint_override: Option>, + resolved_volumes: Vec, + extra_env: Vec<(String, String)>, + port_map: Vec, + network: a3s_box_core::NetworkMode, + tmpfs: Vec, + tee: TeeConfig, +) -> Result { + let (cmd, entrypoint_override) = if args.tty { + ( + vec!["a3s-box-pty-keepalive".to_string()], + Some(interactive_keepalive_entrypoint()), + ) + } else { + (args.cmd.clone(), entrypoint_override) + }; + + Ok(BoxConfig { + isolation: common::execution_isolation(&args.common), + image: args.common.image.clone(), + resources: ResourceConfig { + vcpus: args.common.cpus, + memory_mb, + ..Default::default() + }, + cmd, + stdin_open: args.interactive && !args.no_stdin, + entrypoint_override, + user: common::normalize_user_option(args.common.user.as_deref())?, + workdir: args.common.workdir.clone(), + hostname: args.common.hostname.clone(), + volumes: resolved_volumes, + virtiofs_cache: args + .common + .virtiofs_cache + .map(|mode| mode.as_guest_value().to_string()), + extra_env, + port_map, + dns: args.common.dns.clone(), + add_hosts: args.common.add_host.clone(), + network, + tmpfs, + resource_limits, + tee, + read_only: args.common.read_only, + cap_add: args.common.cap_add.clone(), + cap_drop: args.common.cap_drop.clone(), + security_opt: args.common.security_opt.clone(), + privileged: args.common.privileged, + sidecar: args.sidecar.as_ref().map(|image| SidecarConfig { + image: image.clone(), + vsock_port: args.sidecar_vsock_port, + env: vec![], + }), + // A box without `--rm` survives its stop like a Docker stopped + // container: keep its dir (logs + overlay upper) so `logs`/`start` work + // afterwards. `--rm` boxes and CRI pods stay non-persistent (removed on + // teardown). `rm` force-removes either way (cleanup_removed_box). + persistent: args.common.persistent || !args.rm, + ..Default::default() + }) +} + +pub(super) fn should_create_diff_baseline(args: &RunArgs) -> bool { + !args.rm || args.detach +} + +/// Initial process used only to keep the guest init alive for `run -it`. +/// +/// The actual user command is executed over the PTY after guest control sockets +/// are ready, so short-lived interactive commands do not race the VM shutdown. +pub(super) fn interactive_keepalive_entrypoint() -> Vec { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "trap 'exit 0' TERM INT; while :; do sleep 3600; done".to_string(), + ] +} diff --git a/src/cli/src/commands/run/tests.rs b/src/cli/src/commands/run/tests.rs new file mode 100644 index 00000000..57428bf1 --- /dev/null +++ b/src/cli/src/commands/run/tests.rs @@ -0,0 +1,969 @@ +use super::*; + +// --- build_resource_limits tests (using new struct layout) --- + +fn default_run_args() -> RunArgs { + RunArgs { + common: common::CommonBoxArgs { + image: "test".to_string(), + isolation: None, + name: None, + cpus: 2, + memory: "512m".to_string(), + volumes: vec![], + env: vec![], + publish: vec![], + dns: vec![], + entrypoint: None, + hostname: None, + user: None, + workdir: None, + restart: "no".to_string(), + labels: vec![], + tmpfs: vec![], + virtiofs_cache: None, + network: None, + health_cmd: None, + health_interval: 30, + health_timeout: 5, + health_retries: 3, + health_start_period: 0, + pids_limit: None, + cpuset_cpus: None, + ulimits: vec![], + cpu_shares: None, + cpu_quota: None, + cpu_period: None, + memory_reservation: None, + memory_swap: None, + env_file: vec![], + add_host: vec![], + platform: None, + init: false, + read_only: false, + cap_add: vec![], + cap_drop: vec![], + security_opt: vec![], + privileged: false, + device: vec![], + gpus: None, + shm_size: None, + stop_signal: None, + stop_timeout: None, + no_healthcheck: false, + oom_kill_disable: false, + oom_score_adj: None, + persistent: false, + }, + detach: false, + interactive: false, + no_stdin: false, + tty: false, + timeout: None, + rm: false, + pool: false, + pool_socket: DEFAULT_SOCKET.to_string(), + pool_autostart: false, + pool_exec: false, + package_cache: vec![], + cmd: vec![], + log_driver: "json-file".to_string(), + log_opts: vec![], + tee: false, + tee_workload_id: None, + tee_simulate: false, + sidecar: None, + sidecar_vsock_port: 4092, + } +} + +fn default_pool_run_args() -> RunArgs { + let mut args = default_run_args(); + args.pool = true; + args.rm = true; + args.cmd = vec!["echo".to_string(), "hello".to_string()]; + args +} + +#[test] +fn test_foreground_auto_remove_skips_diff_baseline() { + let mut args = default_run_args(); + args.rm = true; + + assert!(!should_create_diff_baseline(&args)); +} + +#[test] +fn test_detached_auto_remove_keeps_diff_baseline_while_running() { + let mut args = default_run_args(); + args.rm = true; + args.detach = true; + + assert!(should_create_diff_baseline(&args)); +} + +#[test] +fn test_persistent_run_keeps_diff_baseline() { + let args = default_run_args(); + + assert!(should_create_diff_baseline(&args)); +} + +#[test] +fn test_build_resource_limits_defaults() { + let args = default_run_args(); + let limits = common::build_resource_limits(&args.common).unwrap(); + assert!(limits.pids_limit.is_none()); + assert!(limits.cpuset_cpus.is_none()); + assert!(limits.cpu_shares.is_none()); + assert!(limits.memory_reservation.is_none()); + assert!(limits.memory_swap.is_none()); +} + +#[test] +fn test_build_resource_limits_with_values() { + let mut args = default_run_args(); + args.common.pids_limit = Some(100); + args.common.cpuset_cpus = Some("0-3".to_string()); + args.common.ulimits = vec!["nofile=1024:4096".to_string()]; + args.common.cpu_shares = Some(512); + args.common.cpu_quota = Some(50000); + args.common.cpu_period = Some(100000); + args.common.memory_reservation = Some("256m".to_string()); + args.common.memory_swap = Some("-1".to_string()); + + let limits = common::build_resource_limits(&args.common).unwrap(); + assert_eq!(limits.pids_limit, Some(100)); + assert_eq!(limits.cpuset_cpus, Some("0-3".to_string())); + assert_eq!(limits.cpu_shares, Some(512)); + assert_eq!(limits.cpu_quota, Some(50000)); + assert_eq!(limits.cpu_period, Some(100000)); + assert_eq!(limits.memory_reservation, Some(256 * 1024 * 1024)); + assert_eq!(limits.memory_swap, Some(-1)); +} + +#[test] +fn test_build_resource_limits_memory_swap_value() { + let mut args = default_run_args(); + args.common.memory_swap = Some("1g".to_string()); + + let limits = common::build_resource_limits(&args.common).unwrap(); + assert_eq!(limits.memory_swap, Some(1024 * 1024 * 1024)); +} + +#[test] +fn test_parse_health_check_none() { + let args = default_run_args(); + assert!(parse_health_check(&args.common).is_none()); +} + +#[test] +fn test_parse_health_check_disabled() { + let mut args = default_run_args(); + args.common.health_cmd = Some("curl localhost".to_string()); + args.common.no_healthcheck = true; + assert!(parse_health_check(&args.common).is_none()); +} + +#[test] +fn test_parse_health_check_configured() { + let mut args = default_run_args(); + args.common.health_cmd = Some("curl localhost".to_string()); + args.common.health_interval = 10; + args.common.health_retries = 5; + let hc = parse_health_check(&args.common).unwrap(); + assert_eq!(hc.cmd, vec!["sh", "-c", "curl localhost"]); + assert_eq!(hc.interval_secs, 10); + assert_eq!(hc.retries, 5); +} + +#[test] +fn test_validate_run_mode_rejects_detached_tty_before_boot() { + let mut args = default_run_args(); + args.detach = true; + args.tty = true; + + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("Cannot use -t")); +} + +#[test] +fn test_validate_run_mode_rejects_tty_without_terminal_before_boot() { + let mut args = default_run_args(); + args.tty = true; + + let err = validate_run_mode(&args, false).unwrap_err(); + assert!(err.contains("requires a terminal")); +} + +#[test] +fn test_validate_run_mode_allows_detached_without_tty() { + let mut args = default_run_args(); + args.detach = true; + + assert!(validate_run_mode(&args, false).is_ok()); +} + +#[test] +fn test_validate_run_mode_rejects_no_stdin_with_interactive() { + let mut args = default_run_args(); + args.interactive = true; + args.no_stdin = true; + + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("--interactive")); +} + +#[test] +fn test_validate_run_mode_rejects_invalid_timeout_modes() { + let mut args = default_run_args(); + args.timeout = Some(0); + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("greater than zero")); + + let mut args = default_run_args(); + args.timeout = Some(30); + args.detach = true; + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("--timeout")); + assert!(err.contains("detach")); + + let mut args = default_run_args(); + args.timeout = Some(30); + args.tty = true; + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("--timeout")); + assert!(err.contains("tty")); +} + +#[test] +fn test_validate_pool_run_mode_requires_auto_remove_and_command() { + let mut args = default_pool_run_args(); + args.rm = false; + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("requires --rm")); + + let mut args = default_pool_run_args(); + args.cmd = vec![]; + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("explicit command")); +} + +#[test] +fn test_validate_pool_run_mode_rejects_unsupported_modes() { + let mut args = default_pool_run_args(); + args.detach = true; + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("--pool")); + assert!(err.contains("detach")); + + let mut args = default_pool_run_args(); + args.interactive = true; + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("--interactive")); + + let mut args = default_pool_run_args(); + args.common.publish = vec!["8080:80".to_string()]; + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("currently supports only")); + + let mut args = default_pool_run_args(); + args.common.name = Some("named-pool-run".to_string()); + let err = validate_run_mode(&args, true).unwrap_err(); + assert!(err.contains("currently supports only")); +} + +#[test] +fn test_validate_pool_run_mode_allows_timeout() { + let mut args = default_pool_run_args(); + args.timeout = Some(30); + + assert!(validate_run_mode(&args, true).is_ok()); +} + +#[test] +fn test_selected_pool_socket_prefers_explicit_pool_socket() { + let mut args = default_pool_run_args(); + args.pool_socket = "/tmp/explicit.sock".to_string(); + + assert_eq!( + selected_pool_socket(&args, Some("/tmp/env.sock")).as_deref(), + Some("/tmp/explicit.sock") + ); +} + +#[test] +fn test_selected_pool_socket_uses_env_for_compatible_foreground_run() { + let mut args = default_run_args(); + args.rm = true; + args.cmd = vec!["bash".to_string(), "-lc".to_string(), "echo ok".to_string()]; + args.package_cache = vec![PackageCache::Pnpm]; + args.common.volumes = vec!["/host/work:/workspace:rw".to_string()]; + args.common.workdir = Some("/workspace".to_string()); + + assert_eq!( + selected_pool_socket(&args, Some(" /tmp/runtime.sock ")).as_deref(), + Some("/tmp/runtime.sock") + ); +} + +#[test] +fn test_selected_pool_socket_ignores_env_for_incompatible_run() { + let mut args = default_run_args(); + args.rm = true; + args.detach = true; + args.cmd = vec!["echo".to_string(), "ok".to_string()]; + + assert!(selected_pool_socket(&args, Some("/tmp/runtime.sock")).is_none()); + + let mut named = default_run_args(); + named.rm = true; + named.common.name = Some("named-run".to_string()); + named.cmd = vec!["echo".to_string(), "ok".to_string()]; + assert!(selected_pool_socket(&named, Some("/tmp/runtime.sock")).is_none()); + assert!(selected_pool_socket(&args, Some("")).is_none()); +} + +#[test] +fn test_selected_pool_socket_uses_autostart_flag() { + let mut args = default_pool_run_args(); + args.pool = false; + args.pool_autostart = true; + args.pool_socket = "/tmp/autostart.sock".to_string(); + + assert_eq!( + selected_pool_socket(&args, None).as_deref(), + Some("/tmp/autostart.sock") + ); +} + +#[test] +fn test_pool_autostart_config_prewarms_simple_run() { + let args = default_pool_run_args(); + let config = pool_autostart_config_for_run(&args, "/tmp/pool.sock").unwrap(); + + assert_eq!(config.socket, "/tmp/pool.sock"); + assert_eq!(config.image.as_deref(), Some("test")); +} + +#[test] +fn test_pool_autostart_config_skips_prewarm_for_volume_shape() { + let mut args = default_pool_run_args(); + args.common.volumes = vec!["/host:/work:ro".to_string()]; + let config = pool_autostart_config_for_run(&args, "/tmp/pool.sock").unwrap(); + + assert!(config.image.is_none()); +} + +#[test] +fn test_build_pool_client_run_plumbs_supported_options() { + let tmp = tempfile::tempdir().unwrap(); + let env_file = tmp.path().join("env.list"); + std::fs::write(&env_file, "B=file\nC=file\n").unwrap(); + let bind = format!("{}:/workspace:ro", tmp.path().display()); + + let mut args = default_pool_run_args(); + args.common.image = "node:24-bookworm".to_string(); + args.common.cpus = 4; + args.common.memory = "2g".to_string(); + args.common.volumes = vec![bind.clone()]; + args.common.env = vec!["A=cli".to_string(), "B=cli".to_string()]; + args.common.env_file = vec![env_file.display().to_string()]; + args.common.user = Some("root".to_string()); + args.common.workdir = Some("/workspace".to_string()); + args.pool_socket = "/tmp/a3s-box-test-pool.sock".to_string(); + args.pool_exec = true; + args.timeout = Some(45); + + let req = build_pool_client_run(&args, &args.pool_socket).unwrap(); + + assert_eq!(req.socket, "/tmp/a3s-box-test-pool.sock"); + assert_eq!(req.image.as_deref(), Some("node:24-bookworm")); + assert_eq!(req.user.as_deref(), Some("0")); + assert_eq!(req.workdir.as_deref(), Some("/workspace")); + assert_eq!(req.volumes, vec![bind]); + assert_eq!(req.vcpus, 4); + assert_eq!(req.memory_mb, 2048); + assert!(req.exec); + assert_eq!(req.timeout_ns, Some(45_000_000_000)); + assert_eq!(req.cmd, vec!["echo", "hello"]); + assert_eq!(req.env, vec!["A=cli", "B=cli", "C=file"]); +} + +#[test] +fn test_apply_package_caches_adds_pnpm_volume_and_env() { + let mut volumes = Vec::new(); + let mut env = std::collections::HashMap::new(); + + apply_package_caches(&[PackageCache::Pnpm], &mut volumes, &mut env); + + assert_eq!(volumes, vec![PNPM_CACHE_VOLUME_SPEC.to_string()]); + assert_eq!( + env.get(PNPM_CONFIG_STORE_ENV).map(String::as_str), + Some(PNPM_STORE_DIR) + ); + assert_eq!( + env.get(PNPM_STORE_ENV).map(String::as_str), + Some(PNPM_STORE_DIR) + ); + assert_eq!( + env.get(PNPM_COREPACK_HOME_ENV).map(String::as_str), + Some(PNPM_COREPACK_HOME_DIR) + ); + assert_eq!( + env.get(PNPM_HOME_ENV).map(String::as_str), + Some(PNPM_HOME_DIR) + ); + assert_eq!( + env.get(PNPM_NPM_CACHE_ENV).map(String::as_str), + Some(PNPM_NPM_CACHE_DIR) + ); + assert_eq!( + env.get(PNPM_CONFIG_PREFER_OFFLINE_ENV).map(String::as_str), + Some(PNPM_PREFER_OFFLINE_VALUE) + ); + assert_eq!( + env.get(PNPM_PREFER_OFFLINE_ENV).map(String::as_str), + Some(PNPM_PREFER_OFFLINE_VALUE) + ); + assert_eq!( + env.get(COREPACK_DOWNLOAD_PROMPT_ENV).map(String::as_str), + Some(COREPACK_DOWNLOAD_PROMPT_VALUE) + ); +} + +#[test] +fn test_apply_package_caches_preserves_user_pnpm_env() { + let mut volumes = Vec::new(); + let mut env = std::collections::HashMap::from([ + ( + PNPM_CONFIG_STORE_ENV.to_string(), + "/custom/pnpm-config-store".to_string(), + ), + (PNPM_STORE_ENV.to_string(), "/custom/pnpm-store".to_string()), + ( + PNPM_COREPACK_HOME_ENV.to_string(), + "/custom/corepack".to_string(), + ), + (PNPM_HOME_ENV.to_string(), "/custom/pnpm-home".to_string()), + ( + PNPM_NPM_CACHE_ENV.to_string(), + "/custom/npm-cache".to_string(), + ), + ( + PNPM_CONFIG_PREFER_OFFLINE_ENV.to_string(), + "false".to_string(), + ), + (PNPM_PREFER_OFFLINE_ENV.to_string(), "false".to_string()), + (COREPACK_DOWNLOAD_PROMPT_ENV.to_string(), "1".to_string()), + ]); + + apply_package_caches(&[PackageCache::Pnpm], &mut volumes, &mut env); + + assert_eq!( + env.get(PNPM_CONFIG_STORE_ENV).map(String::as_str), + Some("/custom/pnpm-config-store") + ); + assert_eq!( + env.get(PNPM_STORE_ENV).map(String::as_str), + Some("/custom/pnpm-store") + ); + assert_eq!( + env.get(PNPM_COREPACK_HOME_ENV).map(String::as_str), + Some("/custom/corepack") + ); + assert_eq!( + env.get(PNPM_HOME_ENV).map(String::as_str), + Some("/custom/pnpm-home") + ); + assert_eq!( + env.get(PNPM_NPM_CACHE_ENV).map(String::as_str), + Some("/custom/npm-cache") + ); + assert_eq!( + env.get(PNPM_CONFIG_PREFER_OFFLINE_ENV).map(String::as_str), + Some("false") + ); + assert_eq!( + env.get(PNPM_PREFER_OFFLINE_ENV).map(String::as_str), + Some("false") + ); + assert_eq!( + env.get(COREPACK_DOWNLOAD_PROMPT_ENV).map(String::as_str), + Some("1") + ); +} + +#[test] +fn test_apply_package_caches_deduplicates_pnpm_volume() { + let mut volumes = vec![PNPM_CACHE_VOLUME_SPEC.to_string()]; + let mut env = std::collections::HashMap::new(); + + apply_package_caches( + &[PackageCache::Pnpm, PackageCache::Pnpm], + &mut volumes, + &mut env, + ); + + assert_eq!(volumes, vec![PNPM_CACHE_VOLUME_SPEC.to_string()]); +} + +#[test] +fn test_apply_package_caches_adds_npm_volume_and_env() { + let mut volumes = Vec::new(); + let mut env = std::collections::HashMap::new(); + + apply_package_caches(&[PackageCache::Npm], &mut volumes, &mut env); + + assert_eq!(volumes, vec![NPM_CACHE_VOLUME_SPEC.to_string()]); + assert_eq!( + env.get(NPM_CACHE_ENV).map(String::as_str), + Some(NPM_CACHE_DIR) + ); + assert_eq!( + env.get(NPM_PREFER_OFFLINE_ENV).map(String::as_str), + Some(NPM_PREFER_OFFLINE_VALUE) + ); + assert!(!env.contains_key(PNPM_STORE_ENV)); + assert!(!env.contains_key(PNPM_COREPACK_HOME_ENV)); + assert!(!env.contains_key(PNPM_HOME_ENV)); +} + +#[test] +fn test_apply_package_caches_preserves_user_npm_env() { + let mut volumes = Vec::new(); + let mut env = std::collections::HashMap::from([ + (NPM_CACHE_ENV.to_string(), "/custom/npm-cache".to_string()), + (NPM_PREFER_OFFLINE_ENV.to_string(), "false".to_string()), + ]); + + apply_package_caches(&[PackageCache::Npm], &mut volumes, &mut env); + + assert_eq!( + env.get(NPM_CACHE_ENV).map(String::as_str), + Some("/custom/npm-cache") + ); + assert_eq!( + env.get(NPM_PREFER_OFFLINE_ENV).map(String::as_str), + Some("false") + ); +} + +#[test] +fn test_apply_package_caches_deduplicates_npm_volume() { + let mut volumes = vec![NPM_CACHE_VOLUME_SPEC.to_string()]; + let mut env = std::collections::HashMap::new(); + + apply_package_caches( + &[PackageCache::Npm, PackageCache::Npm], + &mut volumes, + &mut env, + ); + + assert_eq!(volumes, vec![NPM_CACHE_VOLUME_SPEC.to_string()]); +} + +#[test] +fn test_build_box_config_uses_keepalive_for_interactive_tty_boot() { + let mut args = default_run_args(); + args.tty = true; + args.cmd = vec!["/bin/echo".to_string(), "hello".to_string()]; + + let config = build_box_config( + &args, + 512, + Default::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + + assert_eq!(config.cmd, vec!["a3s-box-pty-keepalive"]); + assert_eq!( + config.entrypoint_override, + Some(interactive_keepalive_entrypoint()) + ); +} + +#[test] +fn test_build_box_config_plumbs_virtiofs_cache_mode() { + let mut args = default_run_args(); + args.common.virtiofs_cache = Some(common::VirtiofsCacheMode::Always); + + let config = build_box_config( + &args, + 512, + Default::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + + assert_eq!(config.virtiofs_cache.as_deref(), Some("always")); +} + +#[test] +fn test_build_box_config_preserves_non_tty_command() { + let mut args = default_run_args(); + args.cmd = vec!["/bin/echo".to_string(), "hello".to_string()]; + let entrypoint = Some(vec!["/custom-entrypoint".to_string()]); + + let config = build_box_config( + &args, + 512, + Default::default(), + entrypoint.clone(), + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + + assert_eq!(config.cmd, args.cmd); + assert_eq!(config.entrypoint_override, entrypoint); +} + +#[test] +fn test_build_box_config_controls_stdin_open() { + let args = default_run_args(); + let config = build_box_config( + &args, + 512, + Default::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + assert!(!config.stdin_open); + + let mut args = default_run_args(); + args.interactive = true; + let config = build_box_config( + &args, + 512, + Default::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + assert!(config.stdin_open); + + let mut args = default_run_args(); + args.no_stdin = true; + let config = build_box_config( + &args, + 512, + Default::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + assert!(!config.stdin_open); +} + +#[test] +fn test_mark_record_stopped_persists_exit_context() { + let record = crate::test_helpers::fixtures::make_record( + "550e8400-e29b-41d4-a716-446655440000", + "run-exit", + "running", + Some(1234), + ); + let (_tmp, mut state) = crate::test_helpers::fixtures::setup_state(vec![record]); + + mark_record_stopped( + &mut state, + "550e8400-e29b-41d4-a716-446655440000", + Some(42), + true, + ); + + let record = state + .find_by_id("550e8400-e29b-41d4-a716-446655440000") + .unwrap(); + assert_eq!(record.status, "stopped"); + assert_eq!(record.pid, None); + assert_eq!(record.exit_code, Some(42)); + assert!(record.stopped_by_user); +} + +#[tokio::test] +async fn test_cleanup_failure_is_reported_and_preserves_recovery_state() { + let temporary = tempfile::tempdir().unwrap(); + let id = "550e8400-e29b-41d4-a716-446655440000"; + let mut record = + crate::test_helpers::fixtures::make_record(id, "run-cleanup", "running", Some(1234)); + record.box_dir = temporary.path().join("boxes").join(id); + record.exec_socket_path = record.box_dir.join("sockets/exec.sock"); + std::fs::create_dir_all(&record.box_dir).unwrap(); + + let backend = VmLocalExecutionBackend::new(temporary.path()); + let manager = LocalExecutionManager::new( + temporary.path().join("empty-state.json"), + temporary.path(), + std::sync::Arc::new(backend), + ); + let mut context = RunContext { + manager, + execution_id: ExecutionId::new(id).unwrap(), + generation: ExecutionGeneration::new(1).unwrap(), + box_id: id.to_string(), + box_dir: record.box_dir.clone(), + name: record.name.clone(), + record, + exec_socket_path: temporary.path().join("exec.sock"), + pty_socket_path: temporary.path().join("pty.sock"), + anonymous_volumes: Vec::new(), + health_checker: None, + }; + + let error = cleanup_managed_execution(&mut context, true, Some(1), false, false) + .await + .unwrap_err(); + + assert!(error + .to_string() + .contains("state was preserved for recovery")); + assert!(context.box_dir.exists()); +} + +#[test] +fn test_foreground_exit_code_preserves_vm_code() { + assert_eq!( + foreground_exit_code( + ForegroundStopReason::UserInterrupted(FOREGROUND_SIGTERM), + Some(143) + ), + Some(143) + ); + assert_eq!( + foreground_exit_code(ForegroundStopReason::VmUnhealthy, Some(2)), + Some(2) + ); + assert_eq!( + foreground_exit_code(ForegroundStopReason::TimedOut, Some(0)), + Some(124) + ); +} + +#[test] +fn test_foreground_exit_code_has_deterministic_fallbacks() { + assert_eq!( + foreground_exit_code(ForegroundStopReason::ProcessExited, None), + None + ); + assert_eq!( + foreground_exit_code( + ForegroundStopReason::UserInterrupted(FOREGROUND_SIGINT), + None + ), + Some(130) + ); + assert_eq!( + foreground_exit_code( + ForegroundStopReason::UserInterrupted(FOREGROUND_SIGTERM), + None + ), + Some(143) + ); + assert_eq!( + foreground_exit_code(ForegroundStopReason::VmUnhealthy, None), + Some(1) + ); + assert_eq!( + foreground_exit_code(ForegroundStopReason::TimedOut, None), + Some(124) + ); +} + +#[test] +fn test_foreground_stop_reason_user_flag() { + assert!(ForegroundStopReason::UserInterrupted(FOREGROUND_SIGINT).stopped_by_user()); + assert!(!ForegroundStopReason::ProcessExited.stopped_by_user()); + assert!(!ForegroundStopReason::VmUnhealthy.stopped_by_user()); + assert!(!ForegroundStopReason::TimedOut.stopped_by_user()); +} + +#[test] +fn test_foreground_poll_cadence_avoids_fixed_startup_delay() { + assert!(FOREGROUND_EXIT_POLL <= std::time::Duration::from_millis(20)); + assert!(FOREGROUND_EXIT_POLL < FOREGROUND_HEALTH_POLL); + assert!(FOREGROUND_LOG_DRAIN_QUIET <= std::time::Duration::from_millis(50)); + assert!(FOREGROUND_LOG_DRAIN_POLL < FOREGROUND_LOG_DRAIN_QUIET); +} + +#[tokio::test] +async fn finished_sandbox_writers_need_no_additional_quiet_period() { + let directory = tempfile::tempdir().unwrap(); + let log = directory.path().join("console.log"); + std::fs::write(&log, b"complete").unwrap(); + let position = AtomicU64::new(8); + + tokio::time::timeout( + std::time::Duration::from_millis(5), + wait_for_foreground_log_drain(&[(&log, &position)], true), + ) + .await + .expect("a caught-up tail must return immediately after every writer exited"); +} + +#[tokio::test] +async fn finished_sandbox_writer_wait_still_requires_tail_catch_up() { + let directory = tempfile::tempdir().unwrap(); + let log = directory.path().join("console.log"); + std::fs::write(&log, b"pending").unwrap(); + let position = AtomicU64::new(0); + + assert!(tokio::time::timeout( + std::time::Duration::from_millis(5), + wait_for_foreground_log_drain(&[(&log, &position)], true), + ) + .await + .is_err()); +} + +#[test] +fn test_retained_log_hint_only_for_non_user_failures() { + assert!(should_print_retained_log_hint(Some(1), false)); + assert!(!should_print_retained_log_hint(Some(0), false)); + assert!(!should_print_retained_log_hint(None, false)); + assert!(!should_print_retained_log_hint(Some(130), true)); +} + +#[test] +fn test_foreground_completion_messages() { + assert_eq!( + foreground_completion_message(ForegroundStopReason::ProcessExited, true, "box"), + "Box box exited and was removed." + ); + assert_eq!( + foreground_completion_message( + ForegroundStopReason::UserInterrupted(FOREGROUND_SIGINT), + false, + "box" + ), + "Box box stopped." + ); + assert_eq!( + foreground_completion_message(ForegroundStopReason::VmUnhealthy, true, "box"), + "Box box stopped after VM health check failed and was removed." + ); + assert_eq!( + foreground_completion_message(ForegroundStopReason::TimedOut, false, "box"), + "Box box stopped after --timeout expired." + ); +} + +#[test] +fn test_build_box_config_passes_security_options() { + let mut args = default_run_args(); + args.common.cap_add = vec!["NET_ADMIN".to_string()]; + args.common.cap_drop = vec!["NET_RAW".to_string()]; + args.common.security_opt = vec!["seccomp=unconfined".to_string()]; + args.common.privileged = true; + + let config = build_box_config( + &args, + 512, + a3s_box_core::config::ResourceLimits::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + + assert_eq!(config.cap_add, vec!["NET_ADMIN"]); + assert_eq!(config.cap_drop, vec!["NET_RAW"]); + assert_eq!(config.security_opt, vec!["seccomp=unconfined"]); + assert!(config.privileged); +} + +#[test] +fn test_build_box_config_passes_user_and_workdir() { + let mut args = default_run_args(); + args.common.user = Some("root:root".to_string()); + args.common.workdir = Some("/app".to_string()); + + let config = build_box_config( + &args, + 512, + a3s_box_core::config::ResourceLimits::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + + assert_eq!(config.user.as_deref(), Some("0:0")); + assert_eq!(config.workdir.as_deref(), Some("/app")); +} + +#[test] +fn test_build_box_config_passes_hostname_and_add_hosts() { + let mut args = default_run_args(); + args.common.hostname = Some("web".to_string()); + args.common.add_host = vec!["db.local:10.88.0.10".to_string()]; + + let config = build_box_config( + &args, + 512, + a3s_box_core::config::ResourceLimits::default(), + None, + vec![], + vec![], + vec![], + a3s_box_core::NetworkMode::Tsi, + vec![], + TeeConfig::None, + ) + .unwrap(); + + assert_eq!(config.hostname.as_deref(), Some("web")); + assert_eq!(config.add_hosts, vec!["db.local:10.88.0.10"]); +} + +#[path = "request_tests.rs"] +mod request_tests; + +#[test] +fn test_resolve_volumes_empty() { + let (resolved, names) = resolve_volumes(&[]).unwrap(); + assert!(resolved.is_empty()); + assert!(names.is_empty()); +} diff --git a/src/cli/src/commands/snapshot.rs b/src/cli/src/commands/snapshot.rs index a4a28029..95462428 100644 --- a/src/cli/src/commands/snapshot.rs +++ b/src/cli/src/commands/snapshot.rs @@ -158,6 +158,14 @@ async fn execute_create(args: SnapshotCreateArgs) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> async fn start_one(state: &StateFile, query: &str) -> Result<(), Box> { let record = resolve::resolve(state, query)?; - - validate_start_status(&record.name, &record.status) - .map_err(|error| -> Box { error.into() })?; + let plan = + start_plan(record).map_err(|error| -> Box { error.into() })?; let box_id = record.id.clone(); let name = record.name.clone(); println!("Starting box {name}..."); - let result = boot::boot_from_record(record).await?; - - // Persist the boot result atomically (load-fresh + mutate + save under the - // state lock) so it cannot clobber a concurrent writer with our pre-boot - // snapshot. - StateFile::modify(move |s| { - if let Some(record) = s.find_by_id_mut(&box_id) { - boot::apply_boot_result(record, result, boot::RestartCountUpdate::Reset); + let started_record = match plan { + StartPlan::Legacy => { + let result = boot::boot_from_record(record).await?; + + // Persist the boot result atomically (load-fresh + mutate + save under the + // state lock) so it cannot clobber a concurrent writer with our pre-boot + // snapshot. + StateFile::modify({ + let box_id = box_id.clone(); + move |state| { + Ok::<_, std::io::Error>(state.find_by_id_mut(&box_id).map(|record| { + boot::apply_boot_result(record, result, boot::RestartCountUpdate::Reset); + record.clone() + })) + } + })? + } + StartPlan::Managed { + execution_id, + generation, + } => { + let home = a3s_box_core::dirs_home(); + let manager = LocalExecutionManager::with_vm_backend(home.join("boxes.json"), &home); + manager.start(&execution_id, generation).await?; + + let baseline_box_dir = record.box_dir.clone(); + let baseline_box_id = record.id.clone(); + match tokio::task::spawn_blocking(move || { + crate::commands::diff::create_box_baseline_snapshot(&baseline_box_dir) + .map_err(|error| error.to_string()) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!( + box_id = %baseline_box_id, + %error, + "Failed to create rootfs diff baseline snapshot" + ); + } + Err(error) => { + tracing::warn!( + box_id = %baseline_box_id, + %error, + "Rootfs diff baseline task failed" + ); + } + } + + StateFile::load_default()?.find_by_id(&box_id).cloned() } - Ok::<(), std::io::Error>(()) - })?; + }; + if let Some(record) = started_record { + crate::health::spawn_detached_health_checker(&record) + .map_err(|error| -> Box { error.into() })?; + } println!("{name}"); Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq)] +enum StartPlan { + Legacy, + Managed { + execution_id: ExecutionId, + generation: ExecutionGeneration, + }, +} + +fn start_plan(record: &crate::state::BoxRecord) -> Result { + let Some(metadata) = record.managed_execution.as_ref() else { + validate_start_status(&record.name, &record.status)?; + return Ok(StartPlan::Legacy); + }; + let state = record + .managed_state() + .map_err(|error| format!("Invalid managed state for box {}: {error}", record.name))? + .ok_or_else(|| format!("Box {} lost managed lifecycle metadata", record.name))?; + + match state { + ManagedExecutionState::Creating + | ManagedExecutionState::Created + | ManagedExecutionState::Starting => Ok(StartPlan::Managed { + execution_id: ExecutionId::new(record.id.clone()).map_err(|error| error.to_string())?, + generation: metadata.generation, + }), + ManagedExecutionState::Running => Err(format!("Box {} is already running", record.name)), + ManagedExecutionState::Stopped | ManagedExecutionState::Failed => Err(format!( + "Box {} is {state}; ordinary start cannot revive a terminal managed execution without advancing its generation", + record.name + )), + other => Err(format!("Cannot start box in state: {other}")), + } +} + fn validate_start_status(name: &str, status: &str) -> Result<(), String> { match status { "created" | "stopped" | "dead" => Ok(()), @@ -67,6 +149,38 @@ fn validate_start_status(name: &str, status: &str) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + use a3s_box_core::{BoxConfig, CreateExecutionRequest, ExecutionIsolation, OperationId}; + use a3s_box_runtime::{ManagedExecutionMetadata, ManagedExecutionOperation}; + use std::collections::BTreeMap; + + use crate::test_helpers::fixtures::make_record; + + fn managed_record(status: ManagedExecutionState) -> crate::state::BoxRecord { + let id = "11111111-1111-4111-8111-111111111111"; + let mut record = make_record(id, "web", status.as_status(), None); + record.isolation = ExecutionIsolation::Sandbox; + let mut metadata = ManagedExecutionMetadata::new( + OperationId::new("operation-1").unwrap(), + ExecutionGeneration::INITIAL, + CreateExecutionRequest { + external_sandbox_id: "external-1".to_string(), + config: BoxConfig { + isolation: ExecutionIsolation::Sandbox, + image: record.image.clone(), + ..Default::default() + }, + labels: BTreeMap::new(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + ) + .unwrap(); + if status == ManagedExecutionState::Starting { + metadata.pending_operation = Some(ManagedExecutionOperation::Start); + } + record.managed_execution = Some(metadata); + record + } #[test] fn validate_start_status_accepts_startable_states() { @@ -90,4 +204,41 @@ mod tests { "Cannot start box in state: paused" ); } + + #[test] + fn managed_start_plan_uses_the_persisted_generation() { + for status in [ + ManagedExecutionState::Creating, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + ] { + assert_eq!( + start_plan(&managed_record(status)).unwrap(), + StartPlan::Managed { + execution_id: ExecutionId::new("11111111-1111-4111-8111-111111111111").unwrap(), + generation: ExecutionGeneration::INITIAL, + } + ); + } + } + + #[test] + fn managed_start_plan_rejects_terminal_resurrection() { + for status in [ + ManagedExecutionState::Stopped, + ManagedExecutionState::Failed, + ] { + let error = start_plan(&managed_record(status)).unwrap_err(); + assert!(error.contains("cannot revive a terminal managed execution")); + assert!(error.contains("advancing its generation")); + } + } + + #[test] + fn legacy_stopped_box_keeps_legacy_start_behavior() { + assert_eq!( + start_plan(&make_record("legacy", "legacy", "stopped", None)).unwrap(), + StartPlan::Legacy + ); + } } diff --git a/src/cli/src/commands/stop.rs b/src/cli/src/commands/stop.rs index 8dd1f578..2d791b5c 100644 --- a/src/cli/src/commands/stop.rs +++ b/src/cli/src/commands/stop.rs @@ -79,13 +79,13 @@ async fn stop_one( ); if auto_remove { - cleanup::cleanup_removed_box(&record_snapshot); + cleanup::cleanup_removed_box(&record_snapshot)?; StateFile::remove_record(&box_id)?; println!("{name} (auto-removed)"); return Ok(()); } - cleanup::cleanup_stopped_box(&record_snapshot); + cleanup::cleanup_stopped_box(&record_snapshot)?; // Apply the status change atomically (load-fresh + mutate + save under the // state lock) so it cannot clobber a concurrent run/monitor/compose write diff --git a/src/cli/src/commands/wait.rs b/src/cli/src/commands/wait.rs index c8cdf3c9..515b9e50 100644 --- a/src/cli/src/commands/wait.rs +++ b/src/cli/src/commands/wait.rs @@ -6,21 +6,37 @@ use crate::process; use crate::resolve; use crate::state::{BoxRecord, StateFile}; +const WAIT_POLL_MILLIS: u64 = 500; +const DEFAULT_HEARTBEAT_SECS: u64 = 60; + #[derive(Args)] pub struct WaitArgs { /// Box name(s) or ID(s) #[arg(required = true)] pub boxes: Vec, + + /// Seconds between stderr keepalive messages while waiting (0 disables) + #[arg(long, default_value_t = DEFAULT_HEARTBEAT_SECS)] + pub heartbeat_interval: u64, + + /// Disable stderr keepalive messages while waiting + #[arg(long)] + pub no_heartbeat: bool, } pub async fn execute(args: WaitArgs) -> Result<(), Box> { + let heartbeat_interval = wait_heartbeat_interval(&args); for query in &args.boxes { - wait_one(query).await?; + wait_one(query, heartbeat_interval).await?; } Ok(()) } -async fn wait_one(query: &str) -> Result<(), Box> { +async fn wait_one( + query: &str, + heartbeat_interval: Option, +) -> Result<(), Box> { + let mut heartbeat = WaitHeartbeat::new(heartbeat_interval); loop { let state = StateFile::load_default()?; let record = resolve::resolve(&state, query)?; @@ -31,12 +47,56 @@ async fn wait_one(query: &str) -> Result<(), Box> { return Ok(()); } WaitPollAction::Sleep => { - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + heartbeat.maybe_emit(query); + tokio::time::sleep(tokio::time::Duration::from_millis(WAIT_POLL_MILLIS)).await; } } } } +fn wait_heartbeat_interval(args: &WaitArgs) -> Option { + if args.no_heartbeat || args.heartbeat_interval == 0 { + None + } else { + Some(std::time::Duration::from_secs(args.heartbeat_interval)) + } +} + +struct WaitHeartbeat { + interval: Option, + started: std::time::Instant, + next: std::time::Instant, +} + +impl WaitHeartbeat { + fn new(interval: Option) -> Self { + let now = std::time::Instant::now(); + let next = interval.map(|interval| now + interval).unwrap_or(now); + Self { + interval, + started: now, + next, + } + } + + fn maybe_emit(&mut self, query: &str) { + let Some(interval) = self.interval else { + return; + }; + + let now = std::time::Instant::now(); + if now < self.next { + return; + } + + eprintln!( + "a3s-box wait: still waiting for {query} ({}s)", + now.duration_since(self.started).as_secs() + ); + self.next = now + interval; + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WaitPollAction { Finish(i32), @@ -96,4 +156,20 @@ mod tests { assert_eq!(wait_poll_action(&record), WaitPollAction::Finish(0)); } + + #[test] + fn test_wait_heartbeat_interval_can_be_disabled() { + assert!(wait_heartbeat_interval(&WaitArgs { + boxes: vec!["box".to_string()], + heartbeat_interval: 60, + no_heartbeat: true, + }) + .is_none()); + assert!(wait_heartbeat_interval(&WaitArgs { + boxes: vec!["box".to_string()], + heartbeat_interval: 0, + no_heartbeat: false, + }) + .is_none()); + } } diff --git a/src/cli/src/health.rs b/src/cli/src/health.rs index 4d23c69b..8e9e3a9f 100644 --- a/src/cli/src/health.rs +++ b/src/cli/src/health.rs @@ -1,7 +1,8 @@ //! Health check executor for running containers. //! -//! Spawns a background task that periodically runs the user-defined health -//! check command via the exec socket and updates the box state accordingly. +//! Runs user-defined health checks through the exec socket and updates box +//! state. Foreground commands use a Tokio task; detached boxes use a +//! generation-fenced child process so scheduling survives the creating CLI. //! //! Follows Docker health check semantics: //! - Wait `start_period_secs` before the first check @@ -10,9 +11,10 @@ //! - After `retries` consecutive failures → status becomes "unhealthy" //! - Socket disappearing → box has stopped; checker exits +#[cfg(not(windows))] +use std::path::Path; use std::path::PathBuf; -#[cfg(any(not(windows), test))] use crate::state::BoxRecord; use crate::state::HealthCheck; #[cfg(not(windows))] @@ -21,8 +23,8 @@ use crate::state::StateFile; /// Spawn a background health checker task for a running box. /// /// Returns a `JoinHandle` that the caller can abort when the box stops. -/// In detached/daemon scenarios the handle may be dropped; the task will -/// self-terminate once the exec socket disappears. +/// Foreground callers abort the handle during cleanup. Detached callers must +/// use [`spawn_detached_health_checker`] instead. pub fn spawn_health_checker( box_id: String, exec_socket_path: PathBuf, @@ -31,7 +33,7 @@ pub fn spawn_health_checker( #[cfg(not(windows))] { tokio::spawn(async move { - run_health_loop(box_id, exec_socket_path, health_check).await; + run_health_loop(box_id, exec_socket_path, health_check, None).await; }) } #[cfg(windows)] @@ -42,8 +44,165 @@ pub fn spawn_health_checker( } } +/// Start a process-owned health checker for a detached box. +/// +/// A Tokio task owned by `run -d`, `compose up`, or `start` disappears when that +/// short-lived CLI exits. The child process uses a generation-specific lock so +/// duplicate launch attempts collapse to one worker, while a restarted box can +/// immediately acquire a new generation lock. +#[cfg(not(windows))] +pub(crate) fn spawn_detached_health_checker(record: &BoxRecord) -> Result<(), String> { + if record.health_check.is_none() { + return Ok(()); + } + let generation = health_generation(record) + .ok_or_else(|| format!("box '{}' has no health-check generation", record.name))?; + let executable = std::env::current_exe() + .map_err(|error| format!("failed to locate a3s-box for health checker: {error}"))?; + let arguments = detached_health_worker_args(&record.id, generation); + + std::process::Command::new(executable) + .args(arguments) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|error| { + format!( + "failed to start detached health checker for '{}': {error}", + record.name + ) + }) +} + +#[cfg(any(not(windows), test))] +fn detached_health_worker_args(box_id: &str, generation: i64) -> Vec { + vec![ + "monitor".to_string(), + "--health-worker".to_string(), + box_id.to_string(), + "--health-generation".to_string(), + generation.to_string(), + ] +} + +#[cfg(windows)] +pub(crate) fn spawn_detached_health_checker(_record: &BoxRecord) -> Result<(), String> { + Ok(()) +} + +/// Run the hidden process-owned health worker for one box generation. +#[cfg(not(windows))] +pub(crate) async fn run_detached_health_worker( + box_id: String, + generation: i64, +) -> Result<(), Box> { + let Some(_lock) = HealthWorkerLock::try_acquire(&box_id, generation)? else { + return Ok(()); + }; + + let state = StateFile::load_default()?; + let Some(record) = state.find_by_id(&box_id) else { + return Ok(()); + }; + if health_generation(record) != Some(generation) || record.status != "running" { + return Ok(()); + } + let Some(health_check) = record.health_check.clone() else { + return Ok(()); + }; + + run_health_loop( + box_id, + record.exec_socket_path.clone(), + health_check, + Some(generation), + ) + .await; + Ok(()) +} + +#[cfg(windows)] +pub(crate) async fn run_detached_health_worker( + _box_id: String, + _generation: i64, +) -> Result<(), Box> { + Ok(()) +} + #[cfg(not(windows))] -async fn run_health_loop(box_id: String, exec_socket_path: PathBuf, hc: HealthCheck) { +struct HealthWorkerLock { + _file: std::fs::File, +} + +#[cfg(not(windows))] +impl HealthWorkerLock { + fn try_acquire(box_id: &str, generation: i64) -> std::io::Result> { + Self::try_acquire_path(&health_worker_lock_path(box_id, generation)) + } + + fn try_acquire_path(path: &Path) -> std::io::Result> { + use std::os::fd::AsRawFd; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + return Ok(Some(Self { _file: file })); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(error) + } + } +} + +#[cfg(not(windows))] +fn health_worker_lock_path(box_id: &str, generation: i64) -> PathBuf { + a3s_box_core::dirs_home() + .join("locks") + .join(format!("{box_id}.{generation}.health.lock")) +} + +#[cfg(any(not(windows), test))] +fn health_generation(record: &BoxRecord) -> Option { + record + .started_at + .and_then(|started_at| started_at.timestamp_nanos_opt()) +} + +#[cfg(not(windows))] +pub(crate) fn detached_health_worker_active(record: &BoxRecord) -> bool { + let Some(generation) = health_generation(record) else { + return false; + }; + match HealthWorkerLock::try_acquire(&record.id, generation) { + Ok(Some(lock)) => { + drop(lock); + false + } + Ok(None) => true, + Err(_) => false, + } +} + +#[cfg(not(windows))] +async fn run_health_loop( + box_id: String, + exec_socket_path: PathBuf, + hc: HealthCheck, + expected_generation: Option, +) { use std::time::Duration; // Honour start_period before the first probe @@ -57,8 +216,7 @@ async fn run_health_loop(box_id: String, exec_socket_path: PathBuf, hc: HealthCh loop { tokio::time::sleep(interval).await; - // Box stopped — exec socket is gone - if !exec_socket_path.exists() { + if !health_worker_is_current(&box_id, expected_generation) { break; } @@ -73,6 +231,9 @@ async fn run_health_loop(box_id: String, exec_socket_path: PathBuf, hc: HealthCh if record.status != "running" { return Ok(false); // box stopped } + if expected_generation.is_some() && health_generation(record) != expected_generation { + return Ok(false); // box restarted; a new generation owns probes + } apply_probe_result(record, healthy, chrono::Utc::now()); Ok(true) }); @@ -84,6 +245,19 @@ async fn run_health_loop(box_id: String, exec_socket_path: PathBuf, hc: HealthCh } } +#[cfg(not(windows))] +fn health_worker_is_current(box_id: &str, expected_generation: Option) -> bool { + let Ok(state) = StateFile::load_default() else { + return true; + }; + state.find_by_id(box_id).is_some_and(|record| { + record.status == "running" + && expected_generation + .map(|generation| health_generation(record) == Some(generation)) + .unwrap_or(true) + }) +} + #[cfg(not(windows))] pub(crate) async fn run_probe( exec_socket_path: &std::path::Path, @@ -291,4 +465,32 @@ mod tests { assert_eq!(record.health_status, "none"); assert!(record.health_last_check.is_none()); } + + #[test] + fn test_detached_worker_args_bind_box_generation() { + assert_eq!( + detached_health_worker_args("box-id", 1234), + vec![ + "monitor", + "--health-worker", + "box-id", + "--health-generation", + "1234", + ] + ); + } + + #[cfg(unix)] + #[test] + fn test_health_worker_lock_allows_one_owner_per_generation() { + let directory = tempfile::TempDir::new().unwrap(); + let path = directory.path().join("worker.lock"); + + let first = HealthWorkerLock::try_acquire_path(&path) + .unwrap() + .expect("first worker should own the generation"); + assert!(HealthWorkerLock::try_acquire_path(&path).unwrap().is_none()); + drop(first); + assert!(HealthWorkerLock::try_acquire_path(&path).unwrap().is_some()); + } } diff --git a/src/cli/src/lib.rs b/src/cli/src/lib.rs index 3cdb7324..d6ed54bc 100644 --- a/src/cli/src/lib.rs +++ b/src/cli/src/lib.rs @@ -7,6 +7,7 @@ pub mod commands; pub mod health; pub mod image_usage; pub mod lifecycle; +pub(crate) mod log_archive; pub mod output; pub mod platform; pub mod process; diff --git a/src/cli/src/log_archive.rs b/src/cli/src/log_archive.rs new file mode 100644 index 00000000..ce35ae20 --- /dev/null +++ b/src/cli/src/log_archive.rs @@ -0,0 +1,405 @@ +//! Archived logs for auto-removed boxes. + +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::state::BoxRecord; + +const ARCHIVE_DIR: &str = "removed-logs"; +const METADATA_FILE: &str = "metadata.json"; +const DEFAULT_MAX_ARCHIVE_AGE_DAYS: i64 = 7; +const DEFAULT_MAX_ARCHIVES: usize = 50; +const DEFAULT_MAX_ARCHIVE_BYTES: u64 = 100 * 1024 * 1024; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct LogArchiveRetention { + max_age_days: i64, + max_archives: usize, + max_total_bytes: u64, +} + +impl Default for LogArchiveRetention { + fn default() -> Self { + Self { + max_age_days: DEFAULT_MAX_ARCHIVE_AGE_DAYS, + max_archives: DEFAULT_MAX_ARCHIVES, + max_total_bytes: DEFAULT_MAX_ARCHIVE_BYTES, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RemovedLogArchive { + pub id: String, + pub short_id: String, + pub name: String, + pub image: String, + pub removed_at: DateTime, + pub created_at: DateTime, + pub started_at: Option>, + pub exit_code: Option, + pub log_config: a3s_box_core::log::LogConfig, +} + +impl RemovedLogArchive { + pub(crate) fn log_dir(&self) -> PathBuf { + archive_dir(&self.id).join("logs") + } + + pub(crate) fn console_log(&self) -> PathBuf { + self.log_dir().join("console.log") + } +} + +pub(crate) fn archive_removed_logs(record: &BoxRecord) -> std::io::Result> { + if record.log_config.driver == a3s_box_core::log::LogDriver::None { + return Ok(None); + } + + let source_log_dir = record.box_dir.join("logs"); + if !source_log_dir.exists() && !record.console_log.exists() { + return Ok(None); + } + + let archive_dir = archive_dir(&record.id); + if archive_dir.exists() { + std::fs::remove_dir_all(&archive_dir)?; + } + std::fs::create_dir_all(archive_dir.join("logs"))?; + + if source_log_dir.exists() { + copy_dir_contents(&source_log_dir, &archive_dir.join("logs"))?; + } else if record.console_log.exists() { + std::fs::copy( + &record.console_log, + archive_dir.join("logs").join("console.log"), + )?; + } + + let metadata = RemovedLogArchive { + id: record.id.clone(), + short_id: record.short_id.clone(), + name: record.name.clone(), + image: record.image.clone(), + removed_at: Utc::now(), + created_at: record.created_at, + started_at: record.started_at, + exit_code: record.exit_code, + log_config: record.log_config.clone(), + }; + let data = serde_json::to_vec_pretty(&metadata).map_err(std::io::Error::other)?; + std::fs::write(archive_dir.join(METADATA_FILE), data)?; + + if let Err(error) = prune_archives(LogArchiveRetention::default()) { + tracing::debug!( + error = %error, + "Failed to prune removed-log archives after archiving logs" + ); + } + + Ok(Some(archive_dir)) +} + +pub(crate) fn resolve_archive(query: &str) -> Result, String> { + let archives = load_archives().map_err(|e| format!("Failed to read removed logs: {e}"))?; + + if let Some(archive) = archives.iter().find(|archive| archive.id == query) { + return Ok(Some(archive.clone())); + } + if let Some(archive) = archives.iter().find(|archive| archive.short_id == query) { + return Ok(Some(archive.clone())); + } + + let mut named: Vec<_> = archives + .iter() + .filter(|archive| archive.name == query) + .cloned() + .collect(); + if !named.is_empty() { + named.sort_by_key(|archive| archive.removed_at); + return Ok(named.pop()); + } + + let prefix_matches: Vec<_> = archives + .into_iter() + .filter(|archive| archive.id.starts_with(query) || archive.short_id.starts_with(query)) + .collect(); + match prefix_matches.len() { + 0 => Ok(None), + 1 => Ok(prefix_matches.into_iter().next()), + count => Err(format!( + "Ambiguous removed-log reference \"{query}\" - matches {count} archives" + )), + } +} + +fn load_archives() -> std::io::Result> { + Ok(load_archive_entries()? + .into_iter() + .map(|entry| entry.archive) + .collect()) +} + +struct ArchiveEntry { + archive: RemovedLogArchive, + path: PathBuf, + size_bytes: u64, +} + +fn load_archive_entries() -> std::io::Result> { + let root = archive_root(); + if !root.exists() { + return Ok(Vec::new()); + } + + let mut archives = Vec::new(); + for entry in std::fs::read_dir(root)? { + let entry = entry?; + let path = entry.path().join(METADATA_FILE); + if !path.exists() { + continue; + } + let Ok(data) = std::fs::read(&path) else { + continue; + }; + if let Ok(archive) = serde_json::from_slice::(&data) { + let path = entry.path(); + let size_bytes = dir_size(&path)?; + archives.push(ArchiveEntry { + archive, + path, + size_bytes, + }); + } + } + Ok(archives) +} + +pub(crate) fn prune_archives(retention: LogArchiveRetention) -> std::io::Result { + let mut entries = load_archive_entries()?; + let now = Utc::now(); + let mut removed = 0; + + let mut kept_entries = Vec::with_capacity(entries.len()); + for entry in entries { + let too_old = now + .signed_duration_since(entry.archive.removed_at) + .num_days() + > retention.max_age_days; + if too_old { + match remove_archive_dir(&entry.path) { + Ok(()) => { + removed += 1; + continue; + } + Err(_) => kept_entries.push(entry), + } + } else { + kept_entries.push(entry); + } + } + entries = kept_entries; + + entries.sort_by_key(|entry| entry.archive.removed_at); + while entries.len() > retention.max_archives { + let entry = entries.remove(0); + if remove_archive_dir(&entry.path).is_ok() { + removed += 1; + } + } + + let mut total_bytes = entries.iter().map(|entry| entry.size_bytes).sum::(); + while total_bytes > retention.max_total_bytes && !entries.is_empty() { + let entry = entries.remove(0); + total_bytes = total_bytes.saturating_sub(entry.size_bytes); + if remove_archive_dir(&entry.path).is_ok() { + removed += 1; + } + } + + Ok(removed) +} + +fn remove_archive_dir(path: &Path) -> std::io::Result<()> { + if path.exists() { + std::fs::remove_dir_all(path)?; + } + Ok(()) +} + +fn dir_size(path: &Path) -> std::io::Result { + let mut size = 0; + if !path.exists() { + return Ok(size); + } + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let metadata = entry.metadata()?; + if metadata.is_dir() { + size += dir_size(&entry.path())?; + } else if metadata.is_file() { + size += metadata.len(); + } + } + Ok(size) +} + +fn copy_dir_contents(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_contents(&from, &to)?; + } else if file_type.is_file() { + std::fs::copy(from, to)?; + } + } + Ok(()) +} + +fn archive_root() -> PathBuf { + a3s_box_core::dirs_home().join(ARCHIVE_DIR) +} + +fn archive_dir(id: &str) -> PathBuf { + archive_root().join(id) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + struct EnvGuard { + _lock: MutexGuard<'static, ()>, + key: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &Path) -> Self { + static LOCK: OnceLock> = OnceLock::new(); + let lock = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap(); + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { + _lock: lock, + key, + previous, + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + #[test] + fn archives_and_resolves_auto_removed_logs_by_name() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::set("A3S_HOME", tmp.path()); + let box_dir = tmp.path().join("box"); + std::fs::create_dir_all(box_dir.join("logs")).unwrap(); + std::fs::write(box_dir.join("logs").join("container.json"), "{}\n").unwrap(); + + let mut record = crate::test_helpers::fixtures::make_record( + "550e8400-e29b-41d4-a716-446655440000", + "web", + "dead", + None, + ); + record.auto_remove = true; + record.box_dir = box_dir; + record.console_log = record.box_dir.join("logs").join("console.log"); + + let archive_path = archive_removed_logs(&record).unwrap().unwrap(); + assert!(archive_path.join("logs").join("container.json").exists()); + + let archive = resolve_archive("web").unwrap().unwrap(); + assert_eq!(archive.id, record.id); + assert!(archive.log_dir().join("container.json").exists()); + } + + #[test] + fn prunes_archives_by_age_and_count() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::set("A3S_HOME", tmp.path()); + + write_archive("old", Utc::now() - chrono::Duration::days(30), 8); + write_archive("keep-1", Utc::now() - chrono::Duration::days(3), 8); + write_archive("keep-2", Utc::now() - chrono::Duration::days(2), 8); + write_archive("keep-3", Utc::now() - chrono::Duration::days(1), 8); + + let removed = prune_archives(LogArchiveRetention { + max_age_days: 7, + max_archives: 2, + max_total_bytes: u64::MAX, + }) + .unwrap(); + + assert_eq!(removed, 2); + assert!(resolve_archive("old").unwrap().is_none()); + assert!(resolve_archive("keep-1").unwrap().is_none()); + assert!(resolve_archive("keep-2").unwrap().is_some()); + assert!(resolve_archive("keep-3").unwrap().is_some()); + } + + #[test] + fn prunes_archives_by_total_size() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::set("A3S_HOME", tmp.path()); + + write_archive("large-1", Utc::now() - chrono::Duration::days(3), 128); + write_archive("large-2", Utc::now() - chrono::Duration::days(2), 128); + write_archive("large-3", Utc::now() - chrono::Duration::days(1), 128); + + let before = dir_size(&archive_root()).unwrap(); + let removed = prune_archives(LogArchiveRetention { + max_age_days: 7, + max_archives: 10, + max_total_bytes: before.saturating_sub(1), + }) + .unwrap(); + + assert_eq!(removed, 1); + assert!(resolve_archive("large-1").unwrap().is_none()); + assert!(resolve_archive("large-2").unwrap().is_some()); + assert!(resolve_archive("large-3").unwrap().is_some()); + } + + fn write_archive(id: &str, removed_at: DateTime, payload_bytes: usize) { + let dir = archive_dir(id); + std::fs::create_dir_all(dir.join("logs")).unwrap(); + std::fs::write( + dir.join("logs").join("console.log"), + vec![b'x'; payload_bytes], + ) + .unwrap(); + let metadata = RemovedLogArchive { + id: id.to_string(), + short_id: id.to_string(), + name: id.to_string(), + image: "alpine:latest".to_string(), + removed_at, + created_at: removed_at, + started_at: Some(removed_at), + exit_code: Some(1), + log_config: a3s_box_core::log::LogConfig::default(), + }; + std::fs::write( + dir.join(METADATA_FILE), + serde_json::to_vec_pretty(&metadata).unwrap(), + ) + .unwrap(); + } +} diff --git a/src/cli/src/process.rs b/src/cli/src/process.rs index 14b183d9..33537f7a 100644 --- a/src/cli/src/process.rs +++ b/src/cli/src/process.rs @@ -1,5 +1,7 @@ //! Shared process management utilities for CLI commands. +pub use a3s_box_runtime::{is_process_alive, is_process_alive_with_identity, pid_start_time}; + /// Result of asking a VM/shim process to stop. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StopOutcome { @@ -22,12 +24,6 @@ impl StopOutcome { } } -/// Check if a process is alive. -#[cfg(unix)] -pub fn is_process_alive(pid: u32) -> bool { - unsafe { libc::kill(pid as i32, 0) == 0 } -} - /// Whether `pid` has exited, treating a zombie (an exited-but-unreaped child) /// as exited. Unlike [`is_process_alive`], whose `kill(pid, 0)` succeeds for a /// zombie, this inspects `/proc//stat` on Linux so a detached box's shim — @@ -60,64 +56,6 @@ pub fn is_process_exited(pid: u32) -> bool { !is_process_alive(pid) } -/// Read a process's start time (field 22 of `/proc//stat`, in clock ticks -/// since boot) as a stable identity token. After a crash or reboot the kernel -/// can reassign the old shim PID to an unrelated process; the start time lets us -/// tell the original process apart from a reused PID. `None` when it cannot be -/// determined (non-Linux, or the process is already gone). -#[cfg(target_os = "linux")] -pub fn pid_start_time(pid: u32) -> Option { - let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; - // Format: " () ...". comm may contain spaces/parens, so - // scan past the final ')': the tokens after it begin at field 3 (state), so - // field 22 (starttime) is the 20th of those (index 19). - let after = &stat[stat.rfind(')')? + 1..]; - after - .split_whitespace() - .nth(19) - .and_then(|s| s.parse::().ok()) -} - -#[cfg(not(target_os = "linux"))] -pub fn pid_start_time(_pid: u32) -> Option { - None -} - -/// Liveness with PID-identity verification: the process is alive only if it -/// exists AND — when an identity token was recorded — its current start time -/// still matches. After a reboot or PID reuse the start time differs, so a stale -/// record is treated as dead and no signal is ever delivered to an unrelated -/// process. A `None` expected token (records persisted before this field -/// existed) falls back to the bare liveness check, so an in-place upgrade never -/// mis-marks a still-running box. -pub fn is_process_alive_with_identity(pid: u32, expected_start: Option) -> bool { - if !is_process_alive(pid) { - return false; - } - match expected_start { - Some(expected) => pid_start_time(pid) == Some(expected), - None => true, - } -} - -#[cfg(windows)] -pub fn is_process_alive(pid: u32) -> bool { - use windows_sys::Win32::Foundation::STILL_ACTIVE; - use windows_sys::Win32::System::Threading::{ - GetExitCodeProcess, OpenProcess, PROCESS_QUERY_INFORMATION, - }; - unsafe { - let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid); - if handle == 0 { - return false; - } - let mut exit_code = 0u32; - let ok = GetExitCodeProcess(handle, &mut exit_code); - windows_sys::Win32::Foundation::CloseHandle(handle); - ok != 0 && exit_code == STILL_ACTIVE as u32 - } -} - /// Terminate a process immediately. #[cfg(unix)] pub fn terminate_process(pid: u32) { diff --git a/src/cli/src/resolve.rs b/src/cli/src/resolve.rs index 34fe868e..6562a187 100644 --- a/src/cli/src/resolve.rs +++ b/src/cli/src/resolve.rs @@ -83,12 +83,15 @@ mod tests { short_id, name: name.to_string(), image: "test:latest".to_string(), + isolation: Default::default(), + managed_execution: None, status: "created".to_string(), pid: None, pid_start_time: None, cpus: 2, memory_mb: 512, volumes: vec![], + virtiofs_cache: None, env: HashMap::new(), cmd: vec![], entrypoint: None, diff --git a/src/cli/src/state/file.rs b/src/cli/src/state/file.rs index 6090a82a..9c7f28e6 100644 --- a/src/cli/src/state/file.rs +++ b/src/cli/src/state/file.rs @@ -2,13 +2,14 @@ use std::path::{Path, PathBuf}; +use a3s_box_runtime::BoxStateStore; + use super::BoxRecord; use crate::state::policy::{is_record_pid_live, should_restart}; /// Persistent state file backed by JSON. pub struct StateFile { - path: PathBuf, - pub(super) records: Vec, + store: BoxStateStore, } /// In-memory result of a reconcile pass: which records changed, and which dead @@ -26,33 +27,18 @@ struct ReconcileOutcome { impl StateFile { /// Load state from disk. Creates an empty state if the file doesn't exist. pub fn load(path: &Path) -> Result { - if path.exists() { - let data = std::fs::read_to_string(path)?; - let records = Self::parse_or_quarantine(path, &data); - let mut sf = Self { - path: path.to_path_buf(), - records, - }; - // Reconcile in memory so the caller sees accurate live/dead status. - let outcome = sf.reconcile(); - // Persist the change + run teardown under the state lock. Writing / - // tearing-down directly from this (often unlocked) read path was a - // lost-update + double-teardown race; flush_reconcile re-loads fresh - // under the lock so a concurrent run/monitor write is never clobbered. - if outcome.changed { - let _ = Self::flush_reconcile(path); - } - Ok(sf) - } else { - // Ensure parent directory exists - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - Ok(Self { - path: path.to_path_buf(), - records: Vec::new(), - }) + let mut state = Self { + store: BoxStateStore::load_or_quarantine(path)?, + }; + // Reconcile in memory so the caller sees accurate live/dead status. + let outcome = state.reconcile(); + // Persist the change + run teardown under the runtime-owned state + // transaction. A fresh locked read prevents lost updates and duplicate + // teardown when several readers observe the same dead execution. + if outcome.changed { + let _ = Self::flush_reconcile(path); } + Ok(state) } /// Load from the default path (~/.a3s/boxes.json). @@ -61,90 +47,14 @@ impl StateFile { Self::load(&home.join("boxes.json")) } - /// Load the default state WITHOUT the reconcile sweep (PID-liveness checks + - /// cleanup over every record). The append hot path (box registration) only adds - /// a record, so reconciling every *other* box under the global lock is pure - /// overhead — and under a high-concurrency fork burst it makes registration - /// O(N²) serialized syscalls. Reconcile still runs on every `list`/status load - /// and in the monitor, so liveness/exit-code/restart handling is not lost. - fn load_default_raw() -> Result { - let home = a3s_box_core::dirs_home(); - let path = home.join("boxes.json"); - if path.exists() { - let data = std::fs::read_to_string(&path)?; - let records = Self::parse_or_quarantine(&path, &data); - Ok(Self { path, records }) - } else { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - Ok(Self { - path, - records: Vec::new(), - }) - } - } - - /// Parse box records from an existing state file's contents. - /// - /// On a parse failure the corrupt file is NOT silently discarded. Discarding - /// it would let the next `write_to_disk` overwrite `boxes.json` with `[]`, - /// orphaning every running VM/overlay with no error and no recoverable - /// record. Instead the corrupt file is quarantined to a timestamped sibling - /// (`boxes.json.corrupt-`) and a loud warning is emitted, so the - /// data is preserved for recovery (restore it, then `a3s-box ps` re-reconciles; - /// otherwise repair manually) while the process starts from a clean empty - /// state rather than crashing. - fn parse_or_quarantine(path: &Path, data: &str) -> Vec { - match serde_json::from_str::>(data) { - Ok(records) => records, - Err(err) => { - let preserved = Self::quarantine_corrupt_file(path) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "".to_string()); - eprintln!( - "a3s-box: WARNING: state file {} is corrupt ({err}); preserved a \ - copy at {preserved} and started from empty state. Running boxes are \ - no longer tracked — their records are in the preserved copy; repair \ - and restore it, then `a3s-box ps` re-reconciles. Otherwise remove any \ - leaked VMs/overlays manually.", - path.display(), - ); - Vec::new() - } - } - } - - /// Move a corrupt state file aside to a timestamped sibling so it is not - /// overwritten by the next save. Falls back to a copy if rename fails - /// (e.g. cross-device). Returns the backup path on success. - fn quarantine_corrupt_file(path: &Path) -> Option { - let secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let backup = path.with_extension(format!("json.corrupt-{secs}")); - if std::fs::rename(path, &backup).is_ok() { - return Some(backup); - } - match std::fs::copy(path, &backup) { - Ok(_) => Some(backup), - Err(_) => None, - } - } - /// Load the default state **read-only**: no reconcile sweep, no PID-liveness - /// cleanup, no write-back, and — unlike [`load_default_raw`] — **no - /// quarantine** of a corrupt file. For consumers that only need a snapshot - /// of the records (e.g. metrics scraping) and must not cause side effects. + /// cleanup, no write-back, and no quarantine of a corrupt file. For + /// consumers that only need a snapshot of the records (e.g. metrics + /// scraping) and must not cause side effects. /// - /// `load_default_raw` quarantines a corrupt `boxes.json` by renaming it, - /// which mutates the filesystem and bypasses the cross-process state lock — - /// wrong for a lock-free, per-scrape reader. Here a corrupt file is surfaced - /// as an `Err` (no quarantine — a real writer does that under the lock) so the - /// caller can distinguish it from an empty/absent file: swallowing the parse - /// error to an empty snapshot made `/metrics` report a falsely-healthy - /// all-zeros result for a truncated state file instead of an error. + /// A corrupt file is surfaced as an `Err` so the caller can distinguish it + /// from an empty/absent file. Swallowing the parse error would make + /// `/metrics` report a falsely healthy all-zero snapshot. pub(crate) fn load_readonly() -> Result { let home = a3s_box_core::dirs_home(); Self::load_readonly_from(home.join("boxes.json")) @@ -152,32 +62,14 @@ impl StateFile { /// Inner [`load_readonly`] over an explicit path (testable). pub(crate) fn load_readonly_from(path: PathBuf) -> Result { - let records = if path.exists() { - let data = std::fs::read_to_string(&path)?; - serde_json::from_str(&data) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))? - } else { - Vec::new() - }; - Ok(Self { path, records }) + Ok(Self { + store: BoxStateStore::load_readonly(path)?, + }) } /// Save state to disk atomically under the cross-process state lock. pub fn save(&self) -> Result<(), std::io::Error> { - let _lock = super::lock::StateLock::acquire()?; - self.write_to_disk() - } - - /// Atomic write (tmp + rename) WITHOUT taking the state lock. Callers that - /// already hold the lock (`save`, `modify`, and `reconcile` which runs - /// inside `load`) use this to avoid re-locking (`flock` is not reentrant). - fn write_to_disk(&self) -> Result<(), std::io::Error> { - let data = serde_json::to_string_pretty(&self.records).map_err(std::io::Error::other)?; - let tmp_path = self.path.with_extension("json.tmp"); - // Durable atomic write (fsync tmp before rename + fsync parent dir): a - // hard crash must not leave a truncated boxes.json that then parses-fail - // and quarantines the WHOLE box inventory, orphaning every running VM. - a3s_box_core::fs_atomic::write_durable(&tmp_path, &self.path, data.as_bytes()) + self.store.save() } /// Atomically apply `f` to the on-disk state under the exclusive @@ -193,15 +85,21 @@ impl StateFile { where E: From, { - let _lock = super::lock::StateLock::acquire()?; - // Load WITHOUT the reconcile sweep: `modify` is a generic locked RMW for - // the caller's `f`, and reconcile now persists + tears down under its own - // lock (`flush_reconcile`) — running it here would deadlock (flock is not - // reentrant) once reconcile takes the lock. - let mut sf = Self::load_default_raw()?; - let out = f(&mut sf)?; - sf.write_to_disk()?; - Ok(out) + let path = a3s_box_core::dirs_home().join("boxes.json"); + BoxStateStore::modify_or_quarantine(&path, |store| Self::with_runtime_store(store, f)) + } + + fn with_runtime_store( + store: &mut BoxStateStore, + f: impl FnOnce(&mut StateFile) -> Result, + ) -> Result { + let placeholder = BoxStateStore::from_records(store.path().to_path_buf(), Vec::new()); + let mut state = Self { + store: std::mem::replace(store, placeholder), + }; + let output = f(&mut state); + *store = state.store; + output } /// Append a record atomically under the state lock (load fresh → push → @@ -210,25 +108,22 @@ impl StateFile { /// appending a box must not pay an O(N) PID-liveness/cleanup pass over every /// other box (the high-concurrency fork bottleneck). pub fn add_record(record: BoxRecord) -> Result<(), std::io::Error> { - let _lock = super::lock::StateLock::acquire()?; - let mut sf = Self::load_default_raw()?; - sf.records.push(record); - sf.write_to_disk() + let path = a3s_box_core::dirs_home().join("boxes.json"); + BoxStateStore::modify_or_quarantine(&path, |store| { + store.records_mut().push(record); + Ok::<(), std::io::Error>(()) + }) } /// Remove a record by id atomically under the state lock. Returns whether a /// record was removed. pub fn remove_record(id: &str) -> Result { - Self::modify(|sf| { - let before = sf.records.len(); - sf.records.retain(|r| r.id != id); - Ok::(sf.records.len() < before) - }) + Self::modify(|sf| Ok::(sf.store.remove_by_id(id))) } /// Add a record and persist. pub fn add(&mut self, record: BoxRecord) -> Result<(), std::io::Error> { - self.records.push(record); + self.store.records_mut().push(record); self.save() } @@ -238,14 +133,12 @@ impl StateFile { /// [`remove_record`](Self::remove_record); this keeps their in-memory view /// consistent without a second `save` that would clobber concurrent writers. pub(crate) fn forget(&mut self, id: &str) { - self.records.retain(|r| r.id != id); + self.store.remove_by_id(id); } /// Remove a record by ID and persist. pub fn remove(&mut self, id: &str) -> Result { - let len_before = self.records.len(); - self.records.retain(|r| r.id != id); - if self.records.len() < len_before { + if self.store.remove_by_id(id) { self.save()?; Ok(true) } else { @@ -255,42 +148,37 @@ impl StateFile { /// Find a record by exact ID. pub fn find_by_id(&self, id: &str) -> Option<&BoxRecord> { - self.records.iter().find(|r| r.id == id) + self.store.find_by_id(id) } /// Find a mutable record by exact ID. pub fn find_by_id_mut(&mut self, id: &str) -> Option<&mut BoxRecord> { - self.records.iter_mut().find(|r| r.id == id) + self.store.find_by_id_mut(id) } /// Find a record by exact name. pub fn find_by_name(&self, name: &str) -> Option<&BoxRecord> { - self.records.iter().find(|r| r.name == name) + self.store.find_by_name(name) } /// Find records matching an ID prefix (must be unique). pub fn find_by_id_prefix(&self, prefix: &str) -> Vec<&BoxRecord> { - self.records - .iter() - .filter(|r| r.id.starts_with(prefix) || r.short_id.starts_with(prefix)) - .collect() + self.store.find_by_id_prefix(prefix) } /// List records, optionally filtering to running-only. pub fn list(&self, all: bool) -> Vec<&BoxRecord> { - if all { - self.records.iter().collect() - } else { - self.records - .iter() - .filter(|r| r.status == "running") - .collect() - } + self.store.list(all) } /// All records (for iteration). pub fn records(&self) -> &[BoxRecord] { - &self.records + self.store.records() + } + + #[cfg(test)] + pub(super) fn records_mut(&mut self) -> &mut Vec { + self.store.records_mut() } /// Reconcile IN MEMORY: check PID liveness for active boxes, mark dead ones, @@ -307,27 +195,20 @@ impl StateFile { let mut auto_remove_records = Vec::new(); let mut stopped_resource_records = Vec::new(); - for record in &mut self.records { + for record in self.store.records_mut() { if !matches!(record.status.as_str(), "running" | "paused") { continue; } let has_live_pid = is_record_pid_live(record); if !has_live_pid { - // guest-init writes the container exit code into the overlay - // rootfs (`/.a3s_exit_code`) on exit; it surfaces on the host at - // /upper/.a3s_exit_code. Capture it here so a detached - // box's `wait`/`inspect` report the real code — libkrun's - // start_enter takeover means we can't waitpid the VM, so liveness - // polling alone would otherwise always yield exit 0. + // guest-init writes the container exit code into the writable + // rootfs (`/.a3s_exit_code`) on exit. Resolve the provider-specific + // host path so overlay, copy fallback, and APFS-backed rootfses all + // report the real code; liveness polling alone would yield exit 0. if record.exit_code.is_none() { - if let Ok(contents) = - std::fs::read_to_string(record.box_dir.join("upper").join(".a3s_exit_code")) - { - if let Ok(code) = contents.trim().parse::() { - record.exit_code = Some(code); - } - } + record.exit_code = + a3s_box_runtime::rootfs::read_persisted_exit_code(&record.box_dir); } record.status = "dead".to_string(); record.pid = None; @@ -349,7 +230,8 @@ impl StateFile { } if !auto_remove_records.is_empty() { - self.records + self.store + .records_mut() .retain(|record| !auto_remove_records.iter().any(|r| r.id == record.id)); changed = true; } @@ -368,35 +250,30 @@ impl StateFile { /// so a concurrent writer is never clobbered and two readers cannot tear down /// the same box twice (the second's fresh re-load sees the box already gone). fn flush_reconcile(path: &Path) -> std::io::Result<()> { - let _lock = super::lock::StateLock::acquire()?; - let records = if path.exists() { - let data = std::fs::read_to_string(path)?; - Self::parse_or_quarantine(path, &data) - } else { - Vec::new() - }; - let mut sf = Self { - path: path.to_path_buf(), - records, - }; - let outcome = sf.reconcile(); - if outcome.changed { - for record in &outcome.stopped { - crate::cleanup::cleanup_stopped_box(record); - } - for record in &outcome.removed { - crate::cleanup::cleanup_removed_box(record); - } - sf.write_to_disk()?; - } - Ok(()) + BoxStateStore::modify_or_quarantine(path, |store| { + Self::with_runtime_store(store, |state| { + let outcome = state.reconcile(); + if outcome.changed { + for record in &outcome.stopped { + crate::cleanup::cleanup_stopped_box(record) + .map_err(|error| std::io::Error::other(error.to_string()))?; + } + for record in &outcome.removed { + crate::cleanup::cleanup_removed_box(record) + .map_err(|error| std::io::Error::other(error.to_string()))?; + } + } + Ok::<(), std::io::Error>(()) + }) + }) } /// Get box IDs that are pending restart (dead boxes with active restart policy). /// /// This can be called after load to check if any boxes need restarting. pub fn pending_restarts(&self) -> Vec { - self.records + self.store + .records() .iter() .filter(|r| r.status == "dead" && should_restart(r)) .map(|r| r.id.clone()) @@ -405,7 +282,8 @@ impl StateFile { /// Find all records matching a label key-value pair. pub fn find_by_label(&self, key: &str, value: &str) -> Vec<&BoxRecord> { - self.records + self.store + .records() .iter() .filter(|r| r.labels.get(key).is_some_and(|v| v == value)) .collect() diff --git a/src/cli/src/state/lock.rs b/src/cli/src/state/lock.rs deleted file mode 100644 index 544b212e..00000000 --- a/src/cli/src/state/lock.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Cross-process advisory lock for the box state file. - -/// RAII exclusive advisory lock guarding `boxes.json` mutations. -/// -/// Held for the duration of a [`StateFile::modify`](super::StateFile::modify) -/// (and each [`save`](super::StateFile::save)) so concurrent processes — the -/// `monitor` daemon, `compose`, per-box health checkers, and plain CLI -/// commands — cannot interleave a read-modify-write and clobber each other's -/// fields (`save` rewrites the whole record vector). -/// -/// The lock lives on a sibling `boxes.json.lock` file, never on `boxes.json` -/// itself (whose atomic tmp+rename would swap the inode out from under a held -/// lock). `flock` is released automatically when the holder exits or crashes, -/// so a killed monitor/CLI never leaves a stale lock. -pub(crate) struct StateLock { - #[cfg(unix)] - _file: std::fs::File, -} - -impl StateLock { - /// Acquire the exclusive advisory lock, blocking until it is available. - #[cfg(unix)] - pub(crate) fn acquire() -> std::io::Result { - let path = a3s_box_core::dirs_home().join("boxes.json.lock"); - Self::acquire_path(&path) - } - - #[cfg(unix)] - fn acquire_path(path: &std::path::Path) -> std::io::Result { - use std::os::unix::io::AsRawFd; - - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let file = std::fs::OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(path)?; - // Blocking exclusive advisory lock; released when `file` drops. - if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(Self { _file: file }) - } - - /// Non-Unix fallback: the atomic tmp+rename in `save` still prevents torn - /// reads; multi-writer concurrency is not a supported Windows scenario. - #[cfg(not(unix))] - pub(crate) fn acquire() -> std::io::Result { - Ok(Self {}) - } -} - -#[cfg(all(test, unix))] -mod tests { - use super::*; - use std::sync::mpsc; - use std::time::Duration; - - #[test] - fn acquire_path_creates_parent_and_releases_on_drop() { - let tmp = tempfile::tempdir().unwrap(); - let lock_path = tmp.path().join("state").join("boxes.json.lock"); - - let guard = StateLock::acquire_path(&lock_path).unwrap(); - assert!(lock_path.exists()); - drop(guard); - - // Re-acquiring after drop proves the fd-backed flock was released. - let _guard = StateLock::acquire_path(&lock_path).unwrap(); - } - - #[test] - fn exclusive_lock_blocks_other_file_descriptors_until_released() { - let tmp = tempfile::tempdir().unwrap(); - let lock_path = tmp.path().join("boxes.json.lock"); - let guard = StateLock::acquire_path(&lock_path).unwrap(); - let thread_lock_path = lock_path.clone(); - let (tx, rx) = mpsc::channel(); - - let waiter = std::thread::spawn(move || { - let _guard = StateLock::acquire_path(&thread_lock_path).unwrap(); - tx.send(()).unwrap(); - }); - - assert!( - rx.recv_timeout(Duration::from_millis(100)).is_err(), - "second lock acquisition should block while the first guard is alive" - ); - - drop(guard); - rx.recv_timeout(Duration::from_secs(2)) - .expect("second lock acquisition should proceed after drop"); - waiter.join().unwrap(); - } -} diff --git a/src/cli/src/state/mod.rs b/src/cli/src/state/mod.rs index e00c7f9e..70df9790 100644 --- a/src/cli/src/state/mod.rs +++ b/src/cli/src/state/mod.rs @@ -4,222 +4,10 @@ //! On every load, dead active PIDs are reconciled to mark boxes as dead. mod file; -mod lock; pub(crate) mod policy; #[cfg(test)] mod tests; +pub use a3s_box_runtime::{BoxRecord, HealthCheck}; pub use file::StateFile; pub use policy::{generate_name, parse_restart_policy, validate_restart_policy}; - -use std::collections::HashMap; -use std::path::PathBuf; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// Metadata record for a single box instance. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BoxRecord { - /// Full UUID - pub id: String, - /// First 12 hex chars of the UUID (no dashes) - pub short_id: String, - /// User-assigned or auto-generated name - pub name: String, - /// OCI image reference - pub image: String, - /// "created" | "running" | "stopped" | "dead" - pub status: String, - /// Shim process PID (set when running) - pub pid: Option, - /// Start-time identity token (`/proc//stat` field 22) captured when - /// `pid` was recorded, so a reused PID after a crash/reboot is not mistaken - /// for the original shim. `None` for records written before this field - /// existed (they fall back to a bare liveness check). - #[serde(default)] - pub pid_start_time: Option, - /// Number of vCPUs - pub cpus: u32, - /// Memory in MB - pub memory_mb: u32, - /// Volume mounts ("host:guest" pairs) - pub volumes: Vec, - /// Environment variables - pub env: HashMap, - /// Command override - pub cmd: Vec, - /// Entrypoint override (if set via --entrypoint) - #[serde(default)] - pub entrypoint: Option>, - /// Box working directory (~/.a3s/boxes//) - pub box_dir: PathBuf, - /// Path to exec socket - #[serde(default)] - pub exec_socket_path: PathBuf, - /// Path to console log - pub console_log: PathBuf, - /// Creation timestamp - pub created_at: DateTime, - /// Start timestamp - pub started_at: Option>, - /// Whether to auto-remove on stop - pub auto_remove: bool, - /// Custom hostname for the box - #[serde(default)] - pub hostname: Option, - /// User to run as inside the box - #[serde(default)] - pub user: Option, - /// Working directory inside the box - #[serde(default)] - pub workdir: Option, - /// Restart policy: "no", "always", "on-failure", "unless-stopped" - #[serde(default = "default_restart_policy")] - pub restart_policy: String, - /// Port mappings ("host_port:guest_port" pairs) - #[serde(default)] - pub port_map: Vec, - /// User-defined labels (key=value metadata) - #[serde(default)] - pub labels: HashMap, - /// Whether the box was explicitly stopped by the user (for "unless-stopped" policy) - #[serde(default)] - pub stopped_by_user: bool, - /// Number of automatic restarts performed - #[serde(default)] - pub restart_count: u32, - /// Maximum restart count for "on-failure:N" policy (0 = unlimited) - #[serde(default)] - pub max_restart_count: u32, - /// Exit code from the last run (None if not yet captured) - #[serde(default)] - pub exit_code: Option, - /// Health check configuration - #[serde(default)] - pub health_check: Option, - /// Whether image-defined health checks were explicitly disabled. - #[serde(default)] - pub healthcheck_disabled: bool, - /// Current health status: "none", "starting", "healthy", "unhealthy" - #[serde(default = "default_health_status")] - pub health_status: String, - /// Consecutive health check failures - #[serde(default)] - pub health_retries: u32, - /// Timestamp of last health check - #[serde(default)] - pub health_last_check: Option>, - /// Network mode for this box - #[serde(default)] - pub network_mode: a3s_box_core::NetworkMode, - /// Network name (if connected to a bridge network) - #[serde(default)] - pub network_name: Option, - /// Named volumes attached to this box - #[serde(default)] - pub volume_names: Vec, - /// tmpfs mounts for this box - #[serde(default)] - pub tmpfs: Vec, - /// Anonymous volumes auto-created from OCI VOLUME directives - #[serde(default)] - pub anonymous_volumes: Vec, - /// Resource limits (PID limits, CPU pinning, ulimits, cgroup controls) - #[serde(default)] - pub resource_limits: a3s_box_core::config::ResourceLimits, - /// Logging configuration - #[serde(default)] - pub log_config: a3s_box_core::log::LogConfig, - /// Custom host-to-IP mappings (host:ip) - #[serde(default)] - pub add_host: Vec, - /// Target platform (e.g., "linux/amd64") - #[serde(default)] - pub platform: Option, - /// Use init process as PID 1 - #[serde(default)] - pub init: bool, - /// Read-only root filesystem - #[serde(default)] - pub read_only: bool, - /// Added Linux capabilities - #[serde(default)] - pub cap_add: Vec, - /// Dropped Linux capabilities - #[serde(default)] - pub cap_drop: Vec, - /// Security options - #[serde(default)] - pub security_opt: Vec, - /// Extended privileges - #[serde(default)] - pub privileged: bool, - /// Device mappings (host_path:guest_path:perms) - #[serde(default)] - pub devices: Vec, - /// GPU devices - #[serde(default)] - pub gpus: Option, - /// Shared memory size in bytes - #[serde(default)] - pub shm_size: Option, - /// Signal to stop the box - #[serde(default)] - pub stop_signal: Option, - /// Timeout to stop the box before killing - #[serde(default)] - pub stop_timeout: Option, - /// OOM killer disabled - #[serde(default)] - pub oom_kill_disable: bool, - /// OOM score adjustment - #[serde(default)] - pub oom_score_adj: Option, -} - -fn default_health_status() -> String { - "none".to_string() -} - -fn default_restart_policy() -> String { - "no".to_string() -} - -/// Health check configuration for a box. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HealthCheck { - /// Command to run for health check (via exec channel) - pub cmd: Vec, - /// Check interval in seconds (default: 30) - #[serde(default = "default_health_interval")] - pub interval_secs: u64, - /// Per-check timeout in seconds (default: 5) - #[serde(default = "default_health_timeout")] - pub timeout_secs: u64, - /// Consecutive failures before marking unhealthy (default: 3) - #[serde(default = "default_health_retries")] - pub retries: u32, - /// Grace period after start before checks begin, in seconds (default: 0) - #[serde(default)] - pub start_period_secs: u64, -} - -fn default_health_interval() -> u64 { - 30 -} - -fn default_health_timeout() -> u64 { - 5 -} - -fn default_health_retries() -> u32 { - 3 -} - -impl BoxRecord { - /// Generate a short ID from a full UUID (first 12 hex characters, no dashes). - pub fn make_short_id(id: &str) -> String { - id.replace('-', "").chars().take(12).collect() - } -} diff --git a/src/cli/src/state/tests.rs b/src/cli/src/state/tests.rs index 045b1f00..cda47be1 100644 --- a/src/cli/src/state/tests.rs +++ b/src/cli/src/state/tests.rs @@ -2,6 +2,9 @@ use super::*; use std::collections::HashMap; +use std::path::PathBuf; + +use chrono::Utc; use tempfile::TempDir; fn test_state_path(tmp: &TempDir) -> PathBuf { @@ -15,6 +18,8 @@ fn sample_record(id: &str, name: &str, status: &str) -> BoxRecord { short_id, name: name.to_string(), image: "alpine:latest".to_string(), + isolation: Default::default(), + managed_execution: None, status: status.to_string(), pid: if status == "running" { Some(99999) @@ -25,6 +30,7 @@ fn sample_record(id: &str, name: &str, status: &str) -> BoxRecord { cpus: 2, memory_mb: 512, volumes: vec![], + virtiofs_cache: None, env: HashMap::new(), cmd: vec![], entrypoint: None, @@ -506,7 +512,7 @@ fn test_reconcile_marks_dead_pid() { // Manually set to running with an impossible PID record.status = "running".to_string(); record.pid = Some(4294967); // Very unlikely to be a real process - sf.records.push(record); + sf.records_mut().push(record); sf.save().unwrap(); } @@ -520,33 +526,46 @@ fn test_reconcile_marks_dead_pid() { } #[test] -fn test_reconcile_reads_exit_code_from_upper() { - let tmp = TempDir::new().unwrap(); - let path = test_state_path(&tmp); - - // guest-init persists the container exit code into the overlay upperdir - // (/upper/.a3s_exit_code) since libkrun's start_enter takeover - // prevents the host from waitpid-ing a detached VM. - let box_dir = tmp.path().join("boxes").join("exitcode-id"); - std::fs::create_dir_all(box_dir.join("upper")).unwrap(); - std::fs::write(box_dir.join("upper").join(".a3s_exit_code"), "42").unwrap(); - - { - let mut sf = StateFile::load(&path).unwrap(); - let mut record = sample_record("exitcode-id", "exitcode_box", "created"); - record.status = "running".to_string(); - record.pid = Some(4294967); // dead pid -> reconcile marks it dead - record.box_dir = box_dir.clone(); - sf.records.push(record); - sf.save().unwrap(); - } - - // Reconcile on reload marks it dead AND captures the persisted exit code. - { +fn test_reconcile_reads_exit_code_from_each_rootfs_layout() { + // guest-init persists the container exit code into the active writable + // rootfs. The host path differs for overlay, copy fallback, and the private + // data directory inside the macOS APFS-backed rootfs. + for (relative_path, expected) in [ + ("upper/.a3s_exit_code", 41), + ("rootfs/.a3s_exit_code", 42), + ("rootfs/.a3s-rootfs/.a3s_exit_code", 43), + ] { + let tmp = TempDir::new().unwrap(); + let path = test_state_path(&tmp); + let box_dir = tmp.path().join("boxes").join("exitcode-id"); + let exit_code_path = box_dir.join(relative_path); + std::fs::create_dir_all(exit_code_path.parent().unwrap()).unwrap(); + std::fs::write(&exit_code_path, expected.to_string()).unwrap(); + + { + let mut sf = StateFile::load(&path).unwrap(); + let mut record = sample_record("exitcode-id", "exitcode_box", "created"); + record.status = "running".to_string(); + record.pid = Some(4294967); // dead pid -> reconcile marks it dead + record.box_dir = box_dir; + sf.records_mut().push(record); + sf.save().unwrap(); + } + + // Reconcile on reload marks it dead and captures the authoritative + // provider-specific exit code for inspect/wait consumers. let sf = StateFile::load(&path).unwrap(); let record = sf.find_by_id("exitcode-id").unwrap(); - assert_eq!(record.status, "dead"); - assert_eq!(record.exit_code, Some(42)); + assert_eq!(record.status, "dead", "layout: {relative_path}"); + assert_eq!(record.exit_code, Some(expected), "layout: {relative_path}"); + + // The reconcile flush must persist the captured value so a later + // inspect/wait process sees the same result after the rootfs is gone. + drop(sf); + let persisted = StateFile::load(&path).unwrap(); + let record = persisted.find_by_id("exitcode-id").unwrap(); + assert_eq!(record.status, "dead", "layout: {relative_path}"); + assert_eq!(record.exit_code, Some(expected), "layout: {relative_path}"); } } @@ -560,7 +579,7 @@ fn test_reconcile_running_without_pid() { let mut record = sample_record("no-pid-id", "no_pid_box", "created"); record.status = "running".to_string(); record.pid = None; // Running but no PID - sf.records.push(record); + sf.records_mut().push(record); sf.save().unwrap(); } @@ -587,7 +606,7 @@ fn test_reconcile_dead_running_box_removes_external_socket_dir() { record.pid = None; record.box_dir = box_dir.clone(); record.exec_socket_path = external_socket_dir.join("exec.sock"); - sf.records.push(record); + sf.records_mut().push(record); sf.save().unwrap(); } @@ -616,7 +635,7 @@ fn test_reconcile_paused_without_pid_removes_external_socket_dir() { record.pid = None; record.box_dir = box_dir.clone(); record.exec_socket_path = external_socket_dir.join("exec.sock"); - sf.records.push(record); + sf.records_mut().push(record); sf.save().unwrap(); } @@ -648,7 +667,7 @@ fn test_concurrent_reconcile_load_tears_down_once_no_panic() { record.auto_remove = true; record.box_dir = box_dir.clone(); record.exec_socket_path = box_dir.join("sockets").join("exec.sock"); - sf.records.push(record); + sf.records_mut().push(record); sf.save().unwrap(); } @@ -684,7 +703,7 @@ fn test_reconcile_auto_removes_dead_running_box() { record.auto_remove = true; record.box_dir = box_dir.clone(); record.exec_socket_path = box_dir.join("sockets").join("exec.sock"); - sf.records.push(record); + sf.records_mut().push(record); sf.save().unwrap(); } @@ -1047,6 +1066,7 @@ fn test_box_record_backward_compat_no_health() { val.as_object_mut().unwrap().remove("restart_count"); val.as_object_mut().unwrap().remove("max_restart_count"); val.as_object_mut().unwrap().remove("exit_code"); + val.as_object_mut().unwrap().remove("isolation"); let parsed: BoxRecord = serde_json::from_value(val).unwrap(); assert!(parsed.health_check.is_none()); @@ -1057,6 +1077,18 @@ fn test_box_record_backward_compat_no_health() { assert_eq!(parsed.restart_count, 0); assert_eq!(parsed.max_restart_count, 0); assert!(parsed.exit_code.is_none()); + assert_eq!(parsed.isolation, a3s_box_core::ExecutionIsolation::Microvm); +} + +#[test] +fn test_box_record_round_trips_sandbox_isolation() { + let mut record = sample_record("sandbox-id", "sandbox", "created"); + record.isolation = a3s_box_core::ExecutionIsolation::Sandbox; + + let json = serde_json::to_string(&record).unwrap(); + let parsed: BoxRecord = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.isolation, a3s_box_core::ExecutionIsolation::Sandbox); } // --- Restart policy validation tests --- diff --git a/src/cli/src/test_helpers.rs b/src/cli/src/test_helpers.rs index 71a00ed5..af23b7eb 100644 --- a/src/cli/src/test_helpers.rs +++ b/src/cli/src/test_helpers.rs @@ -15,12 +15,15 @@ pub mod fixtures { short_id, name: name.to_string(), image: "alpine:latest".to_string(), + isolation: Default::default(), + managed_execution: None, status: status.to_string(), pid, pid_start_time: None, cpus: 2, memory_mb: 512, volumes: vec![], + virtiofs_cache: None, env: HashMap::new(), cmd: vec![], entrypoint: None, diff --git a/src/cli/tests/command_coverage.rs b/src/cli/tests/command_coverage.rs index dc698886..218d531c 100644 --- a/src/cli/tests/command_coverage.rs +++ b/src/cli/tests/command_coverage.rs @@ -167,6 +167,22 @@ fn test_nested_subcommand_help() { &["compose", "ps"], &["compose", "logs"], &["compose", "config"], + &["compose", "start"], + &["compose", "stop"], + &["compose", "restart"], + &["compose", "rm"], + &["compose", "kill"], + &["compose", "pause"], + &["compose", "unpause"], + &["compose", "wait"], + &["compose", "exec"], + &["compose", "top"], + &["compose", "port"], + &["compose", "cp"], + &["compose", "images"], + &["compose", "pull"], + &["compose", "ls"], + &["compose", "volumes"], ]; for command in commands { @@ -388,6 +404,8 @@ fn test_create_persists_rich_runtime_options() { "4", "--memory", "768m", + "--dns", + "1.1.1.1", "--volume", "covrichdata:/data:ro", "--env-file", @@ -531,6 +549,44 @@ fn test_create_persists_rich_runtime_options() { assert_eq!(inspect["stop_timeout"], 12); assert_eq!(inspect["oom_kill_disable"], true); assert_eq!(inspect["oom_score_adj"], 100); + assert!( + !std::path::Path::new(inspect["box_dir"].as_str().expect("box directory path")).exists(), + "reservation-only create must not allocate a runtime directory" + ); + + let managed = &inspect["managed_execution"]; + assert_eq!(managed["generation"], 1); + assert!(managed["pending_operation"].is_null()); + assert!(managed["operation_id"] + .as_str() + .expect("managed operation ID") + .starts_with("cli-create-")); + assert!(managed["request"]["external_sandbox_id"] + .as_str() + .expect("external diagnostic ID") + .starts_with("cli-create-")); + assert_eq!( + managed["request"]["config"]["image"], + "docker.io/library/alpine:latest" + ); + assert_eq!( + managed["request"]["config"]["dns"], + serde_json::json!(["1.1.1.1"]) + ); + assert_eq!( + managed["request"]["config"]["extra_env"], + serde_json::json!([ + ["FROM_FILE", "kept"], + ["INLINE", "present"], + ["OVERRIDE", "cli"] + ]) + ); + assert_eq!(managed["request"]["config"]["persistent"], true); + assert_eq!(managed["request"]["policy"]["name"], "cov-rich"); + assert_eq!(managed["request"]["policy"]["restart_policy"], "on-failure"); + assert_eq!(managed["request"]["policy"]["max_restart_count"], 3); + assert_eq!(managed["request"]["policy"]["stop_timeout"], 12); + assert_eq!(managed["request"]["labels"]["purpose"], "coverage"); let volume = cli.ok(&["volume", "inspect", "covrichdata"]); assert!(volume.contains("covrichdata")); @@ -620,3 +676,259 @@ fn test_noninteractive_boundary_command_smoke() { "monitor did not announce startup\nstdout:\n{stdout}\nstderr:\n{stderr}" ); } + +#[test] +fn test_compose_config_interpolates_shell_over_project_dotenv() { + let cli = CliTest::new(); + let project = cli.home_path().join("compose-interpolation"); + std::fs::create_dir_all(&project).expect("create Compose project directory"); + let compose = project.join("compose.yaml"); + std::fs::write( + &compose, + r#"services: + redis: + image: redis:7-alpine + ports: + - "${A3S_COMPOSE_TEST_PORT:-6379}:6379" + environment: + FROM_DOTENV: ${A3S_COMPOSE_TEST_DOTENV-default} + EMPTY_DEFAULT: ${A3S_COMPOSE_TEST_EMPTY:-default} + EMPTY_SET: ${A3S_COMPOSE_TEST_EMPTY+replacement} + EMPTY_NONEMPTY: ${A3S_COMPOSE_TEST_EMPTY:+replacement} +"#, + ) + .expect("write Compose file"); + std::fs::write( + project.join(".env"), + "A3S_COMPOSE_TEST_PORT=6379\nA3S_COMPOSE_TEST_DOTENV=dotenv\nA3S_COMPOSE_TEST_EMPTY=\n", + ) + .expect("write project .env"); + let compose_arg = compose.to_string_lossy().to_string(); + + let output = cli.ok_with_env( + &["compose", "--file", &compose_arg, "config"], + &[("A3S_COMPOSE_TEST_PORT", "16379")], + ); + + assert!(output.contains("ports: 16379:6379")); + assert!(output.contains("FROM_DOTENV=dotenv")); + assert!(output.contains("EMPTY_DEFAULT=default")); + assert!(output.contains("EMPTY_SET=replacement")); + assert!(output.contains("EMPTY_NONEMPTY=")); + assert!(output.contains("Configuration is valid.")); +} + +#[test] +fn test_compose_config_accepts_a3s_acl() { + let cli = CliTest::new(); + let project = cli.home_path().join("compose-acl"); + std::fs::create_dir_all(&project).expect("create Compose ACL project directory"); + let compose = project.join("compose.acl"); + std::fs::write( + &compose, + r#"service "api" { + image = "ghcr.io/a3s-lab/api:${A3S_COMPOSE_TEST_TAG:-latest}" + environment = { TOKEN = env("A3S_COMPOSE_TEST_TOKEN") } + labels = { description = "服务" } + ports = ["8080:8080"] + + healthcheck { + test = ["CMD", "true"] + } +} +"#, + ) + .expect("write Compose ACL file"); + let compose_arg = compose.to_string_lossy().to_string(); + + let output = cli.ok_with_env( + &["compose", "--file", &compose_arg, "config"], + &[("A3S_COMPOSE_TEST_TOKEN", "secret")], + ); + + assert!(output.contains("ghcr.io/a3s-lab/api:latest")); + assert!(output.contains("TOKEN=secret")); + assert!(output.contains("ports: 8080:8080")); + assert!(output.contains("Configuration is valid.")); +} + +#[test] +fn test_compose_local_project_views_do_not_require_a_runtime() { + let cli = CliTest::new(); + let project = cli.home_path().join("compose-views"); + std::fs::create_dir_all(&project).expect("create Compose project directory"); + let compose = project.join("compose.yaml"); + std::fs::write( + &compose, + r#"services: + api: + image: ghcr.io/a3s-lab/api:latest + depends_on: [db] + db: + image: postgres:17 + volumes: + - data:/var/lib/postgresql/data +volumes: + data: +"#, + ) + .expect("write Compose file"); + let compose_arg = compose.to_string_lossy().to_string(); + + let images = cli.ok(&["compose", "--file", &compose_arg, "images"]); + assert!(images.contains("api")); + assert!(images.contains("ghcr.io/a3s-lab/api:latest")); + assert!(images.contains("postgres:17")); + + let volumes = cli.ok(&["compose", "--file", &compose_arg, "volumes"]); + assert_eq!(volumes.trim(), "data"); + + let projects = cli.ok(&["compose", "ls"]); + assert!(projects.contains("NAME")); + + cli.fails( + &["compose", "--file", &compose_arg, "logs", "api", "missing"], + "service 'missing' is not defined in the Compose project", + ); +} + +#[cfg(unix)] +#[test] +fn test_pool_status_json_reports_runtime_counters() { + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + + let cli = CliTest::new(); + let socket = cli.home_path().join("fake-pool.sock"); + let listener = UnixListener::bind(&socket).expect("bind fake pool socket"); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept pool status client"); + let mut len = [0_u8; 4]; + stream.read_exact(&mut len).expect("read request length"); + let mut request = vec![0_u8; u32::from_le_bytes(len) as usize]; + stream + .read_exact(&mut request) + .expect("read status request"); + let request: serde_json::Value = + serde_json::from_slice(&request).expect("status request should be JSON"); + assert_eq!(request["op"], "status"); + + let response = br#"{"images":[{"image":"alpine:latest","pool":"alpine:latest|vcpus=2|memory=512m","max":4,"idle":2,"active":1,"leased":1,"total_created":5,"total_acquired":3,"total_evicted":1}]}"#; + stream + .write_all(&(response.len() as u32).to_le_bytes()) + .expect("write response length"); + stream.write_all(response).expect("write status response"); + }); + + let socket_arg = socket.to_string_lossy().to_string(); + let stdout = cli.ok(&["pool", "status", "--json", "--socket", socket_arg.as_str()]); + server.join().expect("fake pool server should finish"); + + let value: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("pool status output should be JSON"); + let image = &value.as_array().expect("status JSON should be an array")[0]; + assert_eq!(image["pool"], "alpine:latest|vcpus=2|memory=512m"); + assert_eq!(image["max"], 4); + assert_eq!(image["idle"], 2); + assert_eq!(image["active"], 1); + assert_eq!(image["leased"], 1); + assert_eq!(image["total_created"], 5); + assert_eq!(image["total_acquired"], 3); + assert_eq!(image["total_evicted"], 1); +} + +#[cfg(unix)] +#[test] +fn test_info_reports_warm_pool_daemon_status() { + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + + let cli = CliTest::new(); + let socket = cli.home_path().join("fake-info-pool.sock"); + let listener = UnixListener::bind(&socket).expect("bind fake pool socket"); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept pool status client"); + let mut len = [0_u8; 4]; + stream.read_exact(&mut len).expect("read status length"); + let mut request = vec![0_u8; u32::from_le_bytes(len) as usize]; + stream + .read_exact(&mut request) + .expect("read status request"); + let request: serde_json::Value = + serde_json::from_slice(&request).expect("status request should be JSON"); + assert_eq!(request["op"], "status"); + + let response = br#"{"images":[{"image":"alpine:latest","pool":"alpine:latest|vcpus=2|memory=512m","max":4,"idle":2,"active":1,"leased":1,"total_created":5,"total_acquired":3,"total_evicted":1}]}"#; + stream + .write_all(&(response.len() as u32).to_le_bytes()) + .expect("write status response length"); + stream.write_all(response).expect("write status response"); + }); + + let socket_arg = socket.to_string_lossy().to_string(); + let stdout = cli.ok_with_env( + &["info"], + &[("A3S_BOX_RUN_POOL_SOCKET", socket_arg.as_str())], + ); + server.join().expect("fake pool server should finish"); + + assert!( + stdout.contains("Warm pool daemon: running at"), + "info output did not report a running warm pool:\n{stdout}" + ); + assert!(stdout.contains("max 4")); + assert!(stdout.contains("2 idle")); + assert!(stdout.contains("1 active")); + assert!(stdout.contains("1 leased")); +} + +#[cfg(unix)] +#[test] +fn test_run_pool_timeout_is_sent_to_daemon() { + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + + let cli = CliTest::new(); + let socket = cli.home_path().join("fake-run-pool.sock"); + let listener = UnixListener::bind(&socket).expect("bind fake pool socket"); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept pool run client"); + let mut len = [0_u8; 4]; + stream.read_exact(&mut len).expect("read request length"); + let mut request = vec![0_u8; u32::from_le_bytes(len) as usize]; + stream.read_exact(&mut request).expect("read run request"); + let request: serde_json::Value = + serde_json::from_slice(&request).expect("run request should be JSON"); + assert_eq!(request["op"], "run"); + assert_eq!(request["image"], "docker.io/library/alpine:latest"); + assert_eq!(request["timeout_ns"], 7_000_000_000_u64); + assert_eq!(request["cmd"][0], "echo"); + + let response = br#"{"stdout":[111,107,10],"stderr":[],"exit_code":0,"error":null}"#; + stream + .write_all(&(response.len() as u32).to_le_bytes()) + .expect("write response length"); + stream.write_all(response).expect("write run response"); + }); + + let socket_arg = socket.to_string_lossy().to_string(); + let stdout = cli.ok(&[ + "run", + "--pool", + "--pool-socket", + socket_arg.as_str(), + "--rm", + "--timeout", + "7", + "docker.io/library/alpine:latest", + "--", + "echo", + "ok", + ]); + server.join().expect("fake pool server should finish"); + + assert_eq!(stdout, "ok\n"); +} diff --git a/src/cli/tests/core_smoke.rs b/src/cli/tests/core_smoke.rs index 00c51f14..984237d0 100644 --- a/src/cli/tests/core_smoke.rs +++ b/src/cli/tests/core_smoke.rs @@ -494,6 +494,26 @@ impl CoreSmoke { panic!("timeout waiting for {name} to become {expected}\nlast inspect:\n{last}"); } + fn wait_for_named_health(&self, name: &str, expected: &str) -> serde_json::Value { + let start = Instant::now(); + let mut last = String::new(); + + while start.elapsed() < self.timeout { + let value = self.inspect_json(name); + last = value.to_string(); + if json_string_field(&value, "health_status") == expected + && value + .get("health_last_check") + .is_some_and(|value| !value.is_null()) + { + return value; + } + std::thread::sleep(Duration::from_millis(500)); + } + + panic!("timeout waiting for {name} health={expected}\nlast inspect:\n{last}"); + } + fn wait_for_named_restart( &self, monitor: &mut BackgroundCommand, @@ -1104,6 +1124,91 @@ fn real_core_published_port_http_smoke() { smoke.ok(&["rm", &smoke.name]); } +#[cfg(target_os = "macos")] +#[test] +#[ignore] +fn real_core_tsi_published_redis_nonblocking_accept_and_exec() { + use std::io::{Read, Write}; + + let smoke = CoreSmoke::new(); + let image = + std::env::var("A3S_BOX_REDIS_SMOKE_IMAGE").unwrap_or_else(|_| "redis:7-alpine".to_string()); + let host_port = unused_tcp_port(); + let publish = format!("{host_port}:6379"); + let _cleanup = NamedBoxCleanup { + smoke: &smoke, + name: smoke.name.clone(), + }; + + smoke.ok(&["pull", &image]); + smoke.ok(&["run", "-d", "--name", &smoke.name, "-p", &publish, &image]); + smoke.wait_for_running(); + smoke.wait_for_logs("Ready to accept connections"); + + let address = std::net::SocketAddr::from(([127, 0, 0, 1], host_port)); + for connection in 1..=2 { + let mut stream = std::net::TcpStream::connect_timeout(&address, Duration::from_secs(5)) + .unwrap_or_else(|error| panic!("connect Redis client {connection}: {error}")); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + stream.write_all(b"*1\r\n$4\r\nPING\r\n").unwrap(); + let mut pong = [0u8; 7]; + stream + .read_exact(&mut pong) + .unwrap_or_else(|error| panic!("read Redis PONG for client {connection}: {error}")); + assert_eq!(&pong, b"+PONG\r\n"); + } + + let exec = smoke.ok(&[ + "exec", + &smoke.name, + "--", + "/bin/sh", + "-c", + "echo EXEC_AFTER_PONG", + ]); + assert_contains(&exec, "EXEC_AFTER_PONG", "exec after Redis PONG"); + + smoke.ok(&["rm", "-f", &smoke.name]); +} + +#[test] +#[ignore] +fn real_core_virtiofs_tar_closes_every_source_file_cleanly() { + let smoke = CoreSmoke::new(); + let image = smoke_image(); + let source = tempfile::tempdir().expect("temporary virtiofs source"); + for directory in 0..16 { + let root = source.path().join(format!("tree-{directory}")); + std::fs::create_dir_all(&root).expect("create source directory"); + for file in 0..128 { + std::fs::write( + root.join(format!("file-{file}.txt")), + format!("virtiofs-close-{directory}-{file}\n"), + ) + .expect("write source fixture"); + } + } + let mount = format!("{}:/source:ro", source.path().display()); + + seed_smoke_image(&smoke, &image); + smoke.ok(&[ + "run", + "--rm", + "--virtiofs-cache=none", + "-v", + &mount, + "-w", + "/source", + &image, + "--", + "/bin/sh", + "-c", + "set -eu; for pass in 1 2 3 4 5; do tar -cf /dev/null .; done", + ]); +} + #[test] #[ignore] fn real_core_named_volume_persists_across_stop_start() { @@ -1225,7 +1330,7 @@ fn real_core_bridge_network_hosts_and_endpoint_lifecycle() { "--", "/bin/sh", "-c", - "echo core-smoke-bridge-db-ready; sleep 3600", + "mkdir -p /www; printf core-smoke-bridge-peer-ok >/www/index.html; echo core-smoke-bridge-db-ready; exec httpd -f -p 8080 -h /www", ]); smoke.wait_for_named_running(&db_box); smoke.wait_for_named_logs(&db_box, "core-smoke-bridge-db-ready"); @@ -1256,6 +1361,22 @@ fn real_core_bridge_network_hosts_and_endpoint_lifecycle() { assert_contains(&inspect, "10.91.0.2", "network inspect"); assert_contains(&inspect, "10.91.0.3", "network inspect"); + let peer_http = smoke.ok(&[ + "exec", + &web_box, + "--", + "/bin/sh", + "-c", + &format!( + "test \"$(wget -T 5 -qO- http://{db_box}:8080)\" = core-smoke-bridge-peer-ok; test \"$(wget -T 5 -qO- http://10.91.0.2:8080)\" = core-smoke-bridge-peer-ok; echo core-smoke-bridge-peer-tcp-ok" + ), + ]); + assert_contains( + &peer_http, + "core-smoke-bridge-peer-tcp-ok", + "bridge peer TCP by name and IP", + ); + smoke.ok(&["stop", &web_box]); smoke.ok(&["rm", &web_box]); smoke.ok(&["stop", &db_box]); @@ -1518,6 +1639,68 @@ fn real_core_filesystem_image_snapshot_commands() { smoke.ok(&["rm", &smoke.name]); } +#[cfg(unix)] +#[test] +#[ignore] +fn real_core_commit_preserves_guest_ownership_and_modes_after_stop() { + let smoke = CoreSmoke::new(); + let image = smoke_image(); + let committed_image = format!("{}-metadata:latest", smoke.name); + let _box_cleanup = NamedBoxCleanup { + smoke: &smoke, + name: smoke.name.clone(), + }; + let _image_cleanup = ImageCleanup { + smoke: &smoke, + reference: committed_image.clone(), + }; + seed_smoke_image(&smoke, &image); + + smoke.ok(&[ + "run", + "--name", + &smoke.name, + "--persistent", + &image, + "--", + "/bin/sh", + "-c", + "cp /bin/busybox /tmp/root-exec; chmod 0755 /tmp/root-exec; \ + cp /bin/busybox /tmp/user-exec; chown 123:456 /tmp/user-exec; chmod 0750 /tmp/user-exec; \ + printf config >/tmp/config; chmod 0644 /tmp/config; \ + printf secret >/tmp/secret; chmod 0600 /tmp/secret; \ + mkdir /tmp/mode-dir; chmod 0711 /tmp/mode-dir; \ + ln -s root-exec /tmp/root-link", + ]); + smoke.wait_for_named_status(&smoke.name, "stopped"); + + smoke.ok(&["commit", &smoke.name, &committed_image]); + let output = smoke.ok(&[ + "run", + "--rm", + &committed_image, + "--", + "/bin/sh", + "-c", + "stat -c '%u:%g:%a' /tmp/root-exec /tmp/user-exec /tmp/config /tmp/secret /tmp/mode-dir; \ + test -x /tmp/root-exec; test \"$(readlink /tmp/root-link)\" = root-exec", + ]); + + let lines: Vec<_> = output + .lines() + .filter(|line| { + line.matches(':').count() == 2 + && line + .chars() + .all(|character| character.is_ascii_digit() || character == ':') + }) + .collect(); + assert_eq!( + lines, + ["0:0:755", "123:456:750", "0:0:644", "0:0:600", "0:0:711"] + ); +} + #[test] #[ignore] fn real_core_snapshot_cow_isolation_and_rm_guard() { @@ -1710,6 +1893,43 @@ fn real_core_restart_policy_monitor_recovers_dead_box() { smoke.ok(&["rm", &smoke.name]); } +#[cfg(unix)] +#[test] +#[ignore] +fn real_core_detached_health_worker_survives_run_cli_exit() { + let smoke = CoreSmoke::new(); + let image = smoke_image(); + seed_smoke_image(&smoke, &image); + let _cleanup = NamedBoxCleanup { + smoke: &smoke, + name: smoke.name.clone(), + }; + + smoke.ok(&[ + "run", + "--detach", + "--name", + &smoke.name, + "--health-cmd", + "true", + "--health-interval", + "1s", + "--health-timeout", + "1s", + "--health-retries", + "2", + &image, + "--", + "sh", + "-c", + "sleep 300", + ]); + + let healthy = smoke.wait_for_named_health(&smoke.name, "healthy"); + assert_eq!(json_string_field(&healthy, "health_status"), "healthy"); + assert_eq!(json_u64_field(&healthy, "health_retries"), 0); +} + #[test] #[ignore] fn real_core_pause_unpause_kill_wait() { @@ -2103,3 +2323,158 @@ fn real_core_interactive_pty_commands() { smoke.ok(&["stop", &smoke.name]); smoke.ok(&["rm", &smoke.name]); } + +#[cfg(target_os = "macos")] +#[test] +#[ignore] +fn real_core_rootfs_is_case_sensitive_and_persists_across_restart() { + let smoke = CoreSmoke::new(); + let image = smoke_image(); + seed_smoke_image(&smoke, &image); + let _cleanup = NamedBoxCleanup { + smoke: &smoke, + name: smoke.name.clone(), + }; + + let script = r#"set -eu +test -e /bin/sh +test ! -e /BIN/SH +if [ -e /case/Foo ]; then + test "$(cat /case/Foo)" = upper + test "$(cat /case/foo)" = lower + test "$(stat -c %i /case/Foo)" != "$(stat -c %i /case/foo)" + echo CASE_SENSITIVE_PERSISTED +else + mkdir /case + printf upper >/case/Foo + printf lower >/case/foo + test "$(stat -c %i /case/Foo)" != "$(stat -c %i /case/foo)" + echo CASE_SENSITIVE_CREATED +fi"#; + + smoke.ok(&[ + "run", + "-d", + "--name", + &smoke.name, + "--entrypoint", + "/bin/sh", + &image, + "--", + "-c", + script, + ]); + let first = smoke.wait_for_named_logs(&smoke.name, "CASE_SENSITIVE_CREATED"); + assert_contains(&first, "CASE_SENSITIVE_CREATED", "first case-sensitive run"); + smoke.wait_for_named_status(&smoke.name, "dead"); + + smoke.ok(&["start", &smoke.name]); + let second = smoke.wait_for_named_logs(&smoke.name, "CASE_SENSITIVE_PERSISTED"); + assert_contains( + &second, + "CASE_SENSITIVE_PERSISTED", + "persistent case-sensitive restart", + ); + + smoke.ok(&["rm", &smoke.name]); + let boxes_dir = smoke.home_path().join("boxes"); + assert!( + std::fs::read_dir(&boxes_dir) + .map(|mut entries| entries.next().is_none()) + .unwrap_or(true), + "removed box left its APFS rootfs directory behind" + ); + + let cached = smoke.ok(&[ + "run", + "--rm", + "--entrypoint", + "/bin/sh", + &image, + "--", + "-c", + script, + ]); + assert_contains( + &cached, + "CASE_SENSITIVE_CREATED", + "case-sensitive cached rootfs clone", + ); + assert!( + smoke + .home_path() + .join("cache/rootfs-apfs") + .read_dir() + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false), + "macOS run did not persist a case-sensitive APFS rootfs cache image" + ); +} + +#[cfg(target_os = "macos")] +#[test] +#[ignore] +fn real_core_buildkit_vm_preserves_multiple_build_args_with_spaces() { + let smoke = CoreSmoke::new(); + let base = smoke_image(); + let tag = format!("{}-build-args:latest", smoke.name); + let context = smoke.home_path().join("buildkit-context"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write( + context.join("Dockerfile"), + format!( + "FROM {base} AS chosen\nARG FIRST=default\nARG SECOND=none\nRUN printf '%s|%s' \"$FIRST\" \"$SECOND\" >/ok\n\nFROM {base} AS default\nRUN printf wrong >/ok\n" + ), + ) + .unwrap(); + let context_arg = context.to_string_lossy().to_string(); + + smoke.ok(&[ + "build", + "--builder", + "buildkit-vm", + "--platform", + "linux/arm64", + "--build-arg", + "FIRST=two words", + "--build-arg", + "SECOND=custom", + "--target", + "chosen", + "-f", + "Dockerfile", + "-t", + &tag, + &context_arg, + ]); + let result = smoke.ok(&["run", "--rm", "--entrypoint", "/bin/cat", &tag, "--", "/ok"]); + assert_contains(&result, "two words|custom", "BuildKit build args"); +} + +#[cfg(target_os = "macos")] +#[test] +#[ignore] +fn real_core_buildkit_vm_executes_amd64_run_on_arm64_host() { + let smoke = CoreSmoke::new(); + let base = smoke_image(); + let tag = format!("{}-amd64-run:latest", smoke.name); + let context = smoke.home_path().join("buildkit-amd64-context"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write( + context.join("Dockerfile"), + format!("FROM {base}\nRUN test \"$(uname -m)\" = x86_64 && printf x86_64 >/arch\n"), + ) + .unwrap(); + let context_arg = context.to_string_lossy().to_string(); + + smoke.ok(&[ + "build", + "--platform", + "linux/amd64", + "-f", + "Dockerfile", + "-t", + &tag, + &context_arg, + ]); +} diff --git a/src/cli/tests/host_smoke.rs b/src/cli/tests/host_smoke.rs index c63ec141..15768713 100644 --- a/src/cli/tests/host_smoke.rs +++ b/src/cli/tests/host_smoke.rs @@ -16,6 +16,15 @@ use std::time::Duration; mod support; use support::*; +fn inspect_box(cli: &CliTest, name: &str) -> serde_json::Value { + let value: serde_json::Value = + serde_json::from_str(&cli.ok(&["inspect", name])).expect("box inspect JSON"); + match value { + serde_json::Value::Array(mut records) if !records.is_empty() => records.remove(0), + other => other, + } +} + #[test] #[ignore] #[cfg(target_os = "linux")] @@ -385,30 +394,47 @@ fn test_real_vm_command_matrix() { #[test] #[ignore] -fn test_real_compose_smoke() { +fn test_real_compose_acl_smoke() { let cli = CliTest::new(); let image = host_smoke_image(); let boot_timeout = host_smoke_timeout(45); let project = "covcompose"; let service_box = "covcompose-worker"; + let unowned_network = "covcompose_unowned"; + let socket_dirs_before = host_socket_dirs(); + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).expect("reserve Compose host port"); + let host_port = listener.local_addr().expect("reserved host address").port(); + drop(listener); cleanup(&cli, service_box); seed_runnable_alpine_image(&cli, &image); + cli.ok(&[ + "network", + "create", + unowned_network, + "--subnet", + "10.126.0.0/24", + ]); let compose_dir = cli.home_path().join("compose"); std::fs::create_dir_all(&compose_dir).expect("create compose dir"); - let compose_file = compose_dir.join("compose.yaml"); + let compose_file = compose_dir.join("compose.acl"); std::fs::write( &compose_file, format!( - r#"services: - worker: - image: {image} - command: ["sleep", "3600"] - environment: - A3S_COMPOSE_COVERAGE: "1" - labels: - purpose: coverage + r#"service "worker" {{ + image = "{image}" + command = ["sleep", "3600"] + environment = {{ A3S_COMPOSE_COVERAGE = "1" }} + labels = {{ purpose = "coverage" }} + ports = ["{host_port}:8080"] + volumes = ["data:/data"] +}} + +volume "data" {{ + driver = "local" +}} "#, ), ) @@ -423,6 +449,16 @@ fn test_real_compose_smoke() { project, "config", ]); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "pull", + "--quiet", + "worker", + ]); cli.ok_status(&[ "compose", "--file", @@ -433,6 +469,21 @@ fn test_real_compose_smoke() { "--detach", ]); wait_for_running(&cli, service_box, boot_timeout); + let first_inspect = inspect_box(&cli, service_box); + + // A second unchanged `up` must converge onto the same service box. + cli.ok_status(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "up", + "--detach", + ]); + let second_inspect = inspect_box(&cli, service_box); + assert_eq!(first_inspect["id"], second_inspect["id"]); + cli.ok(&[ "compose", "--file", @@ -451,15 +502,187 @@ fn test_real_compose_smoke() { "--tail", "20", ]); + let images = cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "images", + ]); + assert!(images.contains("worker")); + assert!(images.contains(&image)); + let volumes = cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "volumes", + ]); + assert_eq!(volumes.trim(), "data"); + let projects = cli.ok(&["compose", "ls", "--quiet"]); + assert!(projects.lines().any(|name| name == project)); + let published = cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "port", + "worker", + "8080", + ]); + assert_eq!(published.trim(), format!("0.0.0.0:{host_port}")); + let env_value = cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, "exec", - service_box, + "worker", "--", "sh", "-c", "echo $A3S_COMPOSE_COVERAGE", ]); assert_eq!(env_value.trim(), "1"); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "top", + "worker", + ]); + + let source = cli.home_path().join("compose-copy-source.txt"); + let destination = cli.home_path().join("compose-copy-destination.txt"); + std::fs::write(&source, "compose-copy-ok\n").expect("write Compose copy source"); + let source_arg = source.to_string_lossy().to_string(); + let destination_arg = destination.to_string_lossy().to_string(); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "cp", + &source_arg, + "worker:/tmp/compose-copy.txt", + ]); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "cp", + "worker:/tmp/compose-copy.txt", + &destination_arg, + ]); + assert_eq!( + std::fs::read_to_string(&destination).expect("read Compose copy destination"), + "compose-copy-ok\n" + ); + + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "stop", + "worker", + ]); + let stopped = inspect_box(&cli, service_box); + assert_eq!(stopped["status"], "stopped"); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "start", + "worker", + ]); + wait_for_running(&cli, service_box, boot_timeout); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "restart", + "worker", + ]); + wait_for_running(&cli, service_box, boot_timeout); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "pause", + "worker", + ]); + let paused = inspect_box(&cli, service_box); + assert_eq!(paused["status"], "paused"); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "unpause", + "worker", + ]); + wait_for_running(&cli, service_box, boot_timeout); + + let waiter = cli.spawn_background(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "wait", + "--no-heartbeat", + "worker", + ]); + std::thread::sleep(Duration::from_secs(1)); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "kill", + "--signal", + "TERM", + "worker", + ]); + let wait_output = waiter + .wait_with_output() + .expect("collect Compose wait output"); + assert!( + wait_output.status.success(), + "Compose wait failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&wait_output.stdout), + String::from_utf8_lossy(&wait_output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&wait_output.stdout).trim(), "143"); + cli.ok(&[ + "compose", + "--file", + &compose_file_arg, + "--project-name", + project, + "rm", + "worker", + ]); cli.ok(&[ "compose", "--file", @@ -467,10 +690,32 @@ fn test_real_compose_smoke() { "--project-name", project, "down", + "--volumes", ]); let ps = cli.ok(&["ps", "-a"]); assert!(!ps.contains(service_box)); + let volume_list = cli.ok(&["volume", "ls"]); + assert!(!volume_list + .lines() + .any(|line| line.split_whitespace().next() == Some("data"))); + let network_list = cli.ok(&["network", "ls"]); + assert!( + network_list.contains(unowned_network), + "Compose down removed a prefix-matching network it did not own\n{network_list}" + ); + assert!(!network_list.contains("covcompose_default")); + cli.ok(&["network", "rm", unowned_network]); + let boxes_dir = cli.home_path().join("boxes"); + let remaining_boxes = std::fs::read_dir(&boxes_dir) + .map(|entries| entries.filter_map(Result::ok).collect::>()) + .unwrap_or_default(); + assert!( + remaining_boxes.is_empty(), + "compose down left box storage or a mounted rootfs under {}", + boxes_dir.display() + ); + assert_no_new_host_socket_dirs(&socket_dirs_before); } /// Warm-pool daemon end-to-end: `pool start` pre-warms VMs, then `pool run` @@ -516,7 +761,7 @@ fn test_real_pool_warm_run() { let start = std::time::Instant::now(); while !sock_path.exists() { if start.elapsed() > Duration::from_secs(120) { - let _ = daemon.kill(); + cli.interrupt_background(&mut daemon); panic!("pool daemon never created its socket"); } if let Ok(Some(status)) = daemon.try_wait() { @@ -540,6 +785,41 @@ fn test_real_pool_warm_run() { assert!(ok, "pool run failed.\nstdout:\n{out}\nstderr:\n{err}"); assert!(out.contains("pool-e2e-ok"), "unexpected output: {out:?}"); + // Docker-like entrypoint: `run --pool --rm` should hit the same daemon. + let run_pool_out = cli.ok(&[ + "run", + "--pool", + "--pool-socket", + socket.as_str(), + "--rm", + image.as_str(), + "--", + "echo", + "run-pool-e2e-ok", + ]); + assert!( + run_pool_out.contains("run-pool-e2e-ok"), + "unexpected run --pool output: {run_pool_out:?}" + ); + + // Env auto-route: compatible foreground `run --rm` uses the daemon without + // changing the CLI shape users already script. + let env_pool_out = cli.ok_with_env( + &[ + "run", + "--rm", + image.as_str(), + "--", + "echo", + "env-pool-e2e-ok", + ], + &[("A3S_BOX_RUN_POOL_SOCKET", socket.as_str())], + ); + assert!( + env_pool_out.contains("env-pool-e2e-ok"), + "unexpected env auto-routed run output: {env_pool_out:?}" + ); + // Concurrent runs — the daemon serves them concurrently. std::thread::scope(|s| { let handles: Vec<_> = (0..3) @@ -597,7 +877,113 @@ fn test_real_pool_warm_run() { "status should list both warmed images:\n{status}" ); - let _ = daemon.kill(); + cli.interrupt_background(&mut daemon); +} + +/// Dockerfile RUN over the warm-pool lease path: one build stage keeps a pooled +/// helper VM, shell-form and exec-form RUN mutate the mounted rootfs, and cache +/// mounts persist across RUN commands without committing cache contents. +#[test] +#[ignore] +fn test_real_build_run_pool_smoke() { + let cli = CliTest::new(); + let image = host_smoke_image(); + seed_runnable_alpine_image(&cli, &image); + let built_image = format!("coverage-run-pool:{}", unique_tag("build")); + let socket = cli + .home_path() + .join("build-pool.sock") + .to_str() + .expect("utf8 socket path") + .to_string(); + + let mut daemon = cli.spawn_background(&[ + "pool", + "start", + "--image", + image.as_str(), + "--size", + "1", + "--max", + "2", + "--socket", + socket.as_str(), + ]); + + let sock_path = cli.home_path().join("build-pool.sock"); + let start = std::time::Instant::now(); + while !sock_path.exists() { + if start.elapsed() > Duration::from_secs(120) { + cli.interrupt_background(&mut daemon); + panic!("build pool daemon never created its socket"); + } + if let Ok(Some(status)) = daemon.try_wait() { + panic!("build pool daemon exited early: {status}"); + } + std::thread::sleep(Duration::from_millis(200)); + } + std::thread::sleep(Duration::from_secs(5)); + + let build_dir = cli.home_path().join("run-pool-build"); + std::fs::create_dir_all(&build_dir).expect("create build --run-pool context"); + std::fs::write( + build_dir.join("Dockerfile"), + format!( + r#"FROM {image} +WORKDIR /work +RUN printf 'shell-ok\n' > /shell.txt +RUN ["/bin/sh", "-c", "printf 'exec-ok\n' > exec.txt"] +RUN --mount=type=cache,id=warm-smoke,sharing=locked,mode=0750,target=/root/.cache printf 'cache-only\n' > /root/.cache/cache.txt +RUN --mount=type=cache,id=warm-smoke,sharing=locked,target=/root/.cache cat /root/.cache/cache.txt > /cache-result.txt +"# + ), + ) + .expect("write build --run-pool Dockerfile"); + + let build_dir_arg = build_dir.to_string_lossy().to_string(); + let run_cache_dir = cli.home_path().join("run-cache"); + let run_cache_arg = run_cache_dir.to_string_lossy().to_string(); + cli.ok(&[ + "build", + "--run-pool", + "--run-pool-socket", + socket.as_str(), + "--run-cache-dir", + &run_cache_arg, + "--tag", + &built_image, + "--quiet", + &build_dir_arg, + ]); + + let status = cli.ok(&["pool", "status", "--socket", socket.as_str()]); + assert!( + status.contains("LEASED"), + "pool status should expose lease columns:\n{status}" + ); + + let image_tar = cli.home_path().join("run-pool-build.tar"); + let image_tar_arg = image_tar.to_string_lossy().to_string(); + cli.ok(&["save", &built_image, "--output", &image_tar_arg]); + assert_eq!( + read_file_from_saved_oci_tar(&image_tar, "/shell.txt").as_deref(), + Some("shell-ok\n") + ); + assert_eq!( + read_file_from_saved_oci_tar(&image_tar, "/work/exec.txt").as_deref(), + Some("exec-ok\n") + ); + assert_eq!( + read_file_from_saved_oci_tar(&image_tar, "/cache-result.txt").as_deref(), + Some("cache-only\n") + ); + assert!( + read_file_from_saved_oci_tar(&image_tar, "/root/.cache/cache.txt").is_none(), + "RUN cache mount contents must not be committed to the final image" + ); + + cli.ok(&["rmi", "--force", &built_image]); + cli.ok(&["pool", "stop", "--socket", socket.as_str()]); let _ = daemon.wait(); } @@ -636,7 +1022,7 @@ fn test_real_pool_deferred_main() { let start = std::time::Instant::now(); while !sock_path.exists() { if start.elapsed() > Duration::from_secs(120) { - let _ = daemon.kill(); + cli.interrupt_background(&mut daemon); panic!("deferred pool daemon never created its socket"); } if let Ok(Some(status)) = daemon.try_wait() { @@ -677,6 +1063,5 @@ fn test_real_pool_deferred_main() { ]); assert!(!ok2, "expected a non-zero exit from the deferred main"); - let _ = daemon.kill(); - let _ = daemon.wait(); + cli.interrupt_background(&mut daemon); } diff --git a/src/cli/tests/support/mod.rs b/src/cli/tests/support/mod.rs index 11680fb9..0348806c 100644 --- a/src/cli/tests/support/mod.rs +++ b/src/cli/tests/support/mod.rs @@ -144,6 +144,29 @@ impl CliTest { }) } + pub fn interrupt_background(&self, child: &mut std::process::Child) { + #[cfg(unix)] + unsafe { + let _ = libc::kill(child.id() as libc::pid_t, libc::SIGINT); + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } + + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(30) { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(Duration::from_millis(100)), + Err(_) => return, + } + } + + let _ = child.kill(); + let _ = child.wait(); + } + pub fn output_with_stdin(&self, args: &[&str], stdin: &[u8]) -> (String, String, bool) { eprintln!(" $ printf ... | a3s-box {}", args.join(" ")); diff --git a/src/compat/Cargo.toml b/src/compat/Cargo.toml new file mode 100644 index 00000000..c8ce6043 --- /dev/null +++ b/src/compat/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "a3s-box-compat" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Protocol compatibility service and contract fixtures for A3S Box" + +[dependencies] +a3s-acl = { workspace = true } +a3s-box-core = { version = "3.0", path = "../core" } +a3s-box-runtime = { version = "3.0", path = "../runtime", default-features = false, features = ["vm"] } +anyhow = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +futures = { workspace = true } +hex = { workspace = true } +hyper = { workspace = true } +libc = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } +ring = { workspace = true } +rustls = { workspace = true } +rustls-pemfile = "2" +rustls-pki-types = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-rustls = { workspace = true } +tokio-rusqlite = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +rcgen = { workspace = true } +tower = { workspace = true, features = ["util"] } + +[[bin]] +name = "a3s-box-e2b-contract" +path = "src/main.rs" + +[[bin]] +name = "a3s-box-e2b-fixture-server" +path = "src/bin/a3s-box-e2b-fixture-server.rs" + +[[bin]] +name = "a3s-box-e2b" +path = "src/bin/a3s-box-e2b.rs" diff --git a/src/compat/migrations/0001_lifecycle_records.sql b/src/compat/migrations/0001_lifecycle_records.sql new file mode 100644 index 00000000..5b300a86 --- /dev/null +++ b/src/compat/migrations/0001_lifecycle_records.sql @@ -0,0 +1,47 @@ +CREATE TABLE sandbox_records ( + sandbox_id TEXT PRIMARY KEY NOT NULL, + record_json TEXT NOT NULL CHECK (json_valid(record_json)), + owner_id TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.owner_id') + ) STORED NOT NULL, + operation_id TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.operation_id') + ) STORED NOT NULL, + generation INTEGER GENERATED ALWAYS AS ( + json_extract(record_json, '$.generation') + ) STORED NOT NULL CHECK (generation > 0), + state TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.state') + ) STORED NOT NULL CHECK ( + state IN ( + 'creating', + 'running', + 'pausing', + 'paused', + 'resuming', + 'killing', + 'killed', + 'failed' + ) + ), + created_at TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.created_at') + ) STORED NOT NULL, + expires_at TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.expires_at') + ) STORED NOT NULL, + CHECK (sandbox_id = json_extract(record_json, '$.sandbox_id')), + CHECK (length(trim(owner_id)) > 0), + CHECK (length(trim(operation_id)) > 0), + CHECK (length(trim(created_at)) > 0), + CHECK (length(trim(expires_at)) > 0) +) STRICT; + +CREATE UNIQUE INDEX sandbox_records_operation_id + ON sandbox_records(operation_id); + +CREATE INDEX sandbox_records_owner_state_created + ON sandbox_records(owner_id, state, created_at, sandbox_id); + +CREATE INDEX sandbox_records_expiry + ON sandbox_records(state, expires_at, sandbox_id); diff --git a/src/compat/migrations/0002_temporal_indexes.sql b/src/compat/migrations/0002_temporal_indexes.sql new file mode 100644 index 00000000..5312e932 --- /dev/null +++ b/src/compat/migrations/0002_temporal_indexes.sql @@ -0,0 +1,24 @@ +DROP INDEX sandbox_records_owner_state_created; +DROP INDEX sandbox_records_expiry; + +CREATE INDEX sandbox_records_owner_state_created + ON sandbox_records( + owner_id, + state, + julianday(created_at), + sandbox_id + ); + +CREATE INDEX sandbox_records_expiry + ON sandbox_records(state, julianday(expires_at), sandbox_id); + +CREATE INDEX sandbox_records_reconcilable + ON sandbox_records(julianday(created_at), sandbox_id) + WHERE state IN ( + 'creating', + 'running', + 'pausing', + 'paused', + 'resuming', + 'killing' + ); diff --git a/src/compat/migrations/0003_volume_records.sql b/src/compat/migrations/0003_volume_records.sql new file mode 100644 index 00000000..0c95c263 --- /dev/null +++ b/src/compat/migrations/0003_volume_records.sql @@ -0,0 +1,34 @@ +CREATE TABLE volume_records ( + volume_id TEXT PRIMARY KEY NOT NULL, + record_json TEXT NOT NULL CHECK (json_valid(record_json)), + owner_id TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.owner_id') + ) STORED NOT NULL, + name TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.name') + ) STORED NOT NULL, + runtime_name TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.runtime_name') + ) STORED NOT NULL, + state TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.state') + ) STORED NOT NULL CHECK (state IN ('creating', 'active', 'deleting')), + created_at TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.created_at') + ) STORED NOT NULL, + CHECK (volume_id = json_extract(record_json, '$.volume_id')), + CHECK (length(trim(owner_id)) > 0), + CHECK (length(name) > 0), + CHECK (length(runtime_name) > 0), + CHECK (length(trim(created_at)) > 0), + UNIQUE (owner_id, name), + UNIQUE (runtime_name) +) STRICT; + +CREATE INDEX volume_records_owner_created + ON volume_records(owner_id, julianday(created_at), volume_id) + WHERE state = 'active'; + +CREATE INDEX volume_records_reconciliation + ON volume_records(state, julianday(created_at), volume_id) + WHERE state IN ('creating', 'deleting'); diff --git a/src/compat/migrations/0004_snapshot_records.sql b/src/compat/migrations/0004_snapshot_records.sql new file mode 100644 index 00000000..9fe5b7f9 --- /dev/null +++ b/src/compat/migrations/0004_snapshot_records.sql @@ -0,0 +1,49 @@ +CREATE TABLE snapshot_records ( + snapshot_id TEXT PRIMARY KEY NOT NULL, + record_json TEXT NOT NULL CHECK (json_valid(record_json)), + content_id TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.content_id') + ) STORED NOT NULL, + owner_id TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.owner_id') + ) STORED NOT NULL, + source_sandbox_id TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.source_sandbox_id') + ) STORED NOT NULL, + source_execution_id TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.source_execution_id') + ) STORED NOT NULL, + source_execution_generation INTEGER GENERATED ALWAYS AS ( + json_extract(record_json, '$.source_execution_generation') + ) STORED NOT NULL, + source_state TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.source_state') + ) STORED NOT NULL CHECK (source_state IN ('running', 'paused')), + reference TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.reference') + ) STORED NOT NULL, + state TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.state') + ) STORED NOT NULL CHECK (state IN ('creating', 'active', 'deleting')), + created_at TEXT GENERATED ALWAYS AS ( + json_extract(record_json, '$.created_at') + ) STORED NOT NULL, + CHECK (snapshot_id = json_extract(record_json, '$.snapshot_id')), + CHECK (length(trim(content_id)) > 0), + CHECK (length(trim(owner_id)) > 0), + CHECK (length(trim(source_sandbox_id)) > 0), + CHECK (length(trim(source_execution_id)) > 0), + CHECK (source_execution_generation > 0), + CHECK (length(trim(reference)) > 0), + CHECK (length(trim(created_at)) > 0), + UNIQUE (content_id), + UNIQUE (owner_id, reference) +) STRICT; + +CREATE INDEX snapshot_records_owner_created + ON snapshot_records(owner_id, julianday(created_at), snapshot_id) + WHERE state = 'active'; + +CREATE INDEX snapshot_records_reconciliation + ON snapshot_records(state, julianday(created_at), snapshot_id) + WHERE state IN ('creating', 'deleting'); diff --git a/src/compat/src/bin/a3s-box-e2b-fixture-server.rs b/src/compat/src/bin/a3s-box-e2b-fixture-server.rs new file mode 100644 index 00000000..09ab52e6 --- /dev/null +++ b/src/compat/src/bin/a3s-box-e2b-fixture-server.rs @@ -0,0 +1,625 @@ +use std::collections::BTreeMap; +use std::num::NonZeroU16; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use a3s_box_compat::control::{ + Clock, ControlService, ControlServiceDependencies, IdentityProviderResult, IssuedToken, + MemorySandboxRepository, ResolvedTemplate, SandboxIdentity, SandboxIdentityProvider, + SecretToken, StoredToken, TemplateProvider, TemplateProviderError, TemplateProviderResult, + TokenIssuer, TokenIssuerError, TokenIssuerResult, TokenResolver, TokenScope, TokenVerifier, +}; +use a3s_box_compat::http::{ + lifecycle_router, AuthenticatedAccount, AuthenticationError, AuthenticationResult, + CredentialScheme, CredentialVerifier, CursorDecoder, CursorError, CursorResult, + LifecycleHttpConfig, LifecycleHttpState, PresentedCredential, +}; +use a3s_box_compat::snapshot::{ + MemorySnapshotRepository, SnapshotService, SnapshotServiceDependencies, + SnapshotTemplateProvider, +}; +use a3s_box_compat::volume::{ + A3sRuntimeVolumeStore, IdentityVolumeIdMapper, MemoryVolumeRepository, VolumeFilesystem, + VolumeService, VolumeServiceDependencies, +}; +use a3s_box_core::{ + resolve_execution, BoxConfig, CreateExecutionRequest, ExecutionGeneration, ExecutionId, + ExecutionIsolation, ExecutionLease, ExecutionManager, ExecutionManagerError, + ExecutionManagerResult, ExecutionPortConnector, ExecutionPortStream, ExecutionReservation, + ExecutionSnapshot, ExecutionSnapshotId, ExecutionState, ExecutionStatus, KillOutcome, + OperationId, ReconcileOutcome, ResourceConfig, +}; +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use chrono::{DateTime, TimeZone, Utc}; +use sha2::{Digest, Sha256}; + +const API_KEY: &str = "e2b_a1b2c3"; + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("Error: {error:#}"); + std::process::exit(1); + } +} + +async fn run() -> Result<()> { + let port_file = parse_port_file()?; + let volume_home = port_file.with_extension("volume-runtime"); + let clock = Arc::new(FixedClock(fixture_time()?)); + let tokens = Arc::new(FixtureTokens); + let executions = Arc::new(FixtureExecutionManager::new(clock.clone())); + let snapshot_repository = Arc::new(MemorySnapshotRepository::default()); + let snapshots = Arc::new(SnapshotService::new(SnapshotServiceDependencies { + repository: snapshot_repository.clone(), + executions: executions.clone(), + clock: clock.clone(), + })); + let templates = Arc::new(SnapshotTemplateProvider::new( + Arc::new(FixtureTemplates), + snapshot_repository, + )); + let volumes = Arc::new(VolumeService::new(VolumeServiceDependencies { + repository: Arc::new(MemoryVolumeRepository::default()), + runtime: Arc::new(A3sRuntimeVolumeStore::new(&volume_home)), + clock: clock.clone(), + token_issuer: tokens.clone(), + token_resolver: tokens.clone(), + token_verifier: tokens.clone(), + filesystem: Arc::new(VolumeFilesystem::new(Arc::new( + IdentityVolumeIdMapper::current(), + ))), + })); + let service = Arc::new( + ControlService::new(ControlServiceDependencies { + repository: Arc::new(MemorySandboxRepository::default()), + executions: executions.clone(), + ports: executions, + clock, + identities: Arc::new(FixtureIdentities::default()), + templates, + token_issuer: tokens.clone(), + token_resolver: tokens, + }) + .with_volume_mount_resolver(volumes.clone()) + .with_snapshot_service(snapshots.clone()), + ); + let state = LifecycleHttpState::new( + service, + Arc::new(FixtureCredentialVerifier), + Arc::new(FixtureCursorDecoder), + LifecycleHttpConfig { + domain: Some("fixture.invalid".to_string()), + ..LifecycleHttpConfig::default() + }, + ) + .with_volume_service(volumes) + .with_snapshot_service(snapshots); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .context("bind fixture listener")?; + let address = listener.local_addr().context("read fixture address")?; + tokio::fs::write(&port_file, address.port().to_string()) + .await + .with_context(|| format!("write fixture port file {}", port_file.display()))?; + let listener = listener + .into_std() + .context("convert fixture listener to std")?; + axum::Server::from_tcp(listener) + .context("create fixture HTTP server")? + .serve(lifecycle_router(state).into_make_service()) + .await + .context("serve fixture HTTP requests") +} + +fn parse_port_file() -> Result { + let mut arguments = std::env::args().skip(1); + let mut port_file = None; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--port-file" => { + port_file = Some(PathBuf::from( + arguments.next().context("--port-file requires a path")?, + )); + } + _ => bail!("unknown argument {argument}"), + } + } + port_file.context("--port-file is required") +} + +fn fixture_time() -> Result> { + Utc.with_ymd_and_hms(2026, 7, 14, 12, 0, 0) + .single() + .context("fixture timestamp is invalid") +} + +struct FixedClock(DateTime); + +impl Clock for FixedClock { + fn now(&self) -> DateTime { + self.0 + } +} + +#[derive(Default)] +struct FixtureIdentities { + next: AtomicU64, +} + +impl SandboxIdentityProvider for FixtureIdentities { + fn next_identity(&self) -> IdentityProviderResult { + let sequence = self.next.fetch_add(1, Ordering::Relaxed) + 1; + let sandbox_id = match sequence { + 1 => "fixture-sandbox".to_string(), + 2 => "fixture-restored".to_string(), + 3 => "fixture-interpreter".to_string(), + value => format!("fixture-sandbox-{value}"), + }; + Ok(SandboxIdentity { + sandbox_id: a3s_box_compat::control::SandboxId::new(sandbox_id) + .map_err(|error| fixture_identity_error(error.to_string()))?, + operation_id: OperationId::new(format!("fixture-operation-{sequence}")) + .map_err(|error| fixture_identity_error(error.to_string()))?, + }) + } +} + +fn fixture_identity_error(message: String) -> a3s_box_compat::control::IdentityProviderError { + a3s_box_compat::control::IdentityProviderError::Unavailable(message) +} + +struct FixtureTemplates; + +#[async_trait] +impl TemplateProvider for FixtureTemplates { + async fn resolve( + &self, + _owner_id: &str, + template_id: &str, + ) -> TemplateProviderResult { + if !matches!(template_id, "fixture-template" | "code-interpreter-v1") { + return Err(TemplateProviderError::NotFound(template_id.to_string())); + } + Ok(ResolvedTemplate { + config: BoxConfig { + isolation: ExecutionIsolation::Sandbox, + image: format!("fixture.invalid/{template_id}:latest"), + resources: ResourceConfig { + vcpus: 2, + memory_mb: 512, + disk_mb: 1024, + timeout: 300, + }, + ..BoxConfig::default() + }, + envd_version: "0.1.3".to_string(), + envd_mode: a3s_box_compat::control::EnvdMode::Broker, + routing: if template_id == "code-interpreter-v1" { + a3s_box_compat::routing::SandboxRoutePolicy::default() + .with_port( + a3s_box_compat::routing::CODE_INTERPRETER_PORT, + TokenScope::Traffic, + ) + .map_err(|error| TemplateProviderError::Invalid(error.to_string()))? + } else { + a3s_box_compat::routing::SandboxRoutePolicy::default() + }, + rootfs_snapshot_id: None, + }) + } +} + +struct FixtureTokens; + +#[async_trait] +impl TokenIssuer for FixtureTokens { + async fn issue(&self, scope: TokenScope) -> TokenIssuerResult { + let secret = match scope { + TokenScope::Envd => "fixture-envd-token", + TokenScope::Traffic => "fixture-traffic-token", + TokenScope::Volume => "fixture-volume-token", + }; + Ok(IssuedToken { + secret: SecretToken::new(secret)?, + stored: store_fixture_token(secret)?, + }) + } +} + +#[async_trait] +impl TokenResolver for FixtureTokens { + async fn resolve( + &self, + _scope: TokenScope, + stored: &StoredToken, + ) -> TokenIssuerResult { + let digest = Sha256::digest(stored.ciphertext()); + if &digest[..] != stored.digest() { + return Err(TokenIssuerError::InvalidMaterial); + } + let value = std::str::from_utf8(stored.ciphertext()) + .map_err(|_| TokenIssuerError::InvalidMaterial)?; + SecretToken::new(value) + } +} + +#[async_trait] +impl TokenVerifier for FixtureTokens { + async fn verify( + &self, + scope: TokenScope, + presented: &SecretToken, + stored: &StoredToken, + ) -> TokenIssuerResult { + if scope != TokenScope::Volume { + return Ok(false); + } + let digest = Sha256::digest(presented.expose_secret().as_bytes()); + Ok(digest[..] == stored.digest()[..]) + } +} + +fn store_fixture_token(secret: &str) -> TokenIssuerResult { + let ciphertext = secret.as_bytes().to_vec(); + let digest = Sha256::digest(&ciphertext).to_vec(); + StoredToken::new(1, ciphertext, digest).map_err(|_| TokenIssuerError::InvalidMaterial) +} + +struct FixtureCredentialVerifier; + +#[async_trait] +impl CredentialVerifier for FixtureCredentialVerifier { + async fn verify( + &self, + credential: &PresentedCredential, + ) -> AuthenticationResult { + if credential.scheme() != CredentialScheme::ApiKey || credential.expose_secret() != API_KEY + { + return Err(AuthenticationError::Invalid); + } + Ok(AuthenticatedAccount { + owner_id: "fixture-owner".to_string(), + client_id: "fixture-client".to_string(), + }) + } +} + +struct FixtureCursorDecoder; + +impl CursorDecoder for FixtureCursorDecoder { + fn decode(&self, value: &str) -> CursorResult> { + if value == "cursor-0" { + Ok(None) + } else { + Err(CursorError::Invalid) + } + } +} + +#[derive(Clone)] +struct FixtureExecution { + lease: ExecutionLease, + state: ExecutionState, +} + +struct FixtureExecutionManager { + clock: Arc, + operations: Mutex>, + executions: Mutex>, + snapshots: Mutex>, +} + +impl FixtureExecutionManager { + fn new(clock: Arc) -> Self { + Self { + clock, + operations: Mutex::new(BTreeMap::new()), + executions: Mutex::new(BTreeMap::new()), + snapshots: Mutex::new(BTreeMap::new()), + } + } + + fn operations(&self) -> ExecutionManagerResult>> { + self.operations.lock().map_err(|_| { + ExecutionManagerError::Unavailable("fixture operation lock poisoned".into()) + }) + } + + fn executions( + &self, + ) -> ExecutionManagerResult>> { + self.executions.lock().map_err(|_| { + ExecutionManagerError::Unavailable("fixture execution lock poisoned".into()) + }) + } + + fn snapshots(&self) -> ExecutionManagerResult>> { + self.snapshots.lock().map_err(|_| { + ExecutionManagerError::Unavailable("fixture snapshot lock poisoned".into()) + }) + } + + fn reservation(execution: &FixtureExecution) -> ExecutionReservation { + ExecutionReservation { + execution_id: execution.lease.execution_id.clone(), + generation: execution.lease.generation, + plan: execution.lease.plan.clone(), + resources: execution.lease.resources.clone(), + created_at: execution.lease.started_at, + } + } +} + +#[async_trait] +impl ExecutionManager for FixtureExecutionManager { + async fn create( + &self, + request: CreateExecutionRequest, + operation_id: &OperationId, + ) -> ExecutionManagerResult { + if let Some(execution_id) = self.operations()?.get(operation_id.as_str()).cloned() { + return self + .executions()? + .get(&execution_id) + .map(Self::reservation) + .ok_or_else(|| { + ExecutionManagerError::Internal( + "fixture operation references a missing execution".into(), + ) + }); + } + + let plan = resolve_execution(&request.config) + .map_err(|error| ExecutionManagerError::InvalidRequest(error.to_string()))?; + let execution_id = ExecutionId::new(format!("execution-{}", operation_id.as_str()))?; + let lease = ExecutionLease { + execution_id: execution_id.clone(), + generation: ExecutionGeneration::INITIAL, + plan, + resources: request.config.resources, + started_at: self.clock.now(), + }; + self.executions()?.insert( + execution_id.to_string(), + FixtureExecution { + lease: lease.clone(), + state: ExecutionState::Created, + }, + ); + self.operations()? + .insert(operation_id.as_str().to_string(), execution_id.to_string()); + Ok(ExecutionReservation { + execution_id, + generation: lease.generation, + plan: lease.plan, + resources: lease.resources, + created_at: lease.started_at, + }) + } + + async fn start( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let mut executions = self.executions()?; + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale fixture start".to_string(), + }); + } + match execution.state { + ExecutionState::Created => execution.state = ExecutionState::Running, + ExecutionState::Running => {} + state => { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: format!("cannot start fixture execution in state {state:?}"), + }); + } + } + Ok(execution.lease.clone()) + } + + async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult { + let executions = self.executions()?; + let execution = executions + .get(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + Ok(ExecutionStatus { + execution_id: execution_id.clone(), + generation: execution.lease.generation, + state: execution.state, + plan: execution.lease.plan.clone(), + }) + } + + async fn read_logs( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult> { + let executions = self.executions()?; + let execution = executions + .get(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale fixture log read".to_string(), + }); + } + Ok([ + ("stdout", "starting\n", 0_i64), + ("stderr", "failed once\n", 1_i64), + ("stdout", "ready\n", 2_i64), + ] + .into_iter() + .map(|(stream, message, offset)| a3s_box_core::log::LogEntry { + log: message.to_string(), + stream: stream.to_string(), + time: (execution.lease.started_at + chrono::Duration::seconds(offset)).to_rfc3339(), + }) + .collect()) + } + + async fn create_filesystem_snapshot( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + let mut executions = self.executions()?; + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation + || !matches!( + execution.state, + ExecutionState::Running | ExecutionState::Paused + ) + { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale fixture snapshot".to_string(), + }); + } + let result = ExecutionSnapshot { + snapshot_id: snapshot_id.clone(), + size_bytes: 4_096, + state: execution.state, + lease: execution.lease.clone(), + }; + drop(executions); + self.snapshots()? + .insert(snapshot_id.to_string(), result.size_bytes); + Ok(result) + } + + async fn filesystem_snapshot_size( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult> { + Ok(self.snapshots()?.get(snapshot_id.as_str()).copied()) + } + + async fn delete_filesystem_snapshot( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + Ok(self.snapshots()?.remove(snapshot_id.as_str()).is_some()) + } + + async fn pause( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + _keep_memory: bool, + ) -> ExecutionManagerResult { + let mut executions = self.executions()?; + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation || execution.state != ExecutionState::Running { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale fixture pause".to_string(), + }); + } + let next_generation = generation.get().checked_add(1).ok_or_else(|| { + ExecutionManagerError::Internal("fixture execution generation is exhausted".into()) + })?; + execution.lease.generation = ExecutionGeneration::new(next_generation)?; + execution.state = ExecutionState::Paused; + Ok(execution.lease.clone()) + } + + async fn resume( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let mut executions = self.executions()?; + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation || execution.state != ExecutionState::Paused { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale fixture resume".to_string(), + }); + } + let next_generation = generation.get().checked_add(1).ok_or_else(|| { + ExecutionManagerError::Internal("fixture execution generation is exhausted".into()) + })?; + execution.lease.generation = ExecutionGeneration::new(next_generation)?; + execution.state = ExecutionState::Running; + Ok(execution.lease.clone()) + } + + async fn kill( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let mut executions = self.executions()?; + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale fixture kill".to_string(), + }); + } + if execution.state == ExecutionState::Stopped { + return Ok(KillOutcome::AlreadyStopped); + } + execution.state = ExecutionState::Stopped; + Ok(KillOutcome::Killed) + } + + async fn reconcile( + &self, + operation_id: &OperationId, + ) -> ExecutionManagerResult { + let Some(execution_id) = self.operations()?.get(operation_id.as_str()).cloned() else { + return Ok(ReconcileOutcome::Absent); + }; + let executions = self.executions()?; + let execution = executions.get(&execution_id).ok_or_else(|| { + ExecutionManagerError::Internal( + "fixture operation references a missing execution".into(), + ) + })?; + Ok(match execution.state { + ExecutionState::Created => ReconcileOutcome::Created(Self::reservation(execution)), + ExecutionState::Creating => ReconcileOutcome::Creating, + ExecutionState::Running | ExecutionState::Paused => { + ReconcileOutcome::Ready(execution.lease.clone()) + } + ExecutionState::Stopped | ExecutionState::Failed => ReconcileOutcome::Failed, + }) + } +} + +#[async_trait] +impl ExecutionPortConnector for FixtureExecutionManager { + async fn connect_port( + &self, + execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _port: NonZeroU16, + _timeout: Duration, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::NotFound(execution_id.clone())) + } +} diff --git a/src/compat/src/bin/a3s-box-e2b.rs b/src/compat/src/bin/a3s-box-e2b.rs new file mode 100644 index 00000000..59b4da4d --- /dev/null +++ b/src/compat/src/bin/a3s-box-e2b.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; + +use a3s_box_compat::production::{E2bCompatConfig, E2bCompatService}; +use anyhow::{Context, Result}; +use clap::Parser; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, Parser)] +#[command( + name = "a3s-box-e2b", + version, + about = "ACL-configured E2B compatibility service for A3S Box" +)] +struct Arguments { + /// Production ACL configuration file. + #[arg(long, value_name = "PATH")] + config: PathBuf, +} + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + eprintln!("a3s-box-e2b failed: {error:#}"); + std::process::exit(1); + } +} + +async fn run() -> Result<()> { + let arguments = Arguments::parse(); + initialize_tracing()?; + let config = E2bCompatConfig::load(&arguments.config) + .await + .with_context(|| format!("load {}", arguments.config.display()))?; + E2bCompatService::build(config) + .await + .context("compose production service")? + .serve() + .await + .context("run production service") +} + +fn initialize_tracing() -> Result<()> { + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("a3s_box_compat=info")); + tracing_subscriber::fmt() + .with_env_filter(filter) + .try_init() + .map_err(|error| anyhow::anyhow!("initialize tracing: {error}")) +} diff --git a/src/compat/src/control/credential.rs b/src/compat/src/control/credential.rs new file mode 100644 index 00000000..7c288d78 --- /dev/null +++ b/src/compat/src/control/credential.rs @@ -0,0 +1,168 @@ +use std::fmt; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::model::LifecycleError; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "StoredTokenRepr", into = "StoredTokenRepr")] +pub struct StoredToken { + key_version: u32, + ciphertext: Vec, + digest: Vec, +} + +#[derive(Serialize, Deserialize)] +struct StoredTokenRepr { + key_version: u32, + ciphertext: Vec, + digest: Vec, +} + +impl TryFrom for StoredToken { + type Error = LifecycleError; + + fn try_from(value: StoredTokenRepr) -> Result { + Self::new(value.key_version, value.ciphertext, value.digest) + } +} + +impl From for StoredTokenRepr { + fn from(value: StoredToken) -> Self { + Self { + key_version: value.key_version, + ciphertext: value.ciphertext, + digest: value.digest, + } + } +} + +impl StoredToken { + pub fn new( + key_version: u32, + ciphertext: Vec, + digest: Vec, + ) -> Result { + if key_version == 0 || ciphertext.is_empty() || digest.is_empty() { + return Err(LifecycleError::InvalidCredentialMaterial); + } + Ok(Self { + key_version, + ciphertext, + digest, + }) + } + + pub const fn key_version(&self) -> u32 { + self.key_version + } + + pub fn ciphertext(&self) -> &[u8] { + &self.ciphertext + } + + pub fn digest(&self) -> &[u8] { + &self.digest + } +} + +impl fmt::Debug for StoredToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StoredToken") + .field("key_version", &self.key_version) + .field("ciphertext", &"[REDACTED]") + .field("digest", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxCredentials { + pub envd: StoredToken, + pub traffic: StoredToken, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct SecretToken(String); + +impl SecretToken { + pub fn new(value: impl Into) -> TokenIssuerResult { + let value = value.into(); + if value.is_empty() { + return Err(TokenIssuerError::InvalidMaterial); + } + Ok(Self(value)) + } + + pub fn expose_secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretToken([REDACTED])") + } +} + +pub struct IssuedToken { + pub secret: SecretToken, + pub stored: StoredToken, +} + +impl fmt::Debug for IssuedToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IssuedToken") + .field("secret", &self.secret) + .field("stored", &self.stored) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TokenScope { + Envd, + Traffic, + Volume, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum TokenIssuerError { + #[error("token material is invalid")] + InvalidMaterial, + #[error("token key version {0} is not configured")] + UnknownKeyVersion(u32), + #[error("token provider is unavailable: {0}")] + Unavailable(String), +} + +pub type TokenIssuerResult = std::result::Result; + +#[async_trait] +pub trait TokenIssuer: Send + Sync { + async fn issue(&self, scope: TokenScope) -> TokenIssuerResult; +} + +#[async_trait] +pub trait TokenResolver: Send + Sync { + async fn resolve( + &self, + scope: TokenScope, + stored: &StoredToken, + ) -> TokenIssuerResult; +} + +#[async_trait] +pub trait TokenVerifier: Send + Sync { + async fn verify( + &self, + scope: TokenScope, + presented: &SecretToken, + stored: &StoredToken, + ) -> TokenIssuerResult; +} diff --git a/src/compat/src/control/lifetime.rs b/src/compat/src/control/lifetime.rs new file mode 100644 index 00000000..0e5da528 --- /dev/null +++ b/src/compat/src/control/lifetime.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Duration, Utc}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub(super) enum ReadyLifetimeError { + #[error("sandbox timeout exceeds the supported duration")] + TimeoutTooLarge, + #[error("sandbox expiry exceeds the supported timestamp range")] + ExpiryOverflow, +} + +pub(super) fn ready_lifetime( + observed_ready_at: DateTime, + runtime_started_at: DateTime, + timeout_seconds: u64, +) -> Result<(DateTime, DateTime), ReadyLifetimeError> { + let ready_at = observed_ready_at.max(runtime_started_at); + let timeout_seconds = + i64::try_from(timeout_seconds).map_err(|_| ReadyLifetimeError::TimeoutTooLarge)?; + let expires_at = ready_at + .checked_add_signed(Duration::seconds(timeout_seconds)) + .ok_or(ReadyLifetimeError::ExpiryOverflow)?; + Ok((ready_at, expires_at)) +} + +#[cfg(test)] +mod tests { + use chrono::{Duration, TimeZone}; + + use super::*; + + #[test] + fn usable_lifetime_starts_after_runtime_and_control_readiness() { + let runtime_started_at = Utc.with_ymd_and_hms(2026, 7, 16, 1, 0, 0).unwrap(); + let observed_ready_at = runtime_started_at + Duration::seconds(7); + + let (ready_at, expires_at) = + ready_lifetime(observed_ready_at, runtime_started_at, 60).unwrap(); + + assert_eq!(ready_at, observed_ready_at); + assert_eq!(expires_at, observed_ready_at + Duration::seconds(60)); + } +} diff --git a/src/compat/src/control/memory.rs b/src/compat/src/control/memory.rs new file mode 100644 index 00000000..40bf9a03 --- /dev/null +++ b/src/compat/src/control/memory.rs @@ -0,0 +1,208 @@ +use std::collections::BTreeMap; +use std::num::NonZeroU32; +use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use super::{ + CompareAndSwapResult, LifecycleState, OnTimeoutAction, RepositoryError, RepositoryResult, + SandboxCursor, SandboxGeneration, SandboxId, SandboxListFilter, SandboxPage, SandboxRecord, + SandboxRepository, +}; + +/// Process-local repository used by protocol fixtures and focused tests. +/// +/// It deliberately has no durability guarantees. The production compatibility +/// service uses the transactional repository introduced by the persistence +/// slice. +#[derive(Debug, Default)] +pub struct MemorySandboxRepository { + records: RwLock>, +} + +impl MemorySandboxRepository { + fn read(&self) -> RepositoryResult>> { + self.records + .read() + .map_err(|_| RepositoryError::Unavailable("memory repository lock poisoned".into())) + } + + fn write(&self) -> RepositoryResult>> { + self.records + .write() + .map_err(|_| RepositoryError::Unavailable("memory repository lock poisoned".into())) + } +} + +#[async_trait] +impl SandboxRepository for MemorySandboxRepository { + async fn insert(&self, record: SandboxRecord) -> RepositoryResult<()> { + let mut records = self.write()?; + if records.contains_key(record.sandbox_id()) { + return Err(RepositoryError::Duplicate(record.sandbox_id().clone())); + } + records.insert(record.sandbox_id().clone(), record); + Ok(()) + } + + async fn get(&self, sandbox_id: &SandboxId) -> RepositoryResult> { + Ok(self.read()?.get(sandbox_id).cloned()) + } + + async fn list(&self, filter: &SandboxListFilter) -> RepositoryResult { + let records = self.read()?; + let mut matching = records + .values() + .filter(|record| record.owner_id() == filter.owner_id) + .filter(|record| { + record + .public_state() + .is_some_and(|state| filter.states.is_empty() || filter.states.contains(&state)) + }) + .filter(|record| { + filter + .metadata + .iter() + .all(|(key, value)| record.metadata().get(key) == Some(value)) + }) + .filter(|record| { + filter.after.as_ref().is_none_or(|cursor| { + (record.created_at(), record.sandbox_id()) + > (cursor.created_at, &cursor.sandbox_id) + }) + }) + .cloned() + .collect::>(); + matching.sort_by(|left, right| { + (left.created_at(), left.sandbox_id()).cmp(&(right.created_at(), right.sandbox_id())) + }); + + let limit = filter.limit.get() as usize; + let has_more = matching.len() > limit; + matching.truncate(limit); + let next = has_more + .then(|| matching.last()) + .flatten() + .map(|last| super::SandboxCursor { + created_at: last.created_at(), + sandbox_id: last.sandbox_id().clone(), + }); + Ok(SandboxPage { + records: matching, + next, + }) + } + + async fn list_reconcilable( + &self, + after: Option<&SandboxCursor>, + limit: NonZeroU32, + ) -> RepositoryResult { + let records = self.read()?; + let mut matching = records + .values() + .filter(|record| !record.is_terminal()) + .filter(|record| { + after.is_none_or(|cursor| { + (record.created_at(), record.sandbox_id()) + > (cursor.created_at, &cursor.sandbox_id) + }) + }) + .cloned() + .collect::>(); + matching.sort_by(|left, right| { + (left.created_at(), left.sandbox_id()).cmp(&(right.created_at(), right.sandbox_id())) + }); + + let limit = limit.get() as usize; + let has_more = matching.len() > limit; + matching.truncate(limit); + let next = has_more + .then(|| matching.last()) + .flatten() + .map(|last| SandboxCursor { + created_at: last.created_at(), + sandbox_id: last.sandbox_id().clone(), + }); + Ok(SandboxPage { + records: matching, + next, + }) + } + + async fn claim_expired( + &self, + deadline: DateTime, + limit: NonZeroU32, + ) -> RepositoryResult> { + let mut records = self.write()?; + let mut candidates = records + .values() + .filter(|record| record.expires_at() <= deadline) + .filter(|record| { + matches!( + (record.state(), record.lifecycle().on_timeout), + ( + LifecycleState::Running, + OnTimeoutAction::Kill | OnTimeoutAction::Pause + ) | (LifecycleState::Paused, OnTimeoutAction::Kill) + ) + }) + .map(|record| { + ( + record.expires_at(), + record.sandbox_id().clone(), + record.lifecycle().on_timeout, + ) + }) + .collect::>(); + candidates.sort_by(|left, right| (left.0, &left.1).cmp(&(right.0, &right.1))); + candidates.truncate(limit.get() as usize); + + let mut claimed = Vec::with_capacity(candidates.len()); + for (_, sandbox_id, action) in candidates { + let record = records.get_mut(&sandbox_id).ok_or_else(|| { + RepositoryError::Corrupt(format!( + "expired memory record disappeared during claim: {sandbox_id}" + )) + })?; + match action { + OnTimeoutAction::Kill => record.begin_kill(), + OnTimeoutAction::Pause => record.begin_pause(), + } + .map_err(|error| { + RepositoryError::Corrupt(format!( + "cannot claim expired memory record {sandbox_id}: {error}" + )) + })?; + claimed.push(record.clone()); + } + Ok(claimed) + } + + async fn compare_and_swap( + &self, + sandbox_id: &SandboxId, + expected: SandboxGeneration, + replacement: SandboxRecord, + ) -> RepositoryResult { + if replacement.sandbox_id() != sandbox_id || replacement.generation() <= expected { + return Err(RepositoryError::Corrupt( + "invalid compare-and-swap replacement".to_string(), + )); + } + + let mut records = self.write()?; + let Some(current) = records.get(sandbox_id) else { + return Ok(CompareAndSwapResult::NotFound); + }; + if current.generation() != expected { + return Ok(CompareAndSwapResult::Conflict { + actual_generation: current.generation(), + }); + } + records.insert(sandbox_id.clone(), replacement); + Ok(CompareAndSwapResult::Updated) + } +} diff --git a/src/compat/src/control/mod.rs b/src/compat/src/control/mod.rs new file mode 100644 index 00000000..6b731cae --- /dev/null +++ b/src/compat/src/control/mod.rs @@ -0,0 +1,52 @@ +mod credential; +mod lifetime; +mod memory; +mod model; +mod ports; +mod repository; +mod service; +mod sqlite; +mod supervisor; +mod token_keyring; +mod validation; + +pub use credential::{ + IssuedToken, SandboxCredentials, SecretToken, StoredToken, TokenIssuer, TokenIssuerError, + TokenIssuerResult, TokenResolver, TokenScope, TokenVerifier, +}; +pub use memory::MemorySandboxRepository; +pub use model::{ + LifecycleError, LifecycleFailure, LifecyclePolicy, LifecycleState, NewSandboxRecord, + OnTimeoutAction, PublicSandboxState, SandboxGeneration, SandboxId, SandboxRecord, +}; +pub use ports::{ + Clock, EnvdMode, IdentityProviderError, IdentityProviderResult, ResolvedTemplate, + SandboxIdentity, SandboxIdentityProvider, SystemClock, TemplateProvider, TemplateProviderError, + TemplateProviderResult, +}; +pub use repository::{ + CompareAndSwapResult, RepositoryError, RepositoryResult, SandboxCursor, SandboxListFilter, + SandboxPage, SandboxRepository, +}; +pub use service::{ + ConnectionDisposition, ControlService, ControlServiceDependencies, ControlServiceError, + ControlServiceResult, CreateSandboxRequest, SandboxConnection, SandboxLog, SandboxMetric, +}; +pub use sqlite::SqliteSandboxRepository; +pub use supervisor::{ + LifecycleMaintenanceFailure, LifecycleMaintenanceReport, LifecycleSupervisor, + LifecycleSupervisorDependencies, LifecycleSupervisorError, LifecycleSupervisorResult, +}; +pub use token_keyring::{RotatingTokenProvider, TokenKeyMaterial}; + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod service_tests; + +#[cfg(test)] +mod supervisor_tests; + +#[cfg(test)] +pub(crate) mod test_support; diff --git a/src/compat/src/control/model.rs b/src/compat/src/control/model.rs new file mode 100644 index 00000000..c8690f00 --- /dev/null +++ b/src/compat/src/control/model.rs @@ -0,0 +1,550 @@ +use std::collections::BTreeMap; +use std::fmt; + +use a3s_box_core::{ + ExecutionGeneration, ExecutionId, ExecutionLease, OperationId, ResolvedExecutionPlan, + ResourceConfig, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{credential::SandboxCredentials, EnvdMode}; +use crate::routing::SandboxRoutePolicy; +use crate::volume::VolumeMount; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct SandboxId(String); + +impl SandboxId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > 48 + || !is_sandbox_id_alphanumeric(bytes[0]) + || !is_sandbox_id_alphanumeric(bytes[bytes.len() - 1]) + || !bytes + .iter() + .all(|byte| is_sandbox_id_alphanumeric(*byte) || *byte == b'-') + { + return Err(LifecycleError::InvalidIdentity( + "sandbox ID must be 1-48 lowercase DNS-label characters".to_string(), + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +const fn is_sandbox_id_alphanumeric(byte: u8) -> bool { + byte.is_ascii_lowercase() || byte.is_ascii_digit() +} + +impl fmt::Display for SandboxId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl TryFrom for SandboxId { + type Error = LifecycleError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: SandboxId) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "u64", into = "u64")] +pub struct SandboxGeneration(u64); + +impl SandboxGeneration { + pub const INITIAL: Self = Self(1); + + pub fn new(value: u64) -> Result { + if value == 0 { + return Err(LifecycleError::InvalidIdentity( + "sandbox generation must be greater than zero".to_string(), + )); + } + Ok(Self(value)) + } + + pub const fn get(self) -> u64 { + self.0 + } + + fn next(self) -> Result { + self.0 + .checked_add(1) + .map(Self) + .ok_or(LifecycleError::GenerationExhausted) + } +} + +impl TryFrom for SandboxGeneration { + type Error = LifecycleError; + + fn try_from(value: u64) -> Result { + Self::new(value) + } +} + +impl From for u64 { + fn from(value: SandboxGeneration) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LifecycleState { + Creating, + Running, + Pausing, + Paused, + Resuming, + Killing, + Killed, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PublicSandboxState { + Running, + Paused, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OnTimeoutAction { + Kill, + Pause, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LifecyclePolicy { + pub on_timeout: OnTimeoutAction, + pub auto_resume: bool, + pub keep_memory_on_pause: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LifecycleFailure { + PolicyRejected, + RuntimeFailed, + ReconciliationFailed, +} + +#[derive(Debug, Clone)] +pub struct NewSandboxRecord { + pub sandbox_id: SandboxId, + pub operation_id: OperationId, + pub owner_id: String, + pub template_id: String, + pub plan: ResolvedExecutionPlan, + pub resources: ResourceConfig, + pub lifecycle: LifecyclePolicy, + pub created_at: DateTime, + pub expires_at: DateTime, + pub metadata: BTreeMap, + pub envd_version: String, + pub envd_mode: EnvdMode, + pub secure: bool, + pub allow_internet_access: Option, + pub credentials: SandboxCredentials, + pub routing: SandboxRoutePolicy, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxRecord { + sandbox_id: SandboxId, + operation_id: OperationId, + owner_id: String, + execution_id: Option, + execution_generation: Option, + generation: SandboxGeneration, + template_id: String, + plan: ResolvedExecutionPlan, + resources: ResourceConfig, + lifecycle: LifecyclePolicy, + state: LifecycleState, + created_at: DateTime, + started_at: Option>, + expires_at: DateTime, + metadata: BTreeMap, + envd_version: String, + #[serde(default)] + envd_mode: EnvdMode, + secure: bool, + allow_internet_access: Option, + credentials: SandboxCredentials, + #[serde(default)] + routing: SandboxRoutePolicy, + #[serde(default)] + volume_mounts: Vec, + failure: Option, +} + +impl SandboxRecord { + pub fn creating(new: NewSandboxRecord) -> Result { + Self::creating_with_mounts(new, Vec::new()) + } + + pub fn creating_with_mounts( + new: NewSandboxRecord, + volume_mounts: Vec, + ) -> Result { + crate::volume::validate_mounts(&volume_mounts).map_err(|error| { + LifecycleError::InvalidIdentity(format!("invalid volume mounts: {error}")) + })?; + if new.owner_id.trim().is_empty() + || new.template_id.trim().is_empty() + || new.envd_version.trim().is_empty() + { + return Err(LifecycleError::InvalidIdentity( + "owner ID, template ID, and envd version cannot be empty".to_string(), + )); + } + if new.expires_at < new.created_at { + return Err(LifecycleError::InvalidExpiry); + } + Ok(Self { + sandbox_id: new.sandbox_id, + operation_id: new.operation_id, + owner_id: new.owner_id, + execution_id: None, + execution_generation: None, + generation: SandboxGeneration::INITIAL, + template_id: new.template_id, + plan: new.plan, + resources: new.resources, + lifecycle: new.lifecycle, + state: LifecycleState::Creating, + created_at: new.created_at, + started_at: None, + expires_at: new.expires_at, + metadata: new.metadata, + envd_version: new.envd_version, + envd_mode: new.envd_mode, + secure: new.secure, + allow_internet_access: new.allow_internet_access, + credentials: new.credentials, + routing: new.routing, + volume_mounts, + failure: None, + }) + } + + pub fn sandbox_id(&self) -> &SandboxId { + &self.sandbox_id + } + + pub fn operation_id(&self) -> &OperationId { + &self.operation_id + } + + pub fn owner_id(&self) -> &str { + &self.owner_id + } + + pub fn execution_id(&self) -> Option<&ExecutionId> { + self.execution_id.as_ref() + } + + pub const fn execution_generation(&self) -> Option { + self.execution_generation + } + + pub const fn generation(&self) -> SandboxGeneration { + self.generation + } + + pub fn template_id(&self) -> &str { + &self.template_id + } + + pub fn plan(&self) -> &ResolvedExecutionPlan { + &self.plan + } + + pub fn resources(&self) -> &ResourceConfig { + &self.resources + } + + pub fn lifecycle(&self) -> &LifecyclePolicy { + &self.lifecycle + } + + pub const fn state(&self) -> LifecycleState { + self.state + } + + pub const fn created_at(&self) -> DateTime { + self.created_at + } + + pub const fn started_at(&self) -> Option> { + self.started_at + } + + pub const fn expires_at(&self) -> DateTime { + self.expires_at + } + + pub fn metadata(&self) -> &BTreeMap { + &self.metadata + } + + pub fn envd_version(&self) -> &str { + &self.envd_version + } + + pub const fn envd_mode(&self) -> EnvdMode { + self.envd_mode + } + + pub const fn secure(&self) -> bool { + self.secure + } + + pub const fn allow_internet_access(&self) -> Option { + self.allow_internet_access + } + + pub fn credentials(&self) -> &SandboxCredentials { + &self.credentials + } + + pub fn routing(&self) -> &SandboxRoutePolicy { + &self.routing + } + + pub fn volume_mounts(&self) -> &[VolumeMount] { + &self.volume_mounts + } + + pub const fn failure(&self) -> Option { + self.failure + } + + pub const fn public_state(&self) -> Option { + match self.state { + LifecycleState::Running => Some(PublicSandboxState::Running), + LifecycleState::Paused => Some(PublicSandboxState::Paused), + _ => None, + } + } + + pub const fn is_terminal(&self) -> bool { + matches!(self.state, LifecycleState::Killed | LifecycleState::Failed) + } + + pub(crate) fn validate_persisted(&self) -> Result<(), LifecycleError> { + super::validation::validate_persisted_record(self) + } + + pub fn mark_running( + &mut self, + lease: ExecutionLease, + ) -> Result { + self.require_state(&[LifecycleState::Creating, LifecycleState::Resuming])?; + self.validate_execution_lease(&lease)?; + let next = self.generation.next()?; + self.execution_id = Some(lease.execution_id); + self.execution_generation = Some(lease.generation); + self.resources = lease.resources; + self.started_at.get_or_insert(lease.started_at); + self.state = LifecycleState::Running; + self.failure = None; + self.generation = next; + Ok(next) + } + + pub fn mark_ready( + &mut self, + lease: ExecutionLease, + ready_at: DateTime, + expires_at: DateTime, + ) -> Result { + self.require_state(&[LifecycleState::Creating])?; + self.validate_execution_lease(&lease)?; + if ready_at < self.created_at || expires_at < ready_at { + return Err(LifecycleError::InvalidExpiry); + } + let next = self.generation.next()?; + self.execution_id = Some(lease.execution_id); + self.execution_generation = Some(lease.generation); + self.resources = lease.resources; + self.started_at = Some(ready_at); + self.expires_at = expires_at; + self.state = LifecycleState::Running; + self.failure = None; + self.generation = next; + Ok(next) + } + + pub fn begin_pause(&mut self) -> Result { + self.transition(&[LifecycleState::Running], LifecycleState::Pausing) + } + + pub fn mark_paused( + &mut self, + lease: ExecutionLease, + ) -> Result { + self.require_state(&[LifecycleState::Pausing])?; + self.validate_execution_lease(&lease)?; + let next = self.generation.next()?; + self.execution_id = Some(lease.execution_id); + self.execution_generation = Some(lease.generation); + self.resources = lease.resources; + self.started_at.get_or_insert(lease.started_at); + self.state = LifecycleState::Paused; + self.generation = next; + Ok(next) + } + + pub fn abort_pause(&mut self) -> Result { + self.transition(&[LifecycleState::Pausing], LifecycleState::Running) + } + + pub fn begin_resume(&mut self) -> Result { + if self.execution_id.is_none() || self.execution_generation.is_none() { + return Err(LifecycleError::MissingExecution); + } + self.transition(&[LifecycleState::Paused], LifecycleState::Resuming) + } + + pub fn abort_resume(&mut self) -> Result { + self.transition(&[LifecycleState::Resuming], LifecycleState::Paused) + } + + pub fn begin_kill(&mut self) -> Result { + self.transition( + &[ + LifecycleState::Creating, + LifecycleState::Running, + LifecycleState::Pausing, + LifecycleState::Paused, + LifecycleState::Resuming, + LifecycleState::Failed, + ], + LifecycleState::Killing, + ) + } + + pub fn mark_killed(&mut self) -> Result { + self.transition(&[LifecycleState::Killing], LifecycleState::Killed) + } + + pub fn mark_failed( + &mut self, + failure: LifecycleFailure, + ) -> Result { + self.require_state(&[ + LifecycleState::Creating, + LifecycleState::Running, + LifecycleState::Pausing, + LifecycleState::Paused, + LifecycleState::Resuming, + ])?; + let next = self.generation.next()?; + self.state = LifecycleState::Failed; + self.failure = Some(failure); + self.generation = next; + Ok(next) + } + + pub fn replace_expiry( + &mut self, + expires_at: DateTime, + ) -> Result { + self.require_state(&[LifecycleState::Running, LifecycleState::Paused])?; + let next = self.generation.next()?; + self.expires_at = expires_at; + self.generation = next; + Ok(next) + } + + fn transition( + &mut self, + allowed: &[LifecycleState], + target: LifecycleState, + ) -> Result { + self.require_state(allowed)?; + let next = self.generation.next()?; + self.state = target; + self.generation = next; + Ok(next) + } + + fn validate_execution_lease(&self, lease: &ExecutionLease) -> Result<(), LifecycleError> { + if lease.plan != self.plan { + return Err(LifecycleError::ExecutionPlanMismatch); + } + if self + .execution_id + .as_ref() + .is_some_and(|execution_id| execution_id != &lease.execution_id) + { + return Err(LifecycleError::ExecutionIdentityMismatch); + } + if self + .execution_generation + .is_some_and(|generation| lease.generation <= generation) + { + return Err(LifecycleError::ExecutionGenerationMismatch); + } + Ok(()) + } + + fn require_state(&self, allowed: &[LifecycleState]) -> Result<(), LifecycleError> { + if allowed.contains(&self.state) { + return Ok(()); + } + Err(LifecycleError::InvalidTransition { + from: self.state, + allowed: allowed.to_vec(), + }) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum LifecycleError { + #[error("invalid lifecycle identity: {0}")] + InvalidIdentity(String), + #[error("sandbox expiry cannot precede creation")] + InvalidExpiry, + #[error("invalid credential material")] + InvalidCredentialMaterial, + #[error("invalid lifecycle transition from {from:?}; expected one of {allowed:?}")] + InvalidTransition { + from: LifecycleState, + allowed: Vec, + }, + #[error("sandbox generation is exhausted")] + GenerationExhausted, + #[error("runtime returned a different resolved execution plan")] + ExecutionPlanMismatch, + #[error("runtime returned a different execution identity")] + ExecutionIdentityMismatch, + #[error("runtime returned a stale execution generation")] + ExecutionGenerationMismatch, + #[error("sandbox has no runtime execution to resume")] + MissingExecution, + #[error("invalid persisted sandbox state: {0}")] + InvalidPersistedState(String), +} diff --git a/src/compat/src/control/ports.rs b/src/compat/src/control/ports.rs new file mode 100644 index 00000000..fe58fd21 --- /dev/null +++ b/src/compat/src/control/ports.rs @@ -0,0 +1,80 @@ +use a3s_box_core::{BoxConfig, ExecutionSnapshotId, OperationId}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::SandboxId; +use crate::routing::SandboxRoutePolicy; + +pub trait Clock: Send + Sync { + fn now(&self) -> DateTime; +} + +#[derive(Debug, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> DateTime { + Utc::now() + } +} + +#[derive(Debug, Clone)] +pub struct SandboxIdentity { + pub sandbox_id: SandboxId, + pub operation_id: OperationId, +} + +#[derive(Debug, Error)] +pub enum IdentityProviderError { + #[error("sandbox identity provider is unavailable: {0}")] + Unavailable(String), +} + +pub type IdentityProviderResult = std::result::Result; + +pub trait SandboxIdentityProvider: Send + Sync { + fn next_identity(&self) -> IdentityProviderResult; +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnvdMode { + #[default] + Broker, + Runtime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolvedTemplate { + pub config: BoxConfig, + pub envd_version: String, + pub envd_mode: EnvdMode, + pub routing: SandboxRoutePolicy, + /// Validated runtime-managed filesystem lower for a dynamic snapshot + /// template. Static configured templates leave this unset. + #[serde(default)] + pub rootfs_snapshot_id: Option, +} + +#[derive(Debug, Error)] +pub enum TemplateProviderError { + #[error("sandbox template not found: {0}")] + NotFound(String), + #[error("sandbox template is invalid: {0}")] + Invalid(String), + #[error("sandbox template provider is unavailable: {0}")] + Unavailable(String), +} + +pub type TemplateProviderResult = std::result::Result; + +#[async_trait] +pub trait TemplateProvider: Send + Sync { + async fn resolve( + &self, + owner_id: &str, + template_id: &str, + ) -> TemplateProviderResult; +} diff --git a/src/compat/src/control/repository.rs b/src/compat/src/control/repository.rs new file mode 100644 index 00000000..aeec4ea5 --- /dev/null +++ b/src/compat/src/control/repository.rs @@ -0,0 +1,89 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::num::NonZeroU32; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use thiserror::Error; + +use super::{PublicSandboxState, SandboxGeneration, SandboxId, SandboxRecord}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxCursor { + pub created_at: DateTime, + pub sandbox_id: SandboxId, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxListFilter { + pub owner_id: String, + pub metadata: BTreeMap, + pub states: BTreeSet, + pub limit: NonZeroU32, + pub after: Option, +} + +#[derive(Debug, Clone)] +pub struct SandboxPage { + pub records: Vec, + pub next: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompareAndSwapResult { + Updated, + NotFound, + Conflict { + actual_generation: SandboxGeneration, + }, +} + +#[derive(Debug, Error)] +pub enum RepositoryError { + #[error("sandbox already exists: {0}")] + Duplicate(SandboxId), + #[error("sandbox repository unavailable: {0}")] + Unavailable(String), + #[error("sandbox repository contains invalid data: {0}")] + Corrupt(String), +} + +pub type RepositoryResult = std::result::Result; + +/// Transactional persistence boundary for compatibility lifecycle records. +#[async_trait] +pub trait SandboxRepository: Send + Sync { + async fn insert(&self, record: SandboxRecord) -> RepositoryResult<()>; + + async fn get(&self, sandbox_id: &SandboxId) -> RepositoryResult>; + + async fn list(&self, filter: &SandboxListFilter) -> RepositoryResult; + + /// Page through every non-terminal record that startup reconciliation must inspect. + async fn list_reconcilable( + &self, + after: Option<&SandboxCursor>, + limit: NonZeroU32, + ) -> RepositoryResult; + + /// Atomically claim actionable records whose expiry is at or before `deadline`. + /// + /// Returned records have already advanced to `pausing` or `killing`. A + /// concurrent timeout replacement must therefore either commit before the + /// claim and make the record ineligible, or observe a generation conflict. + async fn claim_expired( + &self, + deadline: DateTime, + limit: NonZeroU32, + ) -> RepositoryResult>; + + /// Replace one record only when its persisted generation equals `expected`. + /// + /// Implementations must reject a replacement with a different sandbox ID + /// or a generation that does not advance `expected`. + async fn compare_and_swap( + &self, + sandbox_id: &SandboxId, + expected: SandboxGeneration, + replacement: SandboxRecord, + ) -> RepositoryResult; +} diff --git a/src/compat/src/control/service.rs b/src/compat/src/control/service.rs new file mode 100644 index 00000000..d98f081e --- /dev/null +++ b/src/compat/src/control/service.rs @@ -0,0 +1,883 @@ +use std::collections::BTreeMap; +use std::num::NonZeroU16; +use std::sync::Arc; + +use a3s_box_core::{ + resolve_execution, CreateExecutionRequest, ExecutionBackend, ExecutionLease, ExecutionManager, + ExecutionManagerError, ExecutionPortConnector, NetworkMode, +}; +use chrono::{DateTime, Utc}; +use hyper::body::{Body, HttpBody}; +use hyper::client::conn; +use hyper::header::{CONTENT_TYPE, HOST}; +use hyper::{Method, Request, StatusCode}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::{debug, error}; + +use super::lifetime::ready_lifetime; +use super::{ + Clock, CompareAndSwapResult, EnvdMode, IdentityProviderError, LifecycleError, LifecycleFailure, + LifecyclePolicy, LifecycleState, NewSandboxRecord, RepositoryError, SandboxCredentials, + SandboxIdentityProvider, SandboxListFilter, SandboxPage, SandboxRecord, SandboxRepository, + SecretToken, TemplateProvider, TemplateProviderError, TokenIssuer, TokenIssuerError, + TokenResolver, TokenScope, +}; +use crate::routing::ENVD_PORT; +use crate::snapshot::{SnapshotRecord, SnapshotService, SnapshotServiceError}; +use crate::volume::{ResolvedVolumeMount, VolumeMount, VolumeMountResolver, VolumeServiceError}; + +const RUNTIME_ENVD_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const RUNTIME_ENVD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250); +const RUNTIME_ENVD_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +const RUNTIME_ENVD_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25); +const RUNTIME_ENVD_DEFAULT_USER: &str = "user"; + +#[derive(Debug, Clone)] +pub struct CreateSandboxRequest { + pub owner_id: String, + pub template_id: String, + pub timeout_seconds: u32, + pub lifecycle: LifecyclePolicy, + pub metadata: BTreeMap, + pub env_vars: BTreeMap, + pub secure: bool, + pub allow_internet_access: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionDisposition { + Created, + AlreadyRunning, + Resumed, +} + +pub struct SandboxConnection { + pub record: SandboxRecord, + pub envd_access_token: SecretToken, + pub traffic_access_token: SecretToken, + pub disposition: ConnectionDisposition, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SandboxMetric { + pub timestamp: DateTime, + pub cpu_count: u32, + pub cpu_used_pct: f32, + pub mem_used: u64, + pub mem_total: u64, + pub mem_cache: u64, + pub disk_used: u64, + pub disk_total: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxLog { + pub timestamp: DateTime, + pub stream: String, + pub message: String, +} + +impl std::fmt::Debug for SandboxConnection { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SandboxConnection") + .field("record", &self.record) + .field("envd_access_token", &self.envd_access_token) + .field("traffic_access_token", &self.traffic_access_token) + .field("disposition", &self.disposition) + .finish() + } +} + +#[derive(Debug, Error)] +pub enum ControlServiceError { + #[error("invalid sandbox request: {0}")] + InvalidRequest(String), + #[error("sandbox not found: {0}")] + NotFound(super::SandboxId), + #[error("sandbox lifecycle conflict: {0}")] + Conflict(super::SandboxId), + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Execution(#[from] a3s_box_core::ExecutionManagerError), + #[error(transparent)] + Identity(#[from] IdentityProviderError), + #[error(transparent)] + Template(#[from] TemplateProviderError), + #[error(transparent)] + Credential(#[from] TokenIssuerError), + #[error(transparent)] + Volume(#[from] VolumeServiceError), + #[error(transparent)] + Snapshot(#[from] SnapshotServiceError), + #[error("sandbox lifecycle failed: {0}")] + Lifecycle(#[from] LifecycleError), +} + +pub type ControlServiceResult = std::result::Result; + +#[derive(Clone)] +pub struct ControlService { + repository: Arc, + executions: Arc, + ports: Arc, + clock: Arc, + identities: Arc, + templates: Arc, + token_issuer: Arc, + token_resolver: Arc, + volume_mounts: Option>, + snapshots: Option>, +} + +pub struct ControlServiceDependencies { + pub repository: Arc, + pub executions: Arc, + pub ports: Arc, + pub clock: Arc, + pub identities: Arc, + pub templates: Arc, + pub token_issuer: Arc, + pub token_resolver: Arc, +} + +impl ControlService { + pub fn new(dependencies: ControlServiceDependencies) -> Self { + Self { + repository: dependencies.repository, + executions: dependencies.executions, + ports: dependencies.ports, + clock: dependencies.clock, + identities: dependencies.identities, + templates: dependencies.templates, + token_issuer: dependencies.token_issuer, + token_resolver: dependencies.token_resolver, + volume_mounts: None, + snapshots: None, + } + } + + pub fn with_volume_mount_resolver(mut self, resolver: Arc) -> Self { + self.volume_mounts = Some(resolver); + self + } + + pub fn with_snapshot_service(mut self, snapshots: Arc) -> Self { + self.snapshots = Some(snapshots); + self + } + + pub async fn create( + &self, + request: CreateSandboxRequest, + ) -> ControlServiceResult { + self.create_with_mounts(request, Vec::new()).await + } + + pub async fn create_with_mounts( + &self, + request: CreateSandboxRequest, + volume_mounts: Vec, + ) -> ControlServiceResult { + if request.template_id.trim().is_empty() { + return Err(ControlServiceError::InvalidRequest( + "template ID cannot be empty".to_string(), + )); + } + + let identity = self.identities.next_identity()?; + let template = self + .templates + .resolve(&request.owner_id, &request.template_id) + .await?; + let mut config = template.config; + let resolved_mounts = self + .resolve_volume_mounts(&request.owner_id, &volume_mounts) + .await?; + config.volumes.extend( + resolved_mounts + .iter() + .map(ResolvedVolumeMount::runtime_spec), + ); + config.resources.timeout = u64::from(request.timeout_seconds); + config.extra_env.extend(request.env_vars); + let runtime_env_vars = config.extra_env.iter().cloned().collect::>(); + match request.allow_internet_access { + Some(false) => config.network = NetworkMode::None, + Some(true) if matches!(config.network, NetworkMode::None) => { + config.network = NetworkMode::Tsi; + } + _ => {} + } + let plan = resolve_execution(&config) + .map_err(|error| ControlServiceError::InvalidRequest(error.to_string()))?; + let now = self.clock.now(); + let (_, expires_at) = ready_lifetime(now, now, u64::from(request.timeout_seconds)) + .map_err(|error| ControlServiceError::InvalidRequest(error.to_string()))?; + let envd = self.token_issuer.issue(TokenScope::Envd).await?; + let traffic = self.token_issuer.issue(TokenScope::Traffic).await?; + + let mut record = SandboxRecord::creating_with_mounts( + NewSandboxRecord { + sandbox_id: identity.sandbox_id, + operation_id: identity.operation_id, + owner_id: request.owner_id, + template_id: request.template_id, + plan, + resources: config.resources.clone(), + lifecycle: request.lifecycle, + created_at: now, + expires_at, + metadata: request.metadata.clone(), + envd_version: template.envd_version, + envd_mode: template.envd_mode, + secure: request.secure, + allow_internet_access: request.allow_internet_access, + credentials: SandboxCredentials { + envd: envd.stored, + traffic: traffic.stored, + }, + routing: template.routing, + }, + volume_mounts, + )?; + self.repository.insert(record.clone()).await?; + + let mut policy = a3s_box_core::ExecutionRecordPolicy::default(); + for mount in &resolved_mounts { + if !policy.volume_names.contains(&mount.runtime_name) { + policy.volume_names.push(mount.runtime_name.clone()); + } + } + let execution_request = CreateExecutionRequest { + external_sandbox_id: record.sandbox_id().to_string(), + config, + labels: request.metadata, + policy, + rootfs_snapshot_id: template.rootfs_snapshot_id, + }; + let lease = match self + .executions + .create_and_start(execution_request, record.operation_id()) + .await + { + Ok(lease) => lease, + Err(error) => { + error!( + sandbox_id = %record.sandbox_id(), + %error, + "Sandbox runtime creation failed" + ); + let expected = record.generation(); + record.mark_failed(LifecycleFailure::RuntimeFailed)?; + self.replace(expected, record).await?; + return Err(error.into()); + } + }; + if template.envd_mode == EnvdMode::Runtime { + if let Err(readiness_error) = self + .initialize_runtime_envd(&lease, record.sandbox_id().as_str(), &runtime_env_vars) + .await + { + error!( + sandbox_id = %record.sandbox_id(), + execution_id = %lease.execution_id, + error = %readiness_error, + "Sandbox runtime envd initialization failed" + ); + let cleanup = self + .executions + .kill(&lease.execution_id, lease.generation) + .await; + let expected = record.generation(); + record.mark_failed(LifecycleFailure::RuntimeFailed)?; + self.replace(expected, record).await?; + return Err(match cleanup { + Ok(_) => readiness_error, + Err(cleanup_error) => ExecutionManagerError::Internal(format!( + "{readiness_error}; runtime cleanup failed: {cleanup_error}" + )), + } + .into()); + } + } + + let (ready_at, expires_at) = match ready_lifetime( + self.clock.now(), + lease.started_at, + u64::from(request.timeout_seconds), + ) { + Ok(lifetime) => lifetime, + Err(error) => { + let cleanup = self + .executions + .kill(&lease.execution_id, lease.generation) + .await; + let expected = record.generation(); + record.mark_failed(LifecycleFailure::RuntimeFailed)?; + self.replace(expected, record).await?; + return Err(match cleanup { + Ok(_) => ControlServiceError::InvalidRequest(error.to_string()), + Err(cleanup_error) => { + ControlServiceError::Execution(ExecutionManagerError::Internal(format!( + "{error}; runtime cleanup failed: {cleanup_error}" + ))) + } + }); + } + }; + let expected = record.generation(); + if let Err(error) = record.mark_ready(lease, ready_at, expires_at) { + record.mark_failed(LifecycleFailure::RuntimeFailed)?; + self.replace(expected, record).await?; + return Err(error.into()); + } + self.replace(expected, record.clone()).await?; + + Ok(SandboxConnection { + record, + envd_access_token: envd.secret, + traffic_access_token: traffic.secret, + disposition: ConnectionDisposition::Created, + }) + } + + async fn resolve_volume_mounts( + &self, + owner_id: &str, + mounts: &[VolumeMount], + ) -> ControlServiceResult> { + if mounts.is_empty() { + return Ok(Vec::new()); + } + let resolver = self.volume_mounts.as_ref().ok_or_else(|| { + ControlServiceError::InvalidRequest( + "volume mounts are unavailable in this service".to_string(), + ) + })?; + resolver + .resolve_mounts(owner_id, mounts) + .await + .map_err(Into::into) + } + + async fn initialize_runtime_envd( + &self, + lease: &ExecutionLease, + lifecycle_id: &str, + env_vars: &BTreeMap, + ) -> Result<(), ExecutionManagerError> { + let port = NonZeroU16::new(ENVD_PORT).ok_or_else(|| { + ExecutionManagerError::Internal("envd port must be non-zero".to_string()) + })?; + let deadline = tokio::time::Instant::now() + RUNTIME_ENVD_READY_TIMEOUT; + loop { + let last_error = match self + .ports + .connect_port( + &lease.execution_id, + lease.generation, + port, + RUNTIME_ENVD_CONNECT_TIMEOUT, + ) + .await + { + Ok(stream) => match tokio::time::timeout( + RUNTIME_ENVD_REQUEST_TIMEOUT, + send_runtime_envd_init( + stream, + RuntimeEnvdInitRequest { + lifecycle_id, + env_vars, + timestamp: self.clock.now(), + default_user: RUNTIME_ENVD_DEFAULT_USER, + }, + ), + ) + .await + { + Ok(Ok(StatusCode::NO_CONTENT)) => return Ok(()), + Ok(Ok(status)) if status.is_client_error() => { + return Err(ExecutionManagerError::Internal(format!( + "runtime envd initialization returned HTTP {status}" + ))) + } + Ok(Ok(status)) => { + format!("runtime envd initialization returned HTTP {status}") + } + Ok(Err(error)) => error, + Err(_) => format!( + "runtime envd initialization timed out after {} ms", + RUNTIME_ENVD_REQUEST_TIMEOUT.as_millis() + ), + }, + Err(error @ ExecutionManagerError::InvalidRequest(_)) + | Err(error @ ExecutionManagerError::Internal(_)) => return Err(error), + Err(error) => error.to_string(), + }; + if tokio::time::Instant::now() >= deadline { + return Err(ExecutionManagerError::Unavailable(format!( + "runtime envd did not become ready within {} seconds: {}", + RUNTIME_ENVD_READY_TIMEOUT.as_secs(), + last_error + ))); + } + tokio::time::sleep(RUNTIME_ENVD_RETRY_INTERVAL).await; + } + } + + pub async fn connect( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + timeout_seconds: u32, + ) -> ControlServiceResult { + let mut record = self.require_visible(owner_id, sandbox_id).await?; + let disposition = match record.state() { + LifecycleState::Running => ConnectionDisposition::AlreadyRunning, + LifecycleState::Paused => { + let execution_id = record + .execution_id() + .cloned() + .ok_or_else(|| ControlServiceError::Conflict(sandbox_id.clone()))?; + let execution_generation = record + .execution_generation() + .ok_or_else(|| ControlServiceError::Conflict(sandbox_id.clone()))?; + let expected = record.generation(); + record.begin_resume()?; + self.replace(expected, record.clone()).await?; + + let lease = match self + .executions + .resume(&execution_id, execution_generation) + .await + { + Ok(lease) => lease, + Err(error) => { + let expected = record.generation(); + record.abort_resume()?; + self.replace(expected, record).await?; + return Err(error.into()); + } + }; + let expected = record.generation(); + record.mark_running(lease)?; + self.replace(expected, record.clone()).await?; + ConnectionDisposition::Resumed + } + _ => return Err(ControlServiceError::Conflict(sandbox_id.clone())), + }; + + let refreshed_expiry = expiry_from(self.clock.now(), timeout_seconds)?; + if refreshed_expiry > record.expires_at() { + let expected = record.generation(); + record.replace_expiry(refreshed_expiry)?; + self.replace(expected, record.clone()).await?; + } + self.connection(record, disposition).await + } + + pub async fn pause( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + keep_memory: bool, + ) -> ControlServiceResult<()> { + let mut record = self.require_visible(owner_id, sandbox_id).await?; + if record.state() != LifecycleState::Running { + return Err(ControlServiceError::Conflict(sandbox_id.clone())); + } + if !keep_memory + && matches!( + record.plan().backend, + ExecutionBackend::Crun | ExecutionBackend::Krun + ) + { + return Err(ControlServiceError::InvalidRequest( + "filesystem-only pause is not implemented for this execution backend".to_string(), + )); + } + let execution_id = record + .execution_id() + .cloned() + .ok_or_else(|| ControlServiceError::Conflict(sandbox_id.clone()))?; + let execution_generation = record + .execution_generation() + .ok_or_else(|| ControlServiceError::Conflict(sandbox_id.clone()))?; + let expected = record.generation(); + record.begin_pause()?; + self.replace(expected, record.clone()).await?; + + let lease = match self + .executions + .pause(&execution_id, execution_generation, keep_memory) + .await + { + Ok(lease) => lease, + Err(error) => { + let expected = record.generation(); + record.abort_pause()?; + self.replace(expected, record).await?; + return Err(error.into()); + } + }; + let expected = record.generation(); + record.mark_paused(lease)?; + self.replace(expected, record).await + } + + pub async fn resume( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + timeout_seconds: u32, + auto_pause: bool, + ) -> ControlServiceResult { + let record = self.require_visible(owner_id, sandbox_id).await?; + if record.state() != LifecycleState::Paused { + return Err(ControlServiceError::Conflict(sandbox_id.clone())); + } + if auto_pause && record.lifecycle().on_timeout != super::OnTimeoutAction::Pause { + return Err(ControlServiceError::InvalidRequest( + "autoPause cannot change the sandbox lifecycle policy during resume".to_string(), + )); + } + self.connect(owner_id, sandbox_id, timeout_seconds).await + } + + pub async fn get( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + ) -> ControlServiceResult { + self.require_visible(owner_id, sandbox_id).await + } + + pub async fn create_snapshot( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + name: Option<&str>, + ) -> ControlServiceResult { + let source = self.require_visible(owner_id, sandbox_id).await?; + let template = self + .templates + .resolve(owner_id, source.template_id()) + .await?; + let snapshots = self.snapshots.as_ref().ok_or_else(|| { + ControlServiceError::InvalidRequest( + "filesystem snapshots are unavailable in this service".to_string(), + ) + })?; + let pending = snapshots.capture(owner_id, &source, name, template).await?; + Ok(snapshots.publish(pending).await?) + } + + pub async fn list(&self, filter: &SandboxListFilter) -> ControlServiceResult { + Ok(self.repository.list(filter).await?) + } + + pub async fn set_timeout( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + timeout_seconds: u32, + ) -> ControlServiceResult<()> { + let mut record = self.require_visible(owner_id, sandbox_id).await?; + let expected = record.generation(); + record.replace_expiry(expiry_from(self.clock.now(), timeout_seconds)?)?; + self.replace(expected, record).await + } + + pub async fn refresh_timeout( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + timeout_seconds: u32, + ) -> ControlServiceResult<()> { + let mut record = self.require_visible(owner_id, sandbox_id).await?; + let refreshed_expiry = expiry_from(self.clock.now(), timeout_seconds)?; + if refreshed_expiry <= record.expires_at() { + return Ok(()); + } + let expected = record.generation(); + record.replace_expiry(refreshed_expiry)?; + self.replace(expected, record).await + } + + pub async fn current_metric( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + ) -> ControlServiceResult> { + let record = self.require_visible(owner_id, sandbox_id).await?; + if record.state() != LifecycleState::Running || record.envd_mode() != EnvdMode::Runtime { + return Ok(None); + } + let execution_id = record + .execution_id() + .ok_or_else(|| ControlServiceError::Conflict(sandbox_id.clone()))?; + let generation = record + .execution_generation() + .ok_or_else(|| ControlServiceError::Conflict(sandbox_id.clone()))?; + let port = NonZeroU16::new(ENVD_PORT).ok_or_else(|| { + ExecutionManagerError::Internal("envd port must be non-zero".to_string()) + })?; + let stream = self + .ports + .connect_port(execution_id, generation, port, RUNTIME_ENVD_CONNECT_TIMEOUT) + .await?; + let metrics = tokio::time::timeout( + RUNTIME_ENVD_REQUEST_TIMEOUT, + read_runtime_envd_metrics(stream), + ) + .await + .map_err(|_| { + ExecutionManagerError::Unavailable(format!( + "runtime envd metrics timed out after {} ms", + RUNTIME_ENVD_REQUEST_TIMEOUT.as_millis() + )) + })? + .map_err(ExecutionManagerError::Unavailable)?; + let timestamp = DateTime::from_timestamp(metrics.ts, 0).ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "runtime envd returned an invalid metrics timestamp {}", + metrics.ts + )) + })?; + Ok(Some(SandboxMetric { + timestamp, + cpu_count: metrics.cpu_count, + cpu_used_pct: metrics.cpu_used_pct, + mem_used: metrics.mem_used, + mem_total: metrics.mem_total, + mem_cache: 0, + disk_used: metrics.disk_used, + disk_total: metrics.disk_total, + })) + } + + pub async fn logs( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + ) -> ControlServiceResult> { + let record = self.require_visible(owner_id, sandbox_id).await?; + let execution_id = record + .execution_id() + .ok_or_else(|| ControlServiceError::Conflict(sandbox_id.clone()))?; + let generation = record + .execution_generation() + .ok_or_else(|| ControlServiceError::Conflict(sandbox_id.clone()))?; + self.executions + .read_logs(execution_id, generation) + .await? + .into_iter() + .map(|entry| -> ControlServiceResult { + let timestamp = DateTime::parse_from_rfc3339(&entry.time) + .map(|value| value.with_timezone(&Utc)) + .map_err(|error| { + ExecutionManagerError::Internal(format!( + "runtime returned an invalid structured log timestamp: {error}" + )) + })?; + if !matches!(entry.stream.as_str(), "stdout" | "stderr") { + return Err(ExecutionManagerError::Internal(format!( + "runtime returned an invalid structured log stream {}", + entry.stream + )) + .into()); + } + Ok(SandboxLog { + timestamp, + stream: entry.stream, + message: entry.log.trim_end_matches(['\r', '\n']).to_string(), + }) + }) + .collect() + } + + pub async fn kill( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + ) -> ControlServiceResult { + let Some(mut record) = self.repository.get(sandbox_id).await? else { + return Ok(false); + }; + if record.owner_id() != owner_id || record.is_terminal() { + return Ok(false); + } + + let execution = record + .execution_id() + .cloned() + .zip(record.execution_generation()); + let expected = record.generation(); + record.begin_kill()?; + self.replace(expected, record.clone()).await?; + + if let Some((execution_id, generation)) = execution { + self.executions.kill(&execution_id, generation).await?; + } + + let expected = record.generation(); + record.mark_killed()?; + self.replace(expected, record).await?; + Ok(true) + } + + async fn require_visible( + &self, + owner_id: &str, + sandbox_id: &super::SandboxId, + ) -> ControlServiceResult { + let record = self + .repository + .get(sandbox_id) + .await? + .ok_or_else(|| ControlServiceError::NotFound(sandbox_id.clone()))?; + if record.owner_id() != owner_id || record.public_state().is_none() { + return Err(ControlServiceError::NotFound(sandbox_id.clone())); + } + Ok(record) + } + + async fn connection( + &self, + record: SandboxRecord, + disposition: ConnectionDisposition, + ) -> ControlServiceResult { + let envd_access_token = self + .token_resolver + .resolve(TokenScope::Envd, &record.credentials().envd) + .await?; + let traffic_access_token = self + .token_resolver + .resolve(TokenScope::Traffic, &record.credentials().traffic) + .await?; + Ok(SandboxConnection { + record, + envd_access_token, + traffic_access_token, + disposition, + }) + } + + async fn replace( + &self, + expected: super::SandboxGeneration, + replacement: SandboxRecord, + ) -> ControlServiceResult<()> { + let sandbox_id = replacement.sandbox_id().clone(); + match self + .repository + .compare_and_swap(&sandbox_id, expected, replacement) + .await? + { + CompareAndSwapResult::Updated => Ok(()), + CompareAndSwapResult::NotFound => Err(ControlServiceError::NotFound(sandbox_id)), + CompareAndSwapResult::Conflict { .. } => Err(ControlServiceError::Conflict(sandbox_id)), + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeEnvdInitRequest<'a> { + #[serde(rename = "lifecycleID")] + lifecycle_id: &'a str, + env_vars: &'a BTreeMap, + timestamp: DateTime, + default_user: &'a str, +} + +#[derive(Deserialize)] +struct RuntimeEnvdMetrics { + ts: i64, + cpu_count: u32, + cpu_used_pct: f32, + mem_used: u64, + mem_total: u64, + disk_used: u64, + disk_total: u64, +} + +const RUNTIME_ENVD_METRICS_MAX_BYTES: usize = 64 * 1024; + +async fn send_runtime_envd_init( + stream: a3s_box_core::ExecutionPortStream, + init: RuntimeEnvdInitRequest<'_>, +) -> Result { + let payload = serde_json::to_vec(&init) + .map_err(|error| format!("failed to encode runtime envd initialization: {error}"))?; + let request = Request::builder() + .method(Method::POST) + .uri("/init") + .header(HOST, "127.0.0.1") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(payload)) + .map_err(|error| format!("failed to build runtime envd initialization: {error}"))?; + send_runtime_envd_request(stream, request) + .await + .map(|response| response.status()) +} + +async fn read_runtime_envd_metrics( + stream: a3s_box_core::ExecutionPortStream, +) -> Result { + let request = Request::builder() + .method(Method::GET) + .uri("/metrics") + .header(HOST, "127.0.0.1") + .body(Body::empty()) + .map_err(|error| format!("failed to build runtime envd metrics request: {error}"))?; + let response = send_runtime_envd_request(stream, request).await?; + if response.status() != StatusCode::OK { + return Err(format!( + "runtime envd metrics returned HTTP {}", + response.status() + )); + } + let mut response_body = response.into_body(); + let mut body = Vec::new(); + while let Some(chunk) = response_body.data().await { + let chunk = + chunk.map_err(|error| format!("failed to read runtime envd metrics: {error}"))?; + if body.len().saturating_add(chunk.len()) > RUNTIME_ENVD_METRICS_MAX_BYTES { + return Err(format!( + "runtime envd metrics exceeded {RUNTIME_ENVD_METRICS_MAX_BYTES} bytes" + )); + } + body.extend_from_slice(&chunk); + } + serde_json::from_slice(&body) + .map_err(|error| format!("runtime envd returned invalid metrics JSON: {error}")) +} + +async fn send_runtime_envd_request( + stream: a3s_box_core::ExecutionPortStream, + request: Request, +) -> Result, String> { + let (mut sender, connection) = conn::Builder::new() + .handshake(stream) + .await + .map_err(|error| format!("runtime envd HTTP handshake failed: {error}"))?; + tokio::spawn(async move { + if let Err(error) = connection.await { + debug!(%error, "runtime envd HTTP connection closed"); + } + }); + sender + .send_request(request) + .await + .map_err(|error| format!("runtime envd request failed: {error}")) +} + +fn expiry_from(now: DateTime, timeout_seconds: u32) -> ControlServiceResult> { + ready_lifetime(now, now, u64::from(timeout_seconds)) + .map(|(_, expires_at)| expires_at) + .map_err(|error| ControlServiceError::InvalidRequest(error.to_string())) +} diff --git a/src/compat/src/control/service_tests.rs b/src/compat/src/control/service_tests.rs new file mode 100644 index 00000000..36f451fd --- /dev/null +++ b/src/compat/src/control/service_tests.rs @@ -0,0 +1,411 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::sync::Arc; + +use a3s_box_core::{ExecutionManagerError, ExecutionState}; +use async_trait::async_trait; +use chrono::Duration; + +use super::test_support::{ + assert_sandbox_request, create_request, test_time, AdvancingClock, TestHarness, +}; +use super::*; +use crate::volume::{ResolvedVolumeMount, VolumeMount, VolumeMountResolver, VolumeServiceResult}; + +struct TestVolumeMountResolver; + +#[async_trait] +impl VolumeMountResolver for TestVolumeMountResolver { + async fn resolve_mounts( + &self, + owner_id: &str, + mounts: &[VolumeMount], + ) -> VolumeServiceResult> { + assert_eq!(owner_id, "owner-1"); + assert_eq!(mounts, &[VolumeMount::new("data", "/mnt/data").unwrap()]); + Ok(vec![ResolvedVolumeMount { + public: mounts[0].clone(), + runtime_name: "e2b-internal-volume".to_string(), + host_path: PathBuf::from("/var/lib/a3s/volumes/e2b-internal-volume"), + }]) + } +} + +#[tokio::test] +async fn typed_volume_mounts_reach_runtime_policy_and_public_records() { + let harness = TestHarness::new(); + let service = harness + .service + .as_ref() + .clone() + .with_volume_mount_resolver(Arc::new(TestVolumeMountResolver)); + let mount = VolumeMount::new("data", "/mnt/data").unwrap(); + + let created = service + .create_with_mounts(create_request("owner-1"), vec![mount.clone()]) + .await + .unwrap(); + + assert_eq!(created.record.volume_mounts(), &[mount]); + let requests = harness.executions.requests(); + assert_eq!( + requests[0].config.volumes, + vec!["/var/lib/a3s/volumes/e2b-internal-volume:/mnt/data:rw"] + ); + assert_eq!(requests[0].policy.volume_names, vec!["e2b-internal-volume"]); +} + +#[tokio::test] +async fn lifecycle_service_runs_the_official_control_flow() { + let harness = TestHarness::new(); + let created = harness + .service + .create(create_request("owner-1")) + .await + .unwrap(); + assert_eq!(created.disposition, ConnectionDisposition::Created); + assert_eq!(created.record.sandbox_id().as_str(), "sandbox-1"); + assert_eq!(created.record.owner_id(), "owner-1"); + assert_eq!( + created.record.public_state(), + Some(PublicSandboxState::Running) + ); + assert_eq!( + created.record.expires_at(), + test_time() + Duration::seconds(321) + ); + assert_eq!( + created.envd_access_token.expose_secret(), + "fixture-envd-token" + ); + assert_eq!( + created.traffic_access_token.expose_secret(), + "fixture-traffic-token" + ); + assert_sandbox_request(&harness.executions.requests()[0]); + + let sandbox_id = created.record.sandbox_id().clone(); + let connected = harness + .service + .connect("owner-1", &sandbox_id, 222) + .await + .unwrap(); + assert_eq!(connected.disposition, ConnectionDisposition::AlreadyRunning); + assert_eq!( + connected.record.expires_at(), + test_time() + Duration::seconds(321) + ); + + harness + .service + .pause("owner-1", &sandbox_id, true) + .await + .unwrap(); + let paused = harness.service.get("owner-1", &sandbox_id).await.unwrap(); + assert_eq!(paused.state(), LifecycleState::Paused); + assert_eq!(paused.execution_generation().unwrap().get(), 2); + assert!(matches!( + harness.service.pause("owner-1", &sandbox_id, true).await, + Err(ControlServiceError::Conflict(_)) + )); + + let resumed = harness + .service + .resume("owner-1", &sandbox_id, 600, true) + .await + .unwrap(); + assert_eq!(resumed.disposition, ConnectionDisposition::Resumed); + assert_eq!(resumed.record.state(), LifecycleState::Running); + assert_eq!(resumed.record.execution_generation().unwrap().get(), 3); + assert_eq!( + resumed.record.expires_at(), + test_time() + Duration::seconds(600) + ); + + let page = harness + .service + .list(&SandboxListFilter { + owner_id: "owner-1".to_string(), + metadata: BTreeMap::from([("team".to_string(), "alpha beta".to_string())]), + states: BTreeSet::from([PublicSandboxState::Running, PublicSandboxState::Paused]), + limit: NonZeroU32::new(2).unwrap(), + after: None, + }) + .await + .unwrap(); + assert_eq!(page.records.len(), 1); + + harness + .service + .set_timeout("owner-1", &sandbox_id, 123) + .await + .unwrap(); + assert_eq!( + harness + .service + .get("owner-1", &sandbox_id) + .await + .unwrap() + .expires_at(), + test_time() + Duration::seconds(123) + ); + + let generation = harness + .service + .get("owner-1", &sandbox_id) + .await + .unwrap() + .generation(); + harness + .service + .refresh_timeout("owner-1", &sandbox_id, 60) + .await + .unwrap(); + let unchanged = harness.service.get("owner-1", &sandbox_id).await.unwrap(); + assert_eq!(unchanged.expires_at(), test_time() + Duration::seconds(123)); + assert_eq!(unchanged.generation(), generation); + + harness + .service + .refresh_timeout("owner-1", &sandbox_id, 600) + .await + .unwrap(); + let refreshed = harness.service.get("owner-1", &sandbox_id).await.unwrap(); + assert_eq!(refreshed.expires_at(), test_time() + Duration::seconds(600)); + assert!(refreshed.generation() > generation); + assert!(matches!( + harness + .service + .refresh_timeout("owner-2", &sandbox_id, 900) + .await, + Err(ControlServiceError::NotFound(_)) + )); + + assert!(harness.service.kill("owner-1", &sandbox_id).await.unwrap()); + assert!(!harness.service.kill("owner-1", &sandbox_id).await.unwrap()); + assert!(matches!( + harness.service.connect("owner-1", &sandbox_id, 300).await, + Err(ControlServiceError::NotFound(_)) + )); +} + +#[tokio::test] +async fn cold_start_gets_the_full_usable_timeout_after_readiness() { + let ready_at = test_time() + Duration::seconds(120); + let clock = Arc::new(AdvancingClock::new(test_time(), ready_at)); + let harness = TestHarness::with_clock(clock); + let mut request = create_request("owner-1"); + request.timeout_seconds = 60; + request.lifecycle.on_timeout = OnTimeoutAction::Kill; + + let created = harness.service.create(request).await.unwrap(); + + assert_eq!(created.record.started_at(), Some(ready_at)); + assert_eq!(created.record.resources().timeout, 60); + assert_eq!( + created.record.expires_at(), + ready_at + Duration::seconds(60) + ); + + let supervisor = LifecycleSupervisor::new(LifecycleSupervisorDependencies { + repository: harness.repository.clone(), + executions: harness.executions.clone(), + clock: harness.clock.clone(), + }); + let report = supervisor + .reap_expired(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + assert_eq!(report.examined, 0); + assert_eq!( + harness + .service + .get("owner-1", created.record.sandbox_id()) + .await + .unwrap() + .state(), + LifecycleState::Running + ); +} + +#[tokio::test] +async fn lifecycle_service_hides_sandboxes_from_other_owners() { + let harness = TestHarness::new(); + let created = harness + .service + .create(create_request("owner-1")) + .await + .unwrap(); + let sandbox_id = created.record.sandbox_id().clone(); + + assert!(matches!( + harness.service.get("owner-2", &sandbox_id).await, + Err(ControlServiceError::NotFound(_)) + )); + assert!(!harness.service.kill("owner-2", &sandbox_id).await.unwrap()); + let page = harness + .service + .list(&SandboxListFilter { + owner_id: "owner-2".to_string(), + metadata: BTreeMap::new(), + states: BTreeSet::new(), + limit: NonZeroU32::new(100).unwrap(), + after: None, + }) + .await + .unwrap(); + assert!(page.records.is_empty()); +} + +#[tokio::test] +async fn failed_runtime_create_is_not_published() { + let harness = TestHarness::new(); + harness.executions.fail_create(); + + assert!(matches!( + harness.service.create(create_request("owner-1")).await, + Err(ControlServiceError::Execution(_)) + )); + let sandbox_id = SandboxId::new("sandbox-1").unwrap(); + assert!(matches!( + harness.service.get("owner-1", &sandbox_id).await, + Err(ControlServiceError::NotFound(_)) + )); +} + +#[tokio::test] +async fn runtime_envd_is_ready_before_the_sandbox_is_published() { + let harness = TestHarness::new(); + let mut request = create_request("owner-1"); + request.template_id = "runtime-envd-template".to_string(); + + let created = harness.service.create(request).await.unwrap(); + + assert_eq!(created.record.state(), LifecycleState::Running); + assert_eq!(created.record.envd_mode(), EnvdMode::Runtime); + assert_eq!( + harness.executions.port_requests(), + vec![( + "execution-operation-1".to_string(), + 1, + crate::routing::ENVD_PORT, + )] + ); + let requests = harness.executions.runtime_envd_requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].0, "POST"); + assert_eq!(requests[0].1, "/init"); + assert_eq!(requests[0].2["lifecycleID"], "sandbox-1"); + assert_eq!(requests[0].2["defaultUser"], "user"); + assert_eq!(requests[0].2["envVars"]["ALPHA"], "one"); + assert_eq!(requests[0].2["envVars"]["BETA"], "two"); + assert_eq!(requests[0].2["timestamp"], "2026-07-14T12:00:00Z"); + assert!(requests[0].2.get("accessToken").is_none()); +} + +#[tokio::test] +async fn runtime_envd_metrics_are_generation_fenced_and_typed() { + let harness = TestHarness::new(); + let mut request = create_request("owner-1"); + request.template_id = "runtime-envd-template".to_string(); + let created = harness.service.create(request).await.unwrap(); + + let metric = harness + .service + .current_metric("owner-1", created.record.sandbox_id()) + .await + .unwrap() + .unwrap(); + + assert_eq!(metric.timestamp, test_time()); + assert_eq!(metric.cpu_count, 2); + assert_eq!(metric.cpu_used_pct, 12.5); + assert_eq!(metric.mem_used, 134_217_728); + assert_eq!(metric.mem_total, 536_870_912); + assert_eq!(metric.mem_cache, 0); + assert_eq!(metric.disk_used, 268_435_456); + assert_eq!(metric.disk_total, 1_073_741_824); + assert_eq!( + harness.executions.port_requests(), + vec![ + ( + "execution-operation-1".to_string(), + 1, + crate::routing::ENVD_PORT, + ), + ( + "execution-operation-1".to_string(), + 1, + crate::routing::ENVD_PORT, + ), + ] + ); + let requests = harness.executions.runtime_envd_requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1], + ( + "GET".to_string(), + "/metrics".to_string(), + serde_json::Value::Null + ) + ); +} + +#[tokio::test] +async fn permanent_runtime_envd_failure_stops_and_hides_the_execution() { + let harness = TestHarness::new(); + harness.executions.fail_ports(); + let mut request = create_request("owner-1"); + request.template_id = "runtime-envd-template".to_string(); + + assert!(matches!( + harness.service.create(request).await, + Err(ControlServiceError::Execution( + ExecutionManagerError::InvalidRequest(_) + )) + )); + assert_eq!( + harness.executions.port_requests(), + vec![( + "execution-operation-1".to_string(), + 1, + crate::routing::ENVD_PORT, + )] + ); + assert_eq!( + harness.executions.execution_state("execution-operation-1"), + Some(ExecutionState::Stopped) + ); + let sandbox_id = SandboxId::new("sandbox-1").unwrap(); + assert!(matches!( + harness.service.get("owner-1", &sandbox_id).await, + Err(ControlServiceError::NotFound(_)) + )); +} + +#[tokio::test] +async fn rejected_runtime_envd_initialization_stops_and_hides_the_execution() { + let harness = TestHarness::new(); + harness.executions.fail_runtime_envd_init(); + let mut request = create_request("owner-1"); + request.template_id = "runtime-envd-template".to_string(); + + assert!(matches!( + harness.service.create(request).await, + Err(ControlServiceError::Execution( + ExecutionManagerError::Internal(message) + )) if message.contains("HTTP 400 Bad Request") + )); + assert_eq!(harness.executions.runtime_envd_requests().len(), 1); + assert_eq!( + harness.executions.execution_state("execution-operation-1"), + Some(ExecutionState::Stopped) + ); + let sandbox_id = SandboxId::new("sandbox-1").unwrap(); + assert!(matches!( + harness.service.get("owner-1", &sandbox_id).await, + Err(ControlServiceError::NotFound(_)) + )); +} diff --git a/src/compat/src/control/sqlite/mod.rs b/src/compat/src/control/sqlite/mod.rs new file mode 100644 index 00000000..2c261c77 --- /dev/null +++ b/src/compat/src/control/sqlite/mod.rs @@ -0,0 +1,601 @@ +use std::num::NonZeroU32; +use std::path::Path; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::SecondsFormat; +use tokio_rusqlite::rusqlite::{params, OptionalExtension, TransactionBehavior}; +use tokio_rusqlite::Connection; + +use super::{ + CompareAndSwapResult, OnTimeoutAction, RepositoryError, RepositoryResult, SandboxCursor, + SandboxGeneration, SandboxId, SandboxListFilter, SandboxPage, SandboxRecord, SandboxRepository, +}; + +const INITIAL_MIGRATION_NAME: &str = "lifecycle_records"; +const INITIAL_MIGRATION: &str = include_str!("../../../migrations/0001_lifecycle_records.sql"); +const TEMPORAL_INDEX_MIGRATION_NAME: &str = "temporal_indexes"; +const TEMPORAL_INDEX_MIGRATION: &str = + include_str!("../../../migrations/0002_temporal_indexes.sql"); +const VOLUME_RECORDS_MIGRATION_NAME: &str = "volume_records"; +const VOLUME_RECORDS_MIGRATION: &str = include_str!("../../../migrations/0003_volume_records.sql"); +const SNAPSHOT_RECORDS_MIGRATION_NAME: &str = "snapshot_records"; +const SNAPSHOT_RECORDS_MIGRATION: &str = + include_str!("../../../migrations/0004_snapshot_records.sql"); + +#[derive(Clone)] +pub struct SqliteSandboxRepository { + connection: Connection, +} + +impl std::fmt::Debug for SqliteSandboxRepository { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SqliteSandboxRepository") + .finish_non_exhaustive() + } +} + +impl SqliteSandboxRepository { + pub async fn open(path: impl AsRef) -> RepositoryResult { + let connection = Connection::open(path) + .await + .map_err(|error| unavailable("open SQLite repository", error))?; + let repository = Self { connection }; + repository.configure_and_migrate().await?; + Ok(repository) + } + + async fn configure_and_migrate(&self) -> RepositoryResult<()> { + self.call(|connection| { + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| unavailable("configure SQLite busy timeout", error))?; + connection + .pragma_update(None, "foreign_keys", "ON") + .map_err(|error| unavailable("enable SQLite foreign keys", error))?; + connection + .pragma_update(None, "synchronous", "NORMAL") + .map_err(|error| unavailable("configure SQLite synchronization", error))?; + let journal_mode: String = connection + .query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0)) + .map_err(|error| unavailable("enable SQLite WAL mode", error))?; + if !journal_mode.eq_ignore_ascii_case("wal") { + return Err(RepositoryError::Unavailable(format!( + "SQLite refused WAL mode and selected {journal_mode}" + ))); + } + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS compatibility_schema_migrations (\ + version INTEGER PRIMARY KEY NOT NULL,\ + name TEXT NOT NULL,\ + applied_at TEXT NOT NULL\ + ) STRICT;", + ) + .map_err(|error| unavailable("create SQLite migration table", error))?; + + let mut statement = connection + .prepare( + "SELECT version, name FROM compatibility_schema_migrations ORDER BY version", + ) + .map_err(|error| unavailable("prepare SQLite migration query", error))?; + let applied = statement + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| unavailable("query SQLite migrations", error))? + .collect::, _>>() + .map_err(|error| unavailable("read SQLite migrations", error))?; + drop(statement); + + match applied.as_slice() { + [] => { + apply_migration(connection, 1, INITIAL_MIGRATION_NAME, INITIAL_MIGRATION)?; + apply_migration( + connection, + 2, + TEMPORAL_INDEX_MIGRATION_NAME, + TEMPORAL_INDEX_MIGRATION, + )?; + apply_migration( + connection, + 3, + VOLUME_RECORDS_MIGRATION_NAME, + VOLUME_RECORDS_MIGRATION, + )?; + apply_migration( + connection, + 4, + SNAPSHOT_RECORDS_MIGRATION_NAME, + SNAPSHOT_RECORDS_MIGRATION, + )?; + } + [(1, name)] if name == INITIAL_MIGRATION_NAME => { + apply_migration( + connection, + 2, + TEMPORAL_INDEX_MIGRATION_NAME, + TEMPORAL_INDEX_MIGRATION, + )?; + apply_migration( + connection, + 3, + VOLUME_RECORDS_MIGRATION_NAME, + VOLUME_RECORDS_MIGRATION, + )?; + apply_migration( + connection, + 4, + SNAPSHOT_RECORDS_MIGRATION_NAME, + SNAPSHOT_RECORDS_MIGRATION, + )?; + } + [(1, first), (2, second)] + if first == INITIAL_MIGRATION_NAME + && second == TEMPORAL_INDEX_MIGRATION_NAME => + { + apply_migration( + connection, + 3, + VOLUME_RECORDS_MIGRATION_NAME, + VOLUME_RECORDS_MIGRATION, + )?; + apply_migration( + connection, + 4, + SNAPSHOT_RECORDS_MIGRATION_NAME, + SNAPSHOT_RECORDS_MIGRATION, + )?; + } + [(1, first), (2, second), (3, third)] + if first == INITIAL_MIGRATION_NAME + && second == TEMPORAL_INDEX_MIGRATION_NAME + && third == VOLUME_RECORDS_MIGRATION_NAME => + { + apply_migration( + connection, + 4, + SNAPSHOT_RECORDS_MIGRATION_NAME, + SNAPSHOT_RECORDS_MIGRATION, + )?; + } + [(1, first), (2, second), (3, third), (4, fourth)] + if first == INITIAL_MIGRATION_NAME + && second == TEMPORAL_INDEX_MIGRATION_NAME + && third == VOLUME_RECORDS_MIGRATION_NAME + && fourth == SNAPSHOT_RECORDS_MIGRATION_NAME => {} + _ => { + return Err(RepositoryError::Corrupt(format!( + "unsupported SQLite migration history: {applied:?}" + ))); + } + } + Ok(()) + }) + .await + } + + pub(crate) fn connection(&self) -> Connection { + self.connection.clone() + } + + async fn call(&self, function: F) -> RepositoryResult + where + F: FnOnce(&mut tokio_rusqlite::rusqlite::Connection) -> RepositoryResult + + Send + + 'static, + R: Send + 'static, + { + self.connection + .call(function) + .await + .map_err(map_async_error) + } +} + +#[async_trait] +impl SandboxRepository for SqliteSandboxRepository { + async fn insert(&self, record: SandboxRecord) -> RepositoryResult<()> { + validate_record(&record)?; + let sandbox_id = record.sandbox_id().clone(); + let record_json = serialize_record(&record)?; + self.call(move |connection| { + match connection.execute( + "INSERT INTO sandbox_records(sandbox_id, record_json) VALUES (?1, ?2)", + params![sandbox_id.as_str(), record_json], + ) { + Ok(_) => Ok(()), + Err(error) + if error.sqlite_error_code().is_some_and(|code| { + code == tokio_rusqlite::rusqlite::ErrorCode::ConstraintViolation + }) => + { + let existing = connection + .query_row( + "SELECT 1 FROM sandbox_records WHERE sandbox_id = ?1", + [sandbox_id.as_str()], + |_| Ok(()), + ) + .optional() + .map_err(|query_error| { + unavailable("inspect SQLite insert conflict", query_error) + })?; + if existing.is_some() { + Err(RepositoryError::Duplicate(sandbox_id)) + } else { + Err(RepositoryError::Corrupt(format!( + "SQLite rejected lifecycle record: {error}" + ))) + } + } + Err(error) => Err(unavailable("insert SQLite lifecycle record", error)), + } + }) + .await + } + + async fn get(&self, sandbox_id: &SandboxId) -> RepositoryResult> { + let sandbox_id = sandbox_id.clone(); + let record_json = self + .call(move |connection| { + connection + .query_row( + "SELECT record_json FROM sandbox_records WHERE sandbox_id = ?1", + [sandbox_id.as_str()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| unavailable("read SQLite lifecycle record", error)) + }) + .await?; + record_json + .map(|record| deserialize_record(&record)) + .transpose() + } + + async fn list(&self, filter: &SandboxListFilter) -> RepositoryResult { + let owner_id = filter.owner_id.clone(); + let after_created_at = filter.after.as_ref().map(|cursor| { + cursor + .created_at + .to_rfc3339_opts(SecondsFormat::AutoSi, true) + }); + let after_sandbox_id = filter + .after + .as_ref() + .map(|cursor| cursor.sandbox_id.to_string()); + let records = self + .call(move |connection| { + let mut statement = connection + .prepare( + "SELECT record_json FROM sandbox_records \ + WHERE owner_id = ?1 \ + AND state IN ('running', 'paused') \ + AND (\ + ?2 IS NULL \ + OR julianday(created_at) > julianday(?2) \ + OR (\ + julianday(created_at) = julianday(?2) \ + AND sandbox_id > ?3\ + )\ + ) \ + ORDER BY julianday(created_at), sandbox_id", + ) + .map_err(|error| unavailable("prepare SQLite lifecycle list", error))?; + let records = statement + .query_map( + params![owner_id, after_created_at, after_sandbox_id], + |row| row.get::<_, String>(0), + ) + .map_err(|error| unavailable("query SQLite lifecycle list", error))? + .collect::, _>>() + .map_err(|error| unavailable("read SQLite lifecycle list", error))?; + Ok(records) + }) + .await?; + + let mut matching = records + .iter() + .map(|record| deserialize_record(record)) + .collect::>>()?; + matching.retain(|record| { + record.public_state().is_some_and(|state| { + (filter.states.is_empty() || filter.states.contains(&state)) + && filter + .metadata + .iter() + .all(|(key, value)| record.metadata().get(key) == Some(value)) + }) + }); + + let limit = filter.limit.get() as usize; + let has_more = matching.len() > limit; + matching.truncate(limit); + let next = if has_more { + matching.last().map(|last| super::SandboxCursor { + created_at: last.created_at(), + sandbox_id: last.sandbox_id().clone(), + }) + } else { + None + }; + Ok(SandboxPage { + records: matching, + next, + }) + } + + async fn list_reconcilable( + &self, + after: Option<&SandboxCursor>, + limit: NonZeroU32, + ) -> RepositoryResult { + let after_created_at = after.map(|cursor| { + cursor + .created_at + .to_rfc3339_opts(SecondsFormat::AutoSi, true) + }); + let after_sandbox_id = after.map(|cursor| cursor.sandbox_id.to_string()); + let query_limit = i64::from(limit.get()) + 1; + let records = self + .call(move |connection| { + let mut statement = connection + .prepare( + "SELECT record_json FROM sandbox_records \ + WHERE state IN (\ + 'creating', 'running', 'pausing', 'paused',\ + 'resuming', 'killing'\ + ) \ + AND (\ + ?1 IS NULL \ + OR julianday(created_at) > julianday(?1) \ + OR (\ + julianday(created_at) = julianday(?1) \ + AND sandbox_id > ?2\ + )\ + ) \ + ORDER BY julianday(created_at), sandbox_id \ + LIMIT ?3", + ) + .map_err(|error| unavailable("prepare SQLite reconciliation scan", error))?; + let records = statement + .query_map( + params![after_created_at, after_sandbox_id, query_limit], + |row| row.get::<_, String>(0), + ) + .map_err(|error| unavailable("query SQLite reconciliation records", error))? + .collect::, _>>() + .map_err(|error| unavailable("read SQLite reconciliation records", error))?; + Ok(records) + }) + .await?; + + let mut records = records + .iter() + .map(|record| deserialize_record(record)) + .collect::>>()?; + let limit = limit.get() as usize; + let has_more = records.len() > limit; + records.truncate(limit); + let next = has_more + .then(|| records.last()) + .flatten() + .map(|last| SandboxCursor { + created_at: last.created_at(), + sandbox_id: last.sandbox_id().clone(), + }); + Ok(SandboxPage { records, next }) + } + + async fn claim_expired( + &self, + deadline: chrono::DateTime, + limit: NonZeroU32, + ) -> RepositoryResult> { + let deadline = deadline.to_rfc3339_opts(SecondsFormat::AutoSi, true); + let limit = i64::from(limit.get()); + self.call(move |connection| { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| unavailable("begin SQLite expiry claim", error))?; + let mut statement = transaction + .prepare( + "SELECT record_json FROM sandbox_records \ + WHERE julianday(expires_at) <= julianday(?1) \ + AND (\ + state = 'running' \ + OR (\ + state = 'paused' \ + AND json_extract(\ + record_json,\ + '$.lifecycle.on_timeout'\ + ) = 'kill'\ + )\ + ) \ + ORDER BY julianday(expires_at), sandbox_id \ + LIMIT ?2", + ) + .map_err(|error| unavailable("prepare SQLite expiry claim", error))?; + let selected = statement + .query_map(params![deadline, limit], |row| row.get::<_, String>(0)) + .map_err(|error| unavailable("query SQLite expired records", error))? + .collect::, _>>() + .map_err(|error| unavailable("read SQLite expired records", error))?; + drop(statement); + + let mut claimed = Vec::with_capacity(selected.len()); + for serialized in selected { + let mut record = deserialize_record(&serialized)?; + let expected_generation = + i64::try_from(record.generation().get()).map_err(|_| { + RepositoryError::Corrupt( + "SQLite expiry generation exceeds signed 64-bit range".into(), + ) + })?; + match record.lifecycle().on_timeout { + OnTimeoutAction::Kill => record.begin_kill(), + OnTimeoutAction::Pause => record.begin_pause(), + } + .map_err(|error| { + RepositoryError::Corrupt(format!( + "cannot claim expired SQLite record {}: {error}", + record.sandbox_id() + )) + })?; + validate_record(&record)?; + let sandbox_id = record.sandbox_id().to_string(); + let replacement = serialize_record(&record)?; + let updated = transaction + .execute( + "UPDATE sandbox_records SET record_json = ?1 \ + WHERE sandbox_id = ?2 AND generation = ?3", + params![replacement, sandbox_id, expected_generation], + ) + .map_err(|error| unavailable("persist SQLite expiry claim", error))?; + if updated != 1 { + return Err(RepositoryError::Corrupt(format!( + "expired SQLite record changed inside claim transaction: {sandbox_id}" + ))); + } + claimed.push(record); + } + transaction + .commit() + .map_err(|error| unavailable("commit SQLite expiry claim", error))?; + Ok(claimed) + }) + .await + } + + async fn compare_and_swap( + &self, + sandbox_id: &SandboxId, + expected: SandboxGeneration, + replacement: SandboxRecord, + ) -> RepositoryResult { + if replacement.sandbox_id() != sandbox_id || replacement.generation() <= expected { + return Err(RepositoryError::Corrupt( + "invalid compare-and-swap replacement".to_string(), + )); + } + validate_record(&replacement)?; + let expected_generation = i64::try_from(expected.get()).map_err(|_| { + RepositoryError::Corrupt("SQLite CAS generation exceeds signed 64-bit range".into()) + })?; + let sandbox_id = sandbox_id.clone(); + let record_json = serialize_record(&replacement)?; + self.call(move |connection| { + let updated = connection + .execute( + "UPDATE sandbox_records SET record_json = ?1 \ + WHERE sandbox_id = ?2 AND generation = ?3", + params![record_json, sandbox_id.as_str(), expected_generation], + ) + .map_err(|error| unavailable("update SQLite lifecycle record", error))?; + if updated == 1 { + return Ok(CompareAndSwapResult::Updated); + } + + let actual = connection + .query_row( + "SELECT generation FROM sandbox_records WHERE sandbox_id = ?1", + [sandbox_id.as_str()], + |row| row.get::<_, i64>(0), + ) + .optional() + .map_err(|error| unavailable("inspect SQLite CAS conflict", error))?; + match actual { + None => Ok(CompareAndSwapResult::NotFound), + Some(actual) => { + let actual = u64::try_from(actual).map_err(|_| { + RepositoryError::Corrupt( + "SQLite lifecycle generation is negative".to_string(), + ) + })?; + Ok(CompareAndSwapResult::Conflict { + actual_generation: SandboxGeneration::new(actual).map_err(|error| { + RepositoryError::Corrupt(format!( + "invalid SQLite lifecycle generation: {error}" + )) + })?, + }) + } + } + }) + .await + } +} + +fn apply_migration( + connection: &mut tokio_rusqlite::rusqlite::Connection, + version: i64, + name: &str, + migration: &str, +) -> RepositoryResult<()> { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| unavailable("begin SQLite migration", error))?; + transaction.execute_batch(migration).map_err(|error| { + RepositoryError::Corrupt(format!("apply SQLite migration {version}: {error}")) + })?; + transaction + .execute( + "INSERT INTO compatibility_schema_migrations(version, name, applied_at) \ + VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + params![version, name], + ) + .map_err(|error| unavailable("record SQLite migration", error))?; + transaction + .commit() + .map_err(|error| unavailable("commit SQLite migration", error)) +} + +fn serialize_record(record: &SandboxRecord) -> RepositoryResult { + serde_json::to_string(record).map_err(|error| { + RepositoryError::Corrupt(format!("serialize lifecycle record for SQLite: {error}")) + }) +} + +fn deserialize_record(record: &str) -> RepositoryResult { + let record: SandboxRecord = serde_json::from_str(record).map_err(|error| { + RepositoryError::Corrupt(format!("deserialize SQLite lifecycle record: {error}")) + })?; + validate_record(&record)?; + Ok(record) +} + +fn validate_record(record: &SandboxRecord) -> RepositoryResult<()> { + record.validate_persisted().map_err(|error| { + RepositoryError::Corrupt(format!("invalid SQLite lifecycle record: {error}")) + })?; + i64::try_from(record.generation().get()).map_err(|_| { + RepositoryError::Corrupt("SQLite lifecycle generation exceeds signed 64-bit range".into()) + })?; + if record + .execution_generation() + .is_some_and(|generation| i64::try_from(generation.get()).is_err()) + { + return Err(RepositoryError::Corrupt( + "SQLite execution generation exceeds signed 64-bit range".into(), + )); + } + Ok(()) +} + +fn unavailable(context: &str, error: impl std::fmt::Display) -> RepositoryError { + RepositoryError::Unavailable(format!("{context}: {error}")) +} + +fn map_async_error(error: tokio_rusqlite::Error) -> RepositoryError { + match error { + tokio_rusqlite::Error::Error(error) => error, + tokio_rusqlite::Error::ConnectionClosed => { + RepositoryError::Unavailable("SQLite repository connection closed".to_string()) + } + _ => RepositoryError::Unavailable(format!("SQLite repository failed: {error}")), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/compat/src/control/sqlite/tests.rs b/src/compat/src/control/sqlite/tests.rs new file mode 100644 index 00000000..d61b24aa --- /dev/null +++ b/src/compat/src/control/sqlite/tests.rs @@ -0,0 +1,626 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::num::NonZeroU32; + +use a3s_box_core::{ + resolve_execution, BoxConfig, ExecutionGeneration, ExecutionId, ExecutionIsolation, + ExecutionLease, OperationId, +}; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use tempfile::tempdir; + +use super::*; +use crate::control::{ + EnvdMode, LifecyclePolicy, NewSandboxRecord, OnTimeoutAction, PublicSandboxState, + SandboxCredentials, StoredToken, +}; + +fn instant(second: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 14, 12, 0, second) + .single() + .unwrap() +} + +fn stored_token(marker: u8) -> StoredToken { + StoredToken::new(1, vec![marker, 1], vec![marker, 2]).unwrap() +} + +fn running_record( + sandbox_id: &str, + owner_id: &str, + second: u32, + metadata: BTreeMap, +) -> SandboxRecord { + running_record_with_action( + sandbox_id, + owner_id, + second, + metadata, + OnTimeoutAction::Kill, + ) +} + +fn running_record_with_action( + sandbox_id: &str, + owner_id: &str, + second: u32, + metadata: BTreeMap, + on_timeout: OnTimeoutAction, +) -> SandboxRecord { + running_record_at(sandbox_id, owner_id, instant(second), metadata, on_timeout) +} + +fn running_record_at( + sandbox_id: &str, + owner_id: &str, + created_at: DateTime, + metadata: BTreeMap, + on_timeout: OnTimeoutAction, +) -> SandboxRecord { + let config = BoxConfig { + isolation: ExecutionIsolation::Sandbox, + ..BoxConfig::default() + }; + let mut record = SandboxRecord::creating(NewSandboxRecord { + sandbox_id: SandboxId::new(sandbox_id).unwrap(), + operation_id: OperationId::new(format!("operation-{sandbox_id}")).unwrap(), + owner_id: owner_id.to_string(), + template_id: "fixture-template".to_string(), + plan: resolve_execution(&config).unwrap(), + resources: config.resources, + lifecycle: LifecyclePolicy { + on_timeout, + auto_resume: false, + keep_memory_on_pause: false, + }, + created_at, + expires_at: created_at + Duration::seconds(300), + metadata, + envd_version: "0.1.3".to_string(), + envd_mode: EnvdMode::Broker, + secure: true, + allow_internet_access: Some(false), + credentials: SandboxCredentials { + envd: stored_token(10), + traffic: stored_token(20), + }, + routing: crate::routing::SandboxRoutePolicy::default(), + }) + .unwrap(); + record + .mark_running(ExecutionLease { + execution_id: ExecutionId::new(format!("execution-{sandbox_id}")).unwrap(), + generation: ExecutionGeneration::INITIAL, + plan: record.plan().clone(), + resources: record.resources().clone(), + started_at: created_at + Duration::seconds(1), + }) + .unwrap(); + record +} + +#[tokio::test] +async fn opens_in_wal_mode_and_applies_exact_migration_history() { + let directory = tempdir().unwrap(); + let repository = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + + let (journal_mode, migrations, strict, created_index, expiry_index) = repository + .call(|connection| { + let journal_mode = connection + .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0)) + .map_err(|error| unavailable("read journal mode", error))?; + let migrations = connection + .query_row( + "SELECT group_concat(version || ':' || name, ',') \ + FROM compatibility_schema_migrations", + [], + |row| row.get::<_, String>(0), + ) + .map_err(|error| unavailable("read migration history", error))?; + let strict = connection + .query_row( + "SELECT strict FROM pragma_table_list WHERE name = 'sandbox_records'", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|error| unavailable("read strict table status", error))?; + let created_index = connection + .query_row( + "SELECT sql FROM sqlite_master \ + WHERE type = 'index' \ + AND name = 'sandbox_records_owner_state_created'", + [], + |row| row.get::<_, String>(0), + ) + .map_err(|error| unavailable("read creation index definition", error))?; + let expiry_index = connection + .query_row( + "SELECT sql FROM sqlite_master \ + WHERE type = 'index' AND name = 'sandbox_records_expiry'", + [], + |row| row.get::<_, String>(0), + ) + .map_err(|error| unavailable("read expiry index definition", error))?; + Ok(( + journal_mode, + migrations, + strict, + created_index, + expiry_index, + )) + }) + .await + .unwrap(); + + assert_eq!(journal_mode, "wal"); + assert_eq!( + migrations, + "1:lifecycle_records,2:temporal_indexes,3:volume_records,4:snapshot_records" + ); + assert_eq!(strict, 1); + assert!(created_index.contains("julianday(created_at)")); + assert!(expiry_index.contains("julianday(expires_at)")); +} + +#[tokio::test] +async fn upgrades_a_version_one_repository_without_rewriting_records() { + let directory = tempdir().unwrap(); + let path = directory.path().join("control.db"); + let repository = SqliteSandboxRepository::open(&path).await.unwrap(); + let record = running_record("sandbox-1", "owner-1", 0, BTreeMap::new()); + repository.insert(record.clone()).await.unwrap(); + repository + .call(|connection| { + connection + .execute_batch( + "DROP INDEX sandbox_records_owner_state_created; \ + DROP INDEX sandbox_records_expiry; \ + DROP INDEX sandbox_records_reconcilable; \ + DROP TABLE snapshot_records; \ + DROP TABLE volume_records; \ + CREATE INDEX sandbox_records_owner_state_created \ + ON sandbox_records(\ + owner_id, state, created_at, sandbox_id\ + ); \ + CREATE INDEX sandbox_records_expiry \ + ON sandbox_records(state, expires_at, sandbox_id); \ + DELETE FROM compatibility_schema_migrations WHERE version >= 2;", + ) + .map_err(|error| unavailable("downgrade migration fixture", error))?; + Ok(()) + }) + .await + .unwrap(); + drop(repository); + + let repository = SqliteSandboxRepository::open(&path).await.unwrap(); + assert!(repository.get(record.sandbox_id()).await.unwrap().is_some()); + let (migrations, expiry_index) = repository + .call(|connection| { + let migrations = connection + .query_row( + "SELECT group_concat(version || ':' || name, ',') \ + FROM compatibility_schema_migrations", + [], + |row| row.get::<_, String>(0), + ) + .map_err(|error| unavailable("read upgraded migration history", error))?; + let expiry_index = connection + .query_row( + "SELECT sql FROM sqlite_master \ + WHERE type = 'index' AND name = 'sandbox_records_expiry'", + [], + |row| row.get::<_, String>(0), + ) + .map_err(|error| unavailable("read upgraded expiry index", error))?; + Ok((migrations, expiry_index)) + }) + .await + .unwrap(); + assert_eq!( + migrations, + "1:lifecycle_records,2:temporal_indexes,3:volume_records,4:snapshot_records" + ); + assert!(expiry_index.contains("julianday(expires_at)")); +} + +#[tokio::test] +async fn records_survive_restart_and_cas_rejects_stale_writers() { + let directory = tempdir().unwrap(); + let path = directory.path().join("control.db"); + let repository = SqliteSandboxRepository::open(&path).await.unwrap(); + let record = running_record("sandbox-1", "owner-1", 0, BTreeMap::new()); + repository.insert(record.clone()).await.unwrap(); + drop(repository); + + let repository = SqliteSandboxRepository::open(&path).await.unwrap(); + let loaded = repository.get(record.sandbox_id()).await.unwrap().unwrap(); + assert_eq!(loaded.owner_id(), "owner-1"); + assert_eq!(loaded.execution_id(), record.execution_id()); + assert_eq!(loaded.credentials(), record.credentials()); + assert_eq!(loaded.routing(), record.routing()); + + let expected = loaded.generation(); + let mut replacement = loaded.clone(); + replacement.replace_expiry(instant(30)).unwrap(); + assert_eq!( + repository + .compare_and_swap(record.sandbox_id(), expected, replacement.clone()) + .await + .unwrap(), + CompareAndSwapResult::Updated + ); + + let mut stale = loaded; + stale.replace_expiry(instant(40)).unwrap(); + assert_eq!( + repository + .compare_and_swap(record.sandbox_id(), expected, stale) + .await + .unwrap(), + CompareAndSwapResult::Conflict { + actual_generation: replacement.generation(), + } + ); +} + +#[tokio::test] +async fn concurrent_cas_allows_exactly_one_writer() { + let directory = tempdir().unwrap(); + let repository = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + let record = running_record("sandbox-1", "owner-1", 0, BTreeMap::new()); + repository.insert(record.clone()).await.unwrap(); + let expected = record.generation(); + let mut first = record.clone(); + first.replace_expiry(instant(30)).unwrap(); + let mut second = record.clone(); + second.replace_expiry(instant(40)).unwrap(); + + let first_write = repository.compare_and_swap(record.sandbox_id(), expected, first); + let second_write = repository.compare_and_swap(record.sandbox_id(), expected, second); + let (first_result, second_result) = tokio::join!(first_write, second_write); + let results = [first_result.unwrap(), second_result.unwrap()]; + + assert_eq!( + results + .iter() + .filter(|result| matches!(result, CompareAndSwapResult::Updated)) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, CompareAndSwapResult::Conflict { .. })) + .count(), + 1 + ); +} + +#[tokio::test] +async fn claims_expired_records_by_action_in_one_transaction() { + let directory = tempdir().unwrap(); + let repository = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + + let mut kill = running_record("kill", "owner-1", 0, BTreeMap::new()); + kill.replace_expiry(instant(10)).unwrap(); + repository.insert(kill).await.unwrap(); + + let mut pause = running_record_with_action( + "pause", + "owner-1", + 0, + BTreeMap::new(), + OnTimeoutAction::Pause, + ); + pause.replace_expiry(instant(10)).unwrap(); + repository.insert(pause).await.unwrap(); + + let mut already_paused = running_record_with_action( + "already-paused", + "owner-1", + 0, + BTreeMap::new(), + OnTimeoutAction::Pause, + ); + already_paused.begin_pause().unwrap(); + already_paused + .mark_paused(ExecutionLease { + execution_id: already_paused.execution_id().unwrap().clone(), + generation: ExecutionGeneration::new(2).unwrap(), + plan: already_paused.plan().clone(), + resources: already_paused.resources().clone(), + started_at: already_paused.started_at().unwrap(), + }) + .unwrap(); + already_paused.replace_expiry(instant(10)).unwrap(); + repository.insert(already_paused).await.unwrap(); + + let mut renewed = running_record("renewed", "owner-1", 0, BTreeMap::new()); + renewed.replace_expiry(instant(30)).unwrap(); + repository.insert(renewed).await.unwrap(); + + let claimed = repository + .claim_expired(instant(20), NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + let states = claimed + .iter() + .map(|record| (record.sandbox_id().as_str(), record.state())) + .collect::>(); + + assert_eq!(states.len(), 2); + assert_eq!(states["kill"], crate::control::LifecycleState::Killing); + assert_eq!(states["pause"], crate::control::LifecycleState::Pausing); + assert_eq!( + repository + .get(&SandboxId::new("already-paused").unwrap()) + .await + .unwrap() + .unwrap() + .state(), + crate::control::LifecycleState::Paused + ); + assert_eq!( + repository + .get(&SandboxId::new("renewed").unwrap()) + .await + .unwrap() + .unwrap() + .state(), + crate::control::LifecycleState::Running + ); +} + +#[tokio::test] +async fn expiry_claim_compares_fractional_rfc3339_values_chronologically() { + let directory = tempdir().unwrap(); + let repository = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + let mut record = running_record("sandbox-1", "owner-1", 0, BTreeMap::new()); + record.replace_expiry(instant(10)).unwrap(); + repository.insert(record).await.unwrap(); + + let claimed = repository + .claim_expired( + instant(10) + Duration::milliseconds(1), + NonZeroU32::new(1).unwrap(), + ) + .await + .unwrap(); + + assert_eq!(claimed.len(), 1); + assert_eq!(claimed[0].state(), crate::control::LifecycleState::Killing); +} + +#[tokio::test] +async fn timeout_replacement_and_expiry_claim_have_one_winner() { + let directory = tempdir().unwrap(); + let path = directory.path().join("control.db"); + let reaper = SqliteSandboxRepository::open(&path).await.unwrap(); + let api = SqliteSandboxRepository::open(&path).await.unwrap(); + let mut record = running_record("sandbox-1", "owner-1", 0, BTreeMap::new()); + record.replace_expiry(instant(10)).unwrap(); + reaper.insert(record.clone()).await.unwrap(); + + let expected = record.generation(); + let mut renewed = record.clone(); + renewed.replace_expiry(instant(40)).unwrap(); + let claim = reaper.claim_expired(instant(20), NonZeroU32::new(1).unwrap()); + let replace = api.compare_and_swap(record.sandbox_id(), expected, renewed); + let (claimed, replaced) = tokio::join!(claim, replace); + let claimed = claimed.unwrap(); + let replaced = replaced.unwrap(); + + assert!(matches!( + (claimed.len(), replaced), + (1, CompareAndSwapResult::Conflict { .. }) | (0, CompareAndSwapResult::Updated) + )); + let persisted = reaper.get(record.sandbox_id()).await.unwrap().unwrap(); + if claimed.is_empty() { + assert_eq!(persisted.state(), crate::control::LifecycleState::Running); + assert_eq!(persisted.expires_at(), instant(40)); + } else { + assert_eq!(persisted.state(), crate::control::LifecycleState::Killing); + assert_eq!(persisted.expires_at(), instant(10)); + } +} + +#[tokio::test] +async fn list_preserves_owner_metadata_state_and_cursor_boundaries() { + let directory = tempdir().unwrap(); + let repository = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + for record in [ + running_record( + "sandbox-1", + "owner-1", + 0, + BTreeMap::from([("team".to_string(), "alpha".to_string())]), + ), + running_record( + "sandbox-2", + "owner-1", + 2, + BTreeMap::from([("team".to_string(), "alpha".to_string())]), + ), + running_record( + "sandbox-3", + "owner-2", + 4, + BTreeMap::from([("team".to_string(), "alpha".to_string())]), + ), + running_record( + "sandbox-4", + "owner-1", + 6, + BTreeMap::from([("team".to_string(), "beta".to_string())]), + ), + ] { + repository.insert(record).await.unwrap(); + } + + let first = repository + .list(&SandboxListFilter { + owner_id: "owner-1".to_string(), + metadata: BTreeMap::from([("team".to_string(), "alpha".to_string())]), + states: BTreeSet::from([PublicSandboxState::Running]), + limit: NonZeroU32::new(1).unwrap(), + after: None, + }) + .await + .unwrap(); + assert_eq!(first.records[0].sandbox_id().as_str(), "sandbox-1"); + assert!(first.next.is_some()); + + let second = repository + .list(&SandboxListFilter { + owner_id: "owner-1".to_string(), + metadata: BTreeMap::from([("team".to_string(), "alpha".to_string())]), + states: BTreeSet::new(), + limit: NonZeroU32::new(1).unwrap(), + after: first.next, + }) + .await + .unwrap(); + assert_eq!(second.records[0].sandbox_id().as_str(), "sandbox-2"); + assert!(second.next.is_none()); +} + +#[tokio::test] +async fn list_orders_fractional_rfc3339_timestamps_chronologically() { + let directory = tempdir().unwrap(); + let repository = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + let early = running_record_at( + "z-early", + "owner-1", + instant(0), + BTreeMap::new(), + OnTimeoutAction::Kill, + ); + let late = running_record_at( + "a-late", + "owner-1", + instant(0) + Duration::milliseconds(1), + BTreeMap::new(), + OnTimeoutAction::Kill, + ); + repository.insert(late).await.unwrap(); + repository.insert(early).await.unwrap(); + + let first = repository + .list(&SandboxListFilter { + owner_id: "owner-1".to_string(), + metadata: BTreeMap::new(), + states: BTreeSet::new(), + limit: NonZeroU32::new(1).unwrap(), + after: None, + }) + .await + .unwrap(); + assert_eq!(first.records[0].sandbox_id().as_str(), "z-early"); + + let second = repository + .list(&SandboxListFilter { + owner_id: "owner-1".to_string(), + metadata: BTreeMap::new(), + states: BTreeSet::new(), + limit: NonZeroU32::new(1).unwrap(), + after: first.next.as_ref().cloned(), + }) + .await + .unwrap(); + assert_eq!(second.records[0].sandbox_id().as_str(), "a-late"); +} + +#[tokio::test] +async fn semantically_corrupt_rows_are_rejected_on_read() { + let directory = tempdir().unwrap(); + let repository = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + let record = running_record("sandbox-1", "owner-1", 0, BTreeMap::new()); + repository.insert(record.clone()).await.unwrap(); + let sandbox_id = record.sandbox_id().to_string(); + + repository + .call(move |connection| { + connection + .execute( + "UPDATE sandbox_records \ + SET record_json = json_set(\ + record_json,\ + '$.credentials.envd.key_version',\ + 0\ + ) \ + WHERE sandbox_id = ?1", + [sandbox_id], + ) + .map_err(|error| unavailable("inject corrupt test row", error))?; + Ok(()) + }) + .await + .unwrap(); + + assert!(matches!( + repository.get(record.sandbox_id()).await, + Err(RepositoryError::Corrupt(_)) + )); + + let second = running_record("sandbox-2", "owner-1", 2, BTreeMap::new()); + repository.insert(second.clone()).await.unwrap(); + let sandbox_id = second.sandbox_id().to_string(); + repository + .call(move |connection| { + connection + .execute( + "UPDATE sandbox_records \ + SET record_json = json_remove(record_json, '$.execution_id') \ + WHERE sandbox_id = ?1", + [sandbox_id], + ) + .map_err(|error| unavailable("inject inconsistent test row", error))?; + Ok(()) + }) + .await + .unwrap(); + assert!(matches!( + repository.get(second.sandbox_id()).await, + Err(RepositoryError::Corrupt(_)) + )); +} + +#[tokio::test] +async fn unknown_migration_history_refuses_to_open() { + let directory = tempdir().unwrap(); + let path = directory.path().join("control.db"); + let repository = SqliteSandboxRepository::open(&path).await.unwrap(); + repository + .call(|connection| { + connection + .execute( + "INSERT INTO compatibility_schema_migrations(version, name, applied_at) \ + VALUES (99, 'future', '2026-07-14T12:00:00Z')", + [], + ) + .map_err(|error| unavailable("inject future migration", error))?; + Ok(()) + }) + .await + .unwrap(); + drop(repository); + + assert!(matches!( + SqliteSandboxRepository::open(path).await, + Err(RepositoryError::Corrupt(_)) + )); +} diff --git a/src/compat/src/control/supervisor.rs b/src/compat/src/control/supervisor.rs new file mode 100644 index 00000000..a16e603a --- /dev/null +++ b/src/compat/src/control/supervisor.rs @@ -0,0 +1,542 @@ +use std::num::NonZeroU32; +use std::sync::Arc; + +use a3s_box_core::{ + ExecutionLease, ExecutionManager, ExecutionManagerError, ExecutionReservation, ExecutionState, + ReconcileOutcome, +}; +use thiserror::Error; + +use super::lifetime::ready_lifetime; +use super::{ + Clock, CompareAndSwapResult, LifecycleError, LifecycleFailure, LifecycleState, RepositoryError, + SandboxGeneration, SandboxId, SandboxRecord, SandboxRepository, +}; + +#[derive(Clone)] +pub struct LifecycleSupervisor { + repository: Arc, + executions: Arc, + clock: Arc, +} + +pub struct LifecycleSupervisorDependencies { + pub repository: Arc, + pub executions: Arc, + pub clock: Arc, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LifecycleMaintenanceReport { + pub examined: usize, + pub completed: usize, + pub deferred: usize, + pub failures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LifecycleMaintenanceFailure { + pub sandbox_id: SandboxId, + pub message: String, +} + +#[derive(Debug, Error)] +pub enum LifecycleSupervisorError { + #[error(transparent)] + Repository(#[from] RepositoryError), +} + +pub type LifecycleSupervisorResult = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaintenanceDisposition { + Completed, + Deferred, +} + +#[derive(Debug, Error)] +enum MaintenanceItemError { + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Execution(#[from] ExecutionManagerError), + #[error(transparent)] + Lifecycle(#[from] LifecycleError), + #[error("inconsistent runtime reconciliation evidence: {0}")] + Inconsistent(String), +} + +type MaintenanceItemResult = std::result::Result; + +impl LifecycleSupervisor { + pub fn new(dependencies: LifecycleSupervisorDependencies) -> Self { + Self { + repository: dependencies.repository, + executions: dependencies.executions, + clock: dependencies.clock, + } + } + + /// Claim and finish one bounded batch of expired lifecycle records. + pub async fn reap_expired( + &self, + limit: NonZeroU32, + ) -> LifecycleSupervisorResult { + let claimed = self + .repository + .claim_expired(self.clock.now(), limit) + .await?; + let mut report = LifecycleMaintenanceReport::default(); + for record in claimed { + report.observe(record.sandbox_id().clone(), self.finish_claim(record).await); + } + Ok(report) + } + + /// Reconcile every non-terminal record once after service startup. + pub async fn reconcile_startup( + &self, + page_size: NonZeroU32, + ) -> LifecycleSupervisorResult { + let mut report = LifecycleMaintenanceReport::default(); + let mut cursor = None; + loop { + let page = self + .repository + .list_reconcilable(cursor.as_ref(), page_size) + .await?; + let next = page.next; + for record in page.records { + report.observe( + record.sandbox_id().clone(), + self.reconcile_record(record).await, + ); + } + let Some(next) = next else { + break; + }; + cursor = Some(next); + } + Ok(report) + } + + async fn finish_claim( + &self, + record: SandboxRecord, + ) -> MaintenanceItemResult { + match record.state() { + LifecycleState::Pausing => self.finish_pause(record).await, + LifecycleState::Killing => self.finish_kill(record).await, + state => Err(MaintenanceItemError::Inconsistent(format!( + "expiry claim returned {state:?}" + ))), + } + } + + async fn reconcile_record( + &self, + record: SandboxRecord, + ) -> MaintenanceItemResult { + let outcome = self.executions.reconcile(record.operation_id()).await?; + match outcome { + ReconcileOutcome::Absent | ReconcileOutcome::Failed => { + self.finish_missing(record).await + } + ReconcileOutcome::Created(reservation) => { + self.reconcile_created(record, reservation).await + } + ReconcileOutcome::Creating => match record.state() { + LifecycleState::Creating | LifecycleState::Killing => { + Ok(MaintenanceDisposition::Deferred) + } + state => Err(MaintenanceItemError::Inconsistent(format!( + "persisted state {state:?} is ahead of a creating runtime" + ))), + }, + ReconcileOutcome::Ready(lease) => self.reconcile_ready(record, lease).await, + } + } + + async fn reconcile_created( + &self, + record: SandboxRecord, + reservation: ExecutionReservation, + ) -> MaintenanceItemResult { + ensure_created_reservation(&record, &reservation)?; + match record.state() { + LifecycleState::Creating => { + let lease = self + .executions + .start(&reservation.execution_id, reservation.generation) + .await?; + if lease.execution_id != reservation.execution_id + || lease.generation != reservation.generation + || lease.plan != reservation.plan + || !same_resources(&lease.resources, &reservation.resources) + { + return Err(MaintenanceItemError::Inconsistent( + "created reservation and started execution disagree".to_string(), + )); + } + self.publish_running(record, lease).await + } + LifecycleState::Killing => { + self.finish_kill_with_target( + record, + Some((reservation.execution_id, reservation.generation)), + ) + .await + } + state => Err(MaintenanceItemError::Inconsistent(format!( + "persisted state {state:?} disagrees with a created runtime reservation" + ))), + } + } + + async fn reconcile_ready( + &self, + record: SandboxRecord, + lease: ExecutionLease, + ) -> MaintenanceItemResult { + let status = match self.executions.inspect(&lease.execution_id).await { + Ok(status) => status, + Err(ExecutionManagerError::NotFound(_)) => return self.finish_missing(record).await, + Err(error) => return Err(error.into()), + }; + if status.execution_id != lease.execution_id + || status.generation != lease.generation + || status.plan != lease.plan + { + return Err(MaintenanceItemError::Inconsistent( + "reconcile lease and inspection status disagree".to_string(), + )); + } + + match status.state { + ExecutionState::Created => Err(MaintenanceItemError::Inconsistent( + "ready reconciliation inspected a created execution".to_string(), + )), + ExecutionState::Creating => match record.state() { + LifecycleState::Creating | LifecycleState::Killing => { + Ok(MaintenanceDisposition::Deferred) + } + state => Err(MaintenanceItemError::Inconsistent(format!( + "persisted state {state:?} is ahead of inspected creating runtime" + ))), + }, + ExecutionState::Stopped | ExecutionState::Failed => self.finish_missing(record).await, + ExecutionState::Running => match record.state() { + LifecycleState::Creating | LifecycleState::Resuming => { + self.publish_running(record, lease).await + } + LifecycleState::Running => { + ensure_current_execution(&record, &lease)?; + Ok(MaintenanceDisposition::Completed) + } + LifecycleState::Pausing => { + ensure_current_execution(&record, &lease)?; + self.finish_pause(record).await + } + LifecycleState::Killing => { + ensure_kill_target(&record, &lease)?; + self.finish_kill_with_target( + record, + Some((lease.execution_id, lease.generation)), + ) + .await + } + state => Err(MaintenanceItemError::Inconsistent(format!( + "persisted state {state:?} disagrees with running runtime" + ))), + }, + ExecutionState::Paused => match record.state() { + LifecycleState::Paused => { + ensure_current_execution(&record, &lease)?; + Ok(MaintenanceDisposition::Completed) + } + LifecycleState::Pausing => self.publish_paused(record, lease).await, + LifecycleState::Resuming => { + ensure_current_execution(&record, &lease)?; + self.finish_resume(record).await + } + LifecycleState::Killing => { + ensure_kill_target(&record, &lease)?; + self.finish_kill_with_target( + record, + Some((lease.execution_id, lease.generation)), + ) + .await + } + state => Err(MaintenanceItemError::Inconsistent(format!( + "persisted state {state:?} disagrees with paused runtime" + ))), + }, + } + } + + async fn finish_pause( + &self, + record: SandboxRecord, + ) -> MaintenanceItemResult { + let execution_id = required_execution_id(&record)?.clone(); + let execution_generation = required_execution_generation(&record)?; + let lease = match self + .executions + .pause( + &execution_id, + execution_generation, + record.lifecycle().keep_memory_on_pause, + ) + .await + { + Ok(lease) => lease, + Err(ExecutionManagerError::NotFound(_)) => return self.publish_failed(record).await, + Err(error) => return Err(error.into()), + }; + self.publish_paused(record, lease).await + } + + async fn finish_resume( + &self, + record: SandboxRecord, + ) -> MaintenanceItemResult { + let execution_id = required_execution_id(&record)?.clone(); + let execution_generation = required_execution_generation(&record)?; + let lease = match self + .executions + .resume(&execution_id, execution_generation) + .await + { + Ok(lease) => lease, + Err(ExecutionManagerError::NotFound(_)) => return self.publish_failed(record).await, + Err(error) => return Err(error.into()), + }; + self.publish_running(record, lease).await + } + + async fn finish_kill( + &self, + record: SandboxRecord, + ) -> MaintenanceItemResult { + let target = if let (Some(execution_id), Some(execution_generation)) = ( + record.execution_id().cloned(), + record.execution_generation(), + ) { + Some((execution_id, execution_generation)) + } else { + None + }; + self.finish_kill_with_target(record, target).await + } + + async fn finish_kill_with_target( + &self, + mut record: SandboxRecord, + target: Option<(a3s_box_core::ExecutionId, a3s_box_core::ExecutionGeneration)>, + ) -> MaintenanceItemResult { + if let Some((execution_id, execution_generation)) = target { + match self + .executions + .kill(&execution_id, execution_generation) + .await + { + Ok(_) | Err(ExecutionManagerError::NotFound(_)) => {} + Err(error) => return Err(error.into()), + } + } + let expected = record.generation(); + record.mark_killed()?; + self.replace(expected, record).await + } + + async fn finish_missing( + &self, + mut record: SandboxRecord, + ) -> MaintenanceItemResult { + if record.state() == LifecycleState::Killing { + let expected = record.generation(); + record.mark_killed()?; + self.replace(expected, record).await + } else { + self.publish_failed(record).await + } + } + + async fn publish_running( + &self, + mut record: SandboxRecord, + lease: ExecutionLease, + ) -> MaintenanceItemResult { + let expected = record.generation(); + if record.state() == LifecycleState::Creating { + let (ready_at, expires_at) = ready_lifetime( + self.clock.now(), + lease.started_at, + record.resources().timeout, + ) + .map_err(|error| MaintenanceItemError::Inconsistent(error.to_string()))?; + record.mark_ready(lease, ready_at, expires_at)?; + } else { + record.mark_running(lease)?; + } + self.replace(expected, record).await + } + + async fn publish_paused( + &self, + mut record: SandboxRecord, + lease: ExecutionLease, + ) -> MaintenanceItemResult { + let expected = record.generation(); + record.mark_paused(lease)?; + self.replace(expected, record).await + } + + async fn publish_failed( + &self, + mut record: SandboxRecord, + ) -> MaintenanceItemResult { + let expected = record.generation(); + record.mark_failed(LifecycleFailure::ReconciliationFailed)?; + self.replace(expected, record).await + } + + async fn replace( + &self, + expected: SandboxGeneration, + replacement: SandboxRecord, + ) -> MaintenanceItemResult { + let sandbox_id = replacement.sandbox_id().clone(); + Ok( + match self + .repository + .compare_and_swap(&sandbox_id, expected, replacement) + .await? + { + CompareAndSwapResult::Updated => MaintenanceDisposition::Completed, + CompareAndSwapResult::NotFound | CompareAndSwapResult::Conflict { .. } => { + MaintenanceDisposition::Deferred + } + }, + ) + } +} + +fn ensure_created_reservation( + record: &SandboxRecord, + reservation: &ExecutionReservation, +) -> MaintenanceItemResult<()> { + if record.plan() != &reservation.plan { + return Err(MaintenanceItemError::Inconsistent( + "created reservation plan differs from persisted sandbox".to_string(), + )); + } + if !same_resources(record.resources(), &reservation.resources) { + return Err(MaintenanceItemError::Inconsistent( + "created reservation resources differ from persisted sandbox".to_string(), + )); + } + match (record.execution_id(), record.execution_generation()) { + (None, None) => Ok(()), + (Some(execution_id), Some(generation)) + if execution_id == &reservation.execution_id + && generation == reservation.generation => + { + Ok(()) + } + _ => Err(MaintenanceItemError::Inconsistent( + "created reservation differs from persisted execution mapping".to_string(), + )), + } +} + +fn same_resources( + left: &a3s_box_core::ResourceConfig, + right: &a3s_box_core::ResourceConfig, +) -> bool { + left.vcpus == right.vcpus + && left.memory_mb == right.memory_mb + && left.disk_mb == right.disk_mb + && left.timeout == right.timeout +} + +impl LifecycleMaintenanceReport { + fn observe( + &mut self, + sandbox_id: SandboxId, + result: MaintenanceItemResult, + ) { + self.examined += 1; + match result { + Ok(MaintenanceDisposition::Completed) => self.completed += 1, + Ok(MaintenanceDisposition::Deferred) => self.deferred += 1, + Err(error) => self.failures.push(LifecycleMaintenanceFailure { + sandbox_id, + message: error.to_string(), + }), + } + } +} + +fn ensure_current_execution( + record: &SandboxRecord, + lease: &ExecutionLease, +) -> MaintenanceItemResult<()> { + if record.execution_id() != Some(&lease.execution_id) { + return Err(MaintenanceItemError::Inconsistent( + "runtime execution ID differs from persisted mapping".to_string(), + )); + } + if record.execution_generation() != Some(lease.generation) { + return Err(MaintenanceItemError::Inconsistent( + "runtime execution generation differs from persisted mapping".to_string(), + )); + } + if record.plan() != &lease.plan { + return Err(MaintenanceItemError::Inconsistent( + "runtime execution plan differs from persisted mapping".to_string(), + )); + } + Ok(()) +} + +fn ensure_kill_target(record: &SandboxRecord, lease: &ExecutionLease) -> MaintenanceItemResult<()> { + if record.plan() != &lease.plan { + return Err(MaintenanceItemError::Inconsistent( + "runtime execution plan differs from persisted kill target".to_string(), + )); + } + match (record.execution_id(), record.execution_generation()) { + (None, None) => Ok(()), + (Some(execution_id), Some(generation)) + if execution_id == &lease.execution_id && generation == lease.generation => + { + Ok(()) + } + _ => Err(MaintenanceItemError::Inconsistent( + "runtime execution differs from persisted kill target".to_string(), + )), + } +} + +fn required_execution_id( + record: &SandboxRecord, +) -> MaintenanceItemResult<&a3s_box_core::ExecutionId> { + record.execution_id().ok_or_else(|| { + MaintenanceItemError::Inconsistent(format!( + "persisted state {:?} has no execution ID", + record.state() + )) + }) +} + +fn required_execution_generation( + record: &SandboxRecord, +) -> MaintenanceItemResult { + record.execution_generation().ok_or_else(|| { + MaintenanceItemError::Inconsistent(format!( + "persisted state {:?} has no execution generation", + record.state() + )) + }) +} diff --git a/src/compat/src/control/supervisor_tests.rs b/src/compat/src/control/supervisor_tests.rs new file mode 100644 index 00000000..43d3482d --- /dev/null +++ b/src/compat/src/control/supervisor_tests.rs @@ -0,0 +1,507 @@ +use std::collections::BTreeMap; +use std::num::NonZeroU32; +use std::sync::Arc; + +use a3s_box_core::{ + resolve_execution, BoxConfig, CreateExecutionRequest, ExecutionGeneration, ExecutionIsolation, + ExecutionManager, ExecutionState, OperationId, +}; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use tempfile::tempdir; + +use super::test_support::RecordingExecutionManager; +use super::*; + +fn instant(second: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 14, 12, 0, second) + .single() + .unwrap() +} + +#[derive(Debug)] +struct FixedClock(DateTime); + +impl Clock for FixedClock { + fn now(&self) -> DateTime { + self.0 + } +} + +fn stored_token(marker: u8) -> StoredToken { + StoredToken::new(1, vec![marker, 1], vec![marker, 2]).unwrap() +} + +fn creating_record(sandbox_id: &str, action: OnTimeoutAction) -> (SandboxRecord, BoxConfig) { + let config = BoxConfig { + isolation: ExecutionIsolation::Sandbox, + ..BoxConfig::default() + }; + let record = SandboxRecord::creating(NewSandboxRecord { + sandbox_id: SandboxId::new(sandbox_id).unwrap(), + operation_id: OperationId::new(format!("operation-{sandbox_id}")).unwrap(), + owner_id: "owner-1".to_string(), + template_id: "fixture-template".to_string(), + plan: resolve_execution(&config).unwrap(), + resources: config.resources.clone(), + lifecycle: LifecyclePolicy { + on_timeout: action, + auto_resume: false, + keep_memory_on_pause: false, + }, + created_at: instant(0), + expires_at: instant(10), + metadata: BTreeMap::new(), + envd_version: "0.1.3".to_string(), + envd_mode: EnvdMode::Broker, + secure: true, + allow_internet_access: Some(false), + credentials: SandboxCredentials { + envd: stored_token(10), + traffic: stored_token(20), + }, + routing: crate::routing::SandboxRoutePolicy::default(), + }) + .unwrap(); + (record, config) +} + +async fn start_runtime( + manager: &RecordingExecutionManager, + record: &SandboxRecord, + config: BoxConfig, +) -> a3s_box_core::ExecutionLease { + manager + .create_and_start( + CreateExecutionRequest { + external_sandbox_id: record.sandbox_id().to_string(), + config, + labels: BTreeMap::new(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + record.operation_id(), + ) + .await + .unwrap() +} + +async fn create_runtime( + manager: &RecordingExecutionManager, + record: &SandboxRecord, + config: BoxConfig, +) -> a3s_box_core::ExecutionReservation { + manager + .create( + CreateExecutionRequest { + external_sandbox_id: record.sandbox_id().to_string(), + config, + labels: BTreeMap::new(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + record.operation_id(), + ) + .await + .unwrap() +} + +async fn assert_created_reservation_drift_is_rejected( + sandbox_id: &str, + record: SandboxRecord, + runtime_config: BoxConfig, + expected_failure: &str, +) { + let repository = Arc::new(MemorySandboxRepository::default()); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + repository.insert(record.clone()).await.unwrap(); + let reservation = create_runtime(&executions, &record, runtime_config).await; + + let report = supervisor(repository, executions.clone(), clock) + .reconcile_startup(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + + assert_eq!(report.examined, 1, "{sandbox_id}"); + assert_eq!(report.completed, 0, "{sandbox_id}"); + assert_eq!(report.failures.len(), 1, "{sandbox_id}"); + assert!( + report.failures[0].message.contains(expected_failure), + "unexpected reconciliation failure for {sandbox_id}: {}", + report.failures[0].message + ); + assert_eq!( + executions + .inspect(&reservation.execution_id) + .await + .unwrap() + .state, + ExecutionState::Created, + "reconciliation must reject drift before starting {sandbox_id}" + ); +} + +fn supervisor( + repository: Arc, + executions: Arc, + clock: Arc, +) -> LifecycleSupervisor { + LifecycleSupervisor::new(LifecycleSupervisorDependencies { + repository, + executions, + clock, + }) +} + +#[tokio::test] +async fn reaper_completes_generation_fenced_kill_and_pause() { + let repository = Arc::new(MemorySandboxRepository::default()); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + + for (sandbox_id, action) in [ + ("kill", OnTimeoutAction::Kill), + ("pause", OnTimeoutAction::Pause), + ] { + let (mut record, config) = creating_record(sandbox_id, action); + let lease = start_runtime(&executions, &record, config).await; + record.mark_running(lease).unwrap(); + repository.insert(record).await.unwrap(); + } + + let report = supervisor(repository.clone(), executions.clone(), clock) + .reap_expired(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + + assert_eq!(report.examined, 2); + assert_eq!(report.completed, 2); + assert_eq!(report.deferred, 0); + assert!(report.failures.is_empty()); + assert_eq!( + repository + .get(&SandboxId::new("kill").unwrap()) + .await + .unwrap() + .unwrap() + .state(), + LifecycleState::Killed + ); + let paused = repository + .get(&SandboxId::new("pause").unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!(paused.state(), LifecycleState::Paused); + assert_eq!(paused.execution_generation().unwrap().get(), 2); + assert_eq!( + executions + .inspect(paused.execution_id().unwrap()) + .await + .unwrap() + .state, + ExecutionState::Paused + ); +} + +#[tokio::test] +async fn startup_recovers_create_committed_only_in_runtime() { + let directory = tempdir().unwrap(); + let path = directory.path().join("control.db"); + let repository = SqliteSandboxRepository::open(&path).await.unwrap(); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + let (record, config) = creating_record("sandbox-1", OnTimeoutAction::Kill); + repository.insert(record.clone()).await.unwrap(); + let lease = start_runtime(&executions, &record, config).await; + drop(repository); + + let repository = Arc::new(SqliteSandboxRepository::open(&path).await.unwrap()); + let report = supervisor(repository.clone(), executions, clock) + .reconcile_startup(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + + assert_eq!(report.examined, 1); + assert_eq!(report.completed, 1); + assert!(report.failures.is_empty()); + let recovered = repository.get(record.sandbox_id()).await.unwrap().unwrap(); + assert_eq!(recovered.state(), LifecycleState::Running); + assert_eq!(recovered.execution_id(), Some(&lease.execution_id)); + assert_eq!(recovered.started_at(), Some(instant(20))); + assert_eq!( + recovered.expires_at(), + instant(20) + Duration::seconds(3600) + ); + assert_eq!( + recovered.execution_generation(), + Some(ExecutionGeneration::INITIAL) + ); +} + +#[tokio::test] +async fn startup_starts_a_durable_runtime_reservation_before_publishing_running() { + let repository = Arc::new(MemorySandboxRepository::default()); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + let (record, config) = creating_record("sandbox-1", OnTimeoutAction::Kill); + repository.insert(record.clone()).await.unwrap(); + let reservation = create_runtime(&executions, &record, config).await; + + let report = supervisor(repository.clone(), executions.clone(), clock) + .reconcile_startup(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + + assert_eq!(report.examined, 1); + assert_eq!(report.completed, 1); + assert!(report.failures.is_empty()); + let recovered = repository.get(record.sandbox_id()).await.unwrap().unwrap(); + assert_eq!(recovered.state(), LifecycleState::Running); + assert_eq!(recovered.execution_id(), Some(&reservation.execution_id)); + assert_eq!(recovered.started_at(), Some(instant(20))); + assert_eq!( + recovered.expires_at(), + instant(20) + Duration::seconds(3600) + ); + assert_eq!( + executions + .inspect(&reservation.execution_id) + .await + .unwrap() + .state, + ExecutionState::Running + ); +} + +#[tokio::test] +async fn startup_rejects_created_plan_drift_before_starting_the_runtime() { + let (record, mut runtime_config) = creating_record("plan-drift", OnTimeoutAction::Kill); + runtime_config.isolation = ExecutionIsolation::Microvm; + + assert_created_reservation_drift_is_rejected( + "plan-drift", + record, + runtime_config, + "reservation plan differs", + ) + .await; +} + +#[tokio::test] +async fn startup_rejects_created_resource_drift_before_starting_the_runtime() { + let (record, mut runtime_config) = creating_record("resource-drift", OnTimeoutAction::Kill); + runtime_config.resources.memory_mb += 1; + + assert_created_reservation_drift_is_rejected( + "resource-drift", + record, + runtime_config, + "reservation resources differ", + ) + .await; +} + +#[tokio::test] +async fn startup_marks_an_absent_incomplete_runtime_as_failed() { + let repository = Arc::new(MemorySandboxRepository::default()); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + let (record, _) = creating_record("sandbox-1", OnTimeoutAction::Kill); + repository.insert(record.clone()).await.unwrap(); + + let report = supervisor(repository.clone(), executions, clock) + .reconcile_startup(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + + assert_eq!(report.completed, 1); + assert!(report.failures.is_empty()); + let failed = repository.get(record.sandbox_id()).await.unwrap().unwrap(); + assert_eq!(failed.state(), LifecycleState::Failed); + assert_eq!( + failed.failure(), + Some(LifecycleFailure::ReconciliationFailed) + ); +} + +#[tokio::test] +async fn startup_kills_a_runtime_created_after_its_record_was_claimed() { + let repository = Arc::new(MemorySandboxRepository::default()); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + let (mut record, config) = creating_record("sandbox-1", OnTimeoutAction::Kill); + repository.insert(record.clone()).await.unwrap(); + let lease = start_runtime(&executions, &record, config).await; + let expected = record.generation(); + record.begin_kill().unwrap(); + assert_eq!( + repository + .compare_and_swap(record.sandbox_id(), expected, record.clone()) + .await + .unwrap(), + CompareAndSwapResult::Updated + ); + + let report = supervisor(repository.clone(), executions.clone(), clock) + .reconcile_startup(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + + assert_eq!(report.completed, 1); + assert!(report.failures.is_empty()); + assert_eq!( + repository + .get(record.sandbox_id()) + .await + .unwrap() + .unwrap() + .state(), + LifecycleState::Killed + ); + assert_eq!( + executions.inspect(&lease.execution_id).await.unwrap().state, + ExecutionState::Stopped + ); +} + +#[tokio::test] +async fn startup_kills_a_created_reservation_without_starting_it() { + let repository = Arc::new(MemorySandboxRepository::default()); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + let (mut record, config) = creating_record("sandbox-1", OnTimeoutAction::Kill); + let reservation = create_runtime(&executions, &record, config).await; + record.begin_kill().unwrap(); + repository.insert(record.clone()).await.unwrap(); + + let report = supervisor(repository.clone(), executions.clone(), clock) + .reconcile_startup(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + + assert_eq!(report.completed, 1); + assert!(report.failures.is_empty()); + assert_eq!( + repository + .get(record.sandbox_id()) + .await + .unwrap() + .unwrap() + .state(), + LifecycleState::Killed + ); + assert_eq!( + executions + .inspect(&reservation.execution_id) + .await + .unwrap() + .state, + ExecutionState::Stopped + ); +} + +#[tokio::test] +async fn startup_finishes_expiry_claims_after_service_crash() { + let directory = tempdir().unwrap(); + let path = directory.path().join("control.db"); + let repository = SqliteSandboxRepository::open(&path).await.unwrap(); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + + for (sandbox_id, action) in [ + ("kill", OnTimeoutAction::Kill), + ("pause", OnTimeoutAction::Pause), + ] { + let (mut record, config) = creating_record(sandbox_id, action); + let lease = start_runtime(&executions, &record, config).await; + record.mark_running(lease).unwrap(); + repository.insert(record).await.unwrap(); + } + let claimed = repository + .claim_expired(instant(20), NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + let pausing = claimed + .iter() + .find(|record| record.state() == LifecycleState::Pausing) + .unwrap(); + executions + .pause( + pausing.execution_id().unwrap(), + pausing.execution_generation().unwrap(), + false, + ) + .await + .unwrap(); + drop(repository); + + let repository = Arc::new(SqliteSandboxRepository::open(&path).await.unwrap()); + let report = supervisor(repository.clone(), executions, clock) + .reconcile_startup(NonZeroU32::new(1).unwrap()) + .await + .unwrap(); + + assert_eq!(report.examined, 2); + assert_eq!(report.completed, 2); + assert!(report.failures.is_empty()); + assert_eq!( + repository + .get(&SandboxId::new("kill").unwrap()) + .await + .unwrap() + .unwrap() + .state(), + LifecycleState::Killed + ); + let paused = repository + .get(&SandboxId::new("pause").unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!(paused.state(), LifecycleState::Paused); + assert_eq!(paused.execution_generation().unwrap().get(), 2); +} + +#[tokio::test] +async fn startup_publishes_a_resume_completed_before_database_commit() { + let repository = Arc::new(MemorySandboxRepository::default()); + let clock: Arc = Arc::new(FixedClock(instant(20))); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + let (mut record, config) = creating_record("sandbox-1", OnTimeoutAction::Pause); + let initial = start_runtime(&executions, &record, config).await; + record.mark_running(initial).unwrap(); + record.begin_pause().unwrap(); + let paused_lease = executions + .pause( + record.execution_id().unwrap(), + record.execution_generation().unwrap(), + false, + ) + .await + .unwrap(); + record.mark_paused(paused_lease).unwrap(); + record.begin_resume().unwrap(); + let resumed_lease = executions + .resume( + record.execution_id().unwrap(), + record.execution_generation().unwrap(), + ) + .await + .unwrap(); + repository.insert(record.clone()).await.unwrap(); + + let report = supervisor(repository.clone(), executions, clock) + .reconcile_startup(NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + + assert_eq!(report.completed, 1); + assert!(report.failures.is_empty()); + let running = repository.get(record.sandbox_id()).await.unwrap().unwrap(); + assert_eq!(running.state(), LifecycleState::Running); + assert_eq!( + running.execution_generation(), + Some(resumed_lease.generation) + ); +} diff --git a/src/compat/src/control/test_support.rs b/src/compat/src/control/test_support.rs new file mode 100644 index 00000000..36351a3e --- /dev/null +++ b/src/compat/src/control/test_support.rs @@ -0,0 +1,702 @@ +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::num::NonZeroU16; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use a3s_box_core::{ + resolve_execution, BoxConfig, CreateExecutionRequest, ExecutionGeneration, ExecutionId, + ExecutionIsolation, ExecutionLease, ExecutionManager, ExecutionManagerError, + ExecutionManagerResult, ExecutionPortConnector, ExecutionPortStream, ExecutionReservation, + ExecutionSnapshot, ExecutionSnapshotId, ExecutionState, ExecutionStatus, KillOutcome, + NetworkMode, OperationId, ReconcileOutcome, ResourceConfig, +}; +use async_trait::async_trait; +use chrono::{DateTime, TimeZone, Utc}; +use sha2::{Digest, Sha256}; + +use super::*; + +pub(crate) struct TestHarness { + pub service: Arc, + pub executions: Arc, + pub repository: Arc, + pub snapshots: Arc, + pub snapshot_repository: Arc, + pub clock: Arc, +} + +impl TestHarness { + pub fn new() -> Self { + Self::with_clock(Arc::new(FixedClock(test_time()))) + } + + pub fn with_clock(clock: Arc) -> Self { + let repository = Arc::new(MemorySandboxRepository::default()); + let executions = Arc::new(RecordingExecutionManager::new(clock.clone())); + let tokens = Arc::new(TestTokens); + let snapshot_repository = Arc::new(crate::snapshot::MemorySnapshotRepository::default()); + let snapshots = Arc::new(crate::snapshot::SnapshotService::new( + crate::snapshot::SnapshotServiceDependencies { + repository: snapshot_repository.clone(), + executions: executions.clone(), + clock: clock.clone(), + }, + )); + let templates = Arc::new(crate::snapshot::SnapshotTemplateProvider::new( + Arc::new(TestTemplates), + snapshot_repository.clone(), + )); + let service = Arc::new( + ControlService::new(ControlServiceDependencies { + repository: repository.clone(), + executions: executions.clone(), + ports: executions.clone(), + clock: clock.clone(), + identities: Arc::new(TestIdentities::default()), + templates, + token_issuer: tokens.clone(), + token_resolver: tokens, + }) + .with_snapshot_service(snapshots.clone()), + ); + Self { + service, + executions, + repository, + snapshots, + snapshot_repository, + clock, + } + } +} + +pub(crate) struct AdvancingClock { + initial: DateTime, + advanced: DateTime, + calls: AtomicU64, +} + +impl AdvancingClock { + pub fn new(initial: DateTime, advanced: DateTime) -> Self { + Self { + initial, + advanced, + calls: AtomicU64::new(0), + } + } +} + +impl Clock for AdvancingClock { + fn now(&self) -> DateTime { + if self.calls.fetch_add(1, Ordering::Relaxed) == 0 { + self.initial + } else { + self.advanced + } + } +} + +pub(crate) fn create_request(owner_id: &str) -> CreateSandboxRequest { + CreateSandboxRequest { + owner_id: owner_id.to_string(), + template_id: "fixture-template".to_string(), + timeout_seconds: 321, + lifecycle: LifecyclePolicy { + on_timeout: OnTimeoutAction::Pause, + auto_resume: false, + keep_memory_on_pause: false, + }, + metadata: BTreeMap::from([ + ("purpose".to_string(), "fixture".to_string()), + ("team".to_string(), "alpha beta".to_string()), + ]), + env_vars: BTreeMap::from([ + ("ALPHA".to_string(), "one".to_string()), + ("BETA".to_string(), "two".to_string()), + ]), + secure: true, + allow_internet_access: Some(false), + } +} + +pub(crate) fn test_time() -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 14, 12, 0, 0) + .single() + .unwrap() +} + +struct FixedClock(DateTime); + +impl Clock for FixedClock { + fn now(&self) -> DateTime { + self.0 + } +} + +#[derive(Default)] +struct TestIdentities { + sequence: AtomicU64, +} + +impl SandboxIdentityProvider for TestIdentities { + fn next_identity(&self) -> IdentityProviderResult { + let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; + Ok(SandboxIdentity { + sandbox_id: SandboxId::new(format!("sandbox-{sequence}")).unwrap(), + operation_id: OperationId::new(format!("operation-{sequence}")).unwrap(), + }) + } +} + +struct TestTemplates; + +#[async_trait] +impl TemplateProvider for TestTemplates { + async fn resolve( + &self, + _owner_id: &str, + template_id: &str, + ) -> TemplateProviderResult { + if !matches!( + template_id, + "fixture-template" | "code-interpreter-v1" | "runtime-envd-template" + ) { + return Err(TemplateProviderError::NotFound(template_id.to_string())); + } + Ok(ResolvedTemplate { + config: BoxConfig { + isolation: ExecutionIsolation::Sandbox, + resources: ResourceConfig { + vcpus: 2, + memory_mb: 512, + disk_mb: 1024, + timeout: 300, + }, + ..BoxConfig::default() + }, + envd_version: "0.1.3".to_string(), + envd_mode: if template_id == "runtime-envd-template" { + EnvdMode::Runtime + } else { + EnvdMode::Broker + }, + routing: if template_id == "code-interpreter-v1" { + crate::routing::SandboxRoutePolicy::default() + .with_port(crate::routing::CODE_INTERPRETER_PORT, TokenScope::Traffic) + .unwrap() + } else { + crate::routing::SandboxRoutePolicy::default() + }, + rootfs_snapshot_id: None, + }) + } +} + +struct TestTokens; + +#[async_trait] +impl TokenIssuer for TestTokens { + async fn issue(&self, scope: TokenScope) -> TokenIssuerResult { + let secret = match scope { + TokenScope::Envd => "fixture-envd-token", + TokenScope::Traffic => "fixture-traffic-token", + TokenScope::Volume => "fixture-volume-token", + }; + Ok(IssuedToken { + secret: SecretToken::new(secret)?, + stored: stored_token(secret), + }) + } +} + +#[async_trait] +impl TokenResolver for TestTokens { + async fn resolve( + &self, + _scope: TokenScope, + stored: &StoredToken, + ) -> TokenIssuerResult { + SecretToken::new( + std::str::from_utf8(stored.ciphertext()) + .map_err(|_| TokenIssuerError::InvalidMaterial)?, + ) + } +} + +fn stored_token(secret: &str) -> StoredToken { + let ciphertext = secret.as_bytes().to_vec(); + let digest = Sha256::digest(&ciphertext).to_vec(); + StoredToken::new(1, ciphertext, digest).unwrap() +} + +#[derive(Clone)] +struct TestExecution { + lease: ExecutionLease, + state: ExecutionState, + rootfs_snapshot_id: Option, +} + +pub(crate) struct RecordingExecutionManager { + clock: Arc, + fail_create: AtomicBool, + fail_ports: AtomicBool, + port_requests: Mutex>, + runtime_envd_status: AtomicU16, + runtime_envd_requests: Arc>>, + requests: Mutex>, + operations: Mutex>, + executions: Mutex>, + snapshots: Mutex>, +} + +impl RecordingExecutionManager { + pub(crate) fn new(clock: Arc) -> Self { + Self { + clock, + fail_create: AtomicBool::new(false), + fail_ports: AtomicBool::new(false), + port_requests: Mutex::new(Vec::new()), + runtime_envd_status: AtomicU16::new(hyper::StatusCode::NO_CONTENT.as_u16()), + runtime_envd_requests: Arc::new(Mutex::new(Vec::new())), + requests: Mutex::new(Vec::new()), + operations: Mutex::new(BTreeMap::new()), + executions: Mutex::new(BTreeMap::new()), + snapshots: Mutex::new(BTreeMap::new()), + } + } + + pub fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + pub fn fail_create(&self) { + self.fail_create.store(true, Ordering::Relaxed); + } + + pub fn fail_ports(&self) { + self.fail_ports.store(true, Ordering::Relaxed); + } + + pub fn fail_runtime_envd_init(&self) { + self.runtime_envd_status + .store(hyper::StatusCode::BAD_REQUEST.as_u16(), Ordering::Relaxed); + } + + pub fn port_requests(&self) -> Vec<(String, u64, u16)> { + self.port_requests.lock().unwrap().clone() + } + + pub fn runtime_envd_requests(&self) -> Vec<(String, String, serde_json::Value)> { + self.runtime_envd_requests.lock().unwrap().clone() + } + + pub fn execution_state(&self, execution_id: &str) -> Option { + self.executions + .lock() + .unwrap() + .get(execution_id) + .map(|execution| execution.state) + } + + pub fn snapshot_ids(&self) -> Vec { + self.snapshots.lock().unwrap().keys().cloned().collect() + } + + fn reservation(execution: &TestExecution) -> ExecutionReservation { + ExecutionReservation { + execution_id: execution.lease.execution_id.clone(), + generation: execution.lease.generation, + plan: execution.lease.plan.clone(), + resources: execution.lease.resources.clone(), + created_at: execution.lease.started_at, + } + } +} + +#[async_trait] +impl ExecutionPortConnector for RecordingExecutionManager { + async fn connect_port( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + port: NonZeroU16, + _timeout: Duration, + ) -> ExecutionManagerResult { + self.port_requests.lock().unwrap().push(( + execution_id.to_string(), + generation.get(), + port.get(), + )); + if self.fail_ports.load(Ordering::Relaxed) { + return Err(ExecutionManagerError::InvalidRequest( + "test runtime envd is unavailable".to_string(), + )); + } + let (stream, peer) = tokio::io::duplex(64 * 1024); + let status = + hyper::StatusCode::from_u16(self.runtime_envd_status.load(Ordering::Relaxed)).unwrap(); + let metrics_timestamp = self.clock.now().timestamp(); + let requests = self.runtime_envd_requests.clone(); + tokio::spawn(async move { + let service = + hyper::service::service_fn(move |request: hyper::Request| { + let requests = requests.clone(); + async move { + let method = request.method().to_string(); + let path = request.uri().path().to_string(); + let body = hyper::body::to_bytes(request.into_body()).await.unwrap(); + let body = if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap() + }; + let is_metrics = path == "/metrics"; + requests.lock().unwrap().push((method, path, body)); + let (response_status, response_body) = if is_metrics { + ( + hyper::StatusCode::OK, + hyper::Body::from( + serde_json::to_vec(&serde_json::json!({ + "ts": metrics_timestamp, + "cpu_count": 2, + "cpu_used_pct": 12.5, + "mem_used": 134_217_728_u64, + "mem_total": 536_870_912_u64, + "disk_used": 268_435_456_u64, + "disk_total": 1_073_741_824_u64, + })) + .unwrap(), + ), + ) + } else { + (status, hyper::Body::empty()) + }; + Ok::<_, Infallible>( + hyper::Response::builder() + .status(response_status) + .body(response_body) + .unwrap(), + ) + } + }); + hyper::server::conn::Http::new() + .http1_only(true) + .serve_connection(peer, service) + .await + .unwrap(); + }); + Ok(Box::pin(stream)) + } +} + +#[async_trait] +impl ExecutionManager for RecordingExecutionManager { + async fn create( + &self, + request: CreateExecutionRequest, + operation_id: &OperationId, + ) -> ExecutionManagerResult { + if self.fail_create.load(Ordering::Relaxed) { + return Err(ExecutionManagerError::Unavailable("test failure".into())); + } + if let Some(execution_id) = self + .operations + .lock() + .unwrap() + .get(operation_id.as_str()) + .cloned() + { + let executions = self.executions.lock().unwrap(); + let execution = executions.get(&execution_id).ok_or_else(|| { + ExecutionManagerError::Internal("missing test execution".to_string()) + })?; + return Ok(Self::reservation(execution)); + } + self.requests.lock().unwrap().push(request.clone()); + let plan = resolve_execution(&request.config) + .map_err(|error| ExecutionManagerError::InvalidRequest(error.to_string()))?; + let execution_id = ExecutionId::new(format!("execution-{}", operation_id.as_str()))?; + let lease = ExecutionLease { + execution_id: execution_id.clone(), + generation: ExecutionGeneration::INITIAL, + plan, + resources: request.config.resources, + started_at: self.clock.now(), + }; + self.operations + .lock() + .unwrap() + .insert(operation_id.as_str().to_string(), execution_id.to_string()); + self.executions.lock().unwrap().insert( + execution_id.to_string(), + TestExecution { + lease: lease.clone(), + state: ExecutionState::Created, + rootfs_snapshot_id: request.rootfs_snapshot_id, + }, + ); + Ok(ExecutionReservation { + execution_id, + generation: lease.generation, + plan: lease.plan, + resources: lease.resources, + created_at: lease.started_at, + }) + } + + async fn start( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let mut executions = self.executions.lock().unwrap(); + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale test start".to_string(), + }); + } + match execution.state { + ExecutionState::Created => execution.state = ExecutionState::Running, + ExecutionState::Running => {} + state => { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: format!("cannot start test execution in state {state:?}"), + }); + } + } + Ok(execution.lease.clone()) + } + + async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult { + let executions = self.executions.lock().unwrap(); + let execution = executions + .get(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + Ok(ExecutionStatus { + execution_id: execution_id.clone(), + generation: execution.lease.generation, + state: execution.state, + plan: execution.lease.plan.clone(), + }) + } + + async fn read_logs( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult> { + let executions = self.executions.lock().unwrap(); + let execution = executions + .get(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale test log read".to_string(), + }); + } + Ok(test_log_entries(self.clock.now())) + } + + async fn create_filesystem_snapshot( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + let executions = self.executions.lock().unwrap(); + let execution = executions + .get(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation + || !matches!( + execution.state, + ExecutionState::Running | ExecutionState::Paused + ) + { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale test snapshot".to_string(), + }); + } + let result = ExecutionSnapshot { + snapshot_id: snapshot_id.clone(), + size_bytes: 4_096, + state: execution.state, + lease: execution.lease.clone(), + }; + drop(executions); + self.snapshots + .lock() + .unwrap() + .insert(snapshot_id.to_string(), result.size_bytes); + Ok(result) + } + + async fn filesystem_snapshot_size( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult> { + Ok(self + .snapshots + .lock() + .unwrap() + .get(snapshot_id.as_str()) + .copied()) + } + + async fn delete_filesystem_snapshot( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + if self.executions.lock().unwrap().values().any(|execution| { + !matches!( + execution.state, + ExecutionState::Stopped | ExecutionState::Failed + ) && execution.rootfs_snapshot_id.as_ref() == Some(snapshot_id) + }) { + return Err(ExecutionManagerError::Conflict { + execution_id: ExecutionId::new(format!("snapshot-{snapshot_id}"))?, + message: "test snapshot is in use".to_string(), + }); + } + Ok(self + .snapshots + .lock() + .unwrap() + .remove(snapshot_id.as_str()) + .is_some()) + } + + async fn pause( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + _keep_memory: bool, + ) -> ExecutionManagerResult { + let mut executions = self.executions.lock().unwrap(); + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation || execution.state != ExecutionState::Running { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale test pause".to_string(), + }); + } + execution.state = ExecutionState::Paused; + let next_generation = generation.get().checked_add(1).ok_or_else(|| { + ExecutionManagerError::Internal("test execution generation is exhausted".into()) + })?; + execution.lease.generation = ExecutionGeneration::new(next_generation)?; + Ok(execution.lease.clone()) + } + + async fn resume( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let mut executions = self.executions.lock().unwrap(); + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation || execution.state != ExecutionState::Paused { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale test resume".to_string(), + }); + } + execution.state = ExecutionState::Running; + let next_generation = generation.get().checked_add(1).ok_or_else(|| { + ExecutionManagerError::Internal("test execution generation is exhausted".into()) + })?; + execution.lease.generation = ExecutionGeneration::new(next_generation)?; + Ok(execution.lease.clone()) + } + + async fn kill( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let mut executions = self.executions.lock().unwrap(); + let execution = executions + .get_mut(execution_id.as_str()) + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if execution.lease.generation != generation { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "stale test kill".to_string(), + }); + } + if execution.state == ExecutionState::Stopped { + Ok(KillOutcome::AlreadyStopped) + } else { + execution.state = ExecutionState::Stopped; + Ok(KillOutcome::Killed) + } + } + + async fn reconcile( + &self, + operation_id: &OperationId, + ) -> ExecutionManagerResult { + let Some(execution_id) = self + .operations + .lock() + .unwrap() + .get(operation_id.as_str()) + .cloned() + else { + return Ok(ReconcileOutcome::Absent); + }; + let executions = self.executions.lock().unwrap(); + let execution = executions + .get(&execution_id) + .ok_or_else(|| ExecutionManagerError::Internal("missing test execution".to_string()))?; + Ok(match execution.state { + ExecutionState::Created => ReconcileOutcome::Created(Self::reservation(execution)), + ExecutionState::Creating => ReconcileOutcome::Creating, + ExecutionState::Running | ExecutionState::Paused => { + ReconcileOutcome::Ready(execution.lease.clone()) + } + ExecutionState::Stopped | ExecutionState::Failed => ReconcileOutcome::Failed, + }) + } +} + +fn test_log_entries(started_at: DateTime) -> Vec { + [ + ("stdout", "starting\n", 0_i64), + ("stderr", "failed once\n", 1_i64), + ("stdout", "ready\n", 2_i64), + ] + .into_iter() + .map(|(stream, message, offset)| a3s_box_core::log::LogEntry { + log: message.to_string(), + stream: stream.to_string(), + time: (started_at + chrono::Duration::seconds(offset)).to_rfc3339(), + }) + .collect() +} + +pub(crate) fn assert_sandbox_request(request: &CreateExecutionRequest) { + assert_eq!(request.config.isolation, ExecutionIsolation::Sandbox); + assert_eq!(request.config.network, NetworkMode::None); + assert_eq!(request.config.resources.timeout, 321); + assert_eq!( + request.config.extra_env, + vec![ + ("ALPHA".to_string(), "one".to_string()), + ("BETA".to_string(), "two".to_string()), + ] + ); +} diff --git a/src/compat/src/control/tests.rs b/src/compat/src/control/tests.rs new file mode 100644 index 00000000..781e9447 --- /dev/null +++ b/src/compat/src/control/tests.rs @@ -0,0 +1,485 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::num::NonZeroU32; + +use a3s_box_core::{ + resolve_execution, BoxConfig, ExecutionGeneration, ExecutionId, ExecutionLease, + ExecutionManagerError, ExecutionManagerResult, ExecutionState, ExecutionStatus, KillOutcome, + OperationId, ReconcileOutcome, +}; +use async_trait::async_trait; +use chrono::{DateTime, Duration, TimeZone, Utc}; + +use super::*; + +fn instant(second: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 14, 12, 0, second) + .single() + .unwrap() +} + +fn stored_token(marker: u8) -> StoredToken { + StoredToken::new(1, vec![marker, 1], vec![marker, 2]).unwrap() +} + +fn creating_record(id: &str) -> SandboxRecord { + creating_record_with_timeout_action(id, OnTimeoutAction::Kill) +} + +fn creating_record_with_timeout_action(id: &str, on_timeout: OnTimeoutAction) -> SandboxRecord { + let config = BoxConfig { + isolation: a3s_box_core::ExecutionIsolation::Sandbox, + ..BoxConfig::default() + }; + let plan = resolve_execution(&config).unwrap(); + SandboxRecord::creating(NewSandboxRecord { + sandbox_id: SandboxId::new(id).unwrap(), + operation_id: OperationId::new(format!("operation-{id}")).unwrap(), + owner_id: "fixture-client".to_string(), + template_id: "code-interpreter-v1".to_string(), + plan, + resources: config.resources, + lifecycle: LifecyclePolicy { + on_timeout, + auto_resume: false, + keep_memory_on_pause: false, + }, + created_at: instant(0), + expires_at: instant(0) + Duration::seconds(300), + metadata: BTreeMap::from([("team".to_string(), "fixture".to_string())]), + envd_version: "0.1.3".to_string(), + envd_mode: EnvdMode::Broker, + secure: true, + allow_internet_access: Some(false), + credentials: SandboxCredentials { + envd: stored_token(10), + traffic: stored_token(20), + }, + routing: crate::routing::SandboxRoutePolicy::default(), + }) + .unwrap() +} + +#[tokio::test] +async fn memory_repository_claims_only_actionable_expired_records() { + let repository = MemorySandboxRepository::default(); + + let mut kill = creating_record("kill"); + kill.mark_running(execution_lease(&kill, 1)).unwrap(); + kill.replace_expiry(instant(10)).unwrap(); + repository.insert(kill).await.unwrap(); + + let mut pause = creating_record_with_timeout_action("pause", OnTimeoutAction::Pause); + pause.mark_running(execution_lease(&pause, 1)).unwrap(); + pause.replace_expiry(instant(10)).unwrap(); + repository.insert(pause).await.unwrap(); + + let mut already_paused = + creating_record_with_timeout_action("already-paused", OnTimeoutAction::Pause); + already_paused + .mark_running(execution_lease(&already_paused, 1)) + .unwrap(); + already_paused.begin_pause().unwrap(); + already_paused + .mark_paused(execution_lease(&already_paused, 2)) + .unwrap(); + already_paused.replace_expiry(instant(10)).unwrap(); + repository.insert(already_paused).await.unwrap(); + + let mut renewed = creating_record("renewed"); + renewed.mark_running(execution_lease(&renewed, 1)).unwrap(); + renewed.replace_expiry(instant(30)).unwrap(); + repository.insert(renewed).await.unwrap(); + + let claimed = repository + .claim_expired(instant(20), NonZeroU32::new(10).unwrap()) + .await + .unwrap(); + let states = claimed + .iter() + .map(|record| (record.sandbox_id().as_str(), record.state())) + .collect::>(); + + assert_eq!(states.len(), 2); + assert_eq!(states["kill"], LifecycleState::Killing); + assert_eq!(states["pause"], LifecycleState::Pausing); + assert_eq!( + repository + .get(&SandboxId::new("already-paused").unwrap()) + .await + .unwrap() + .unwrap() + .state(), + LifecycleState::Paused + ); + assert_eq!( + repository + .get(&SandboxId::new("renewed").unwrap()) + .await + .unwrap() + .unwrap() + .state(), + LifecycleState::Running + ); +} + +#[tokio::test] +async fn memory_repository_pages_reconcilable_records() { + let repository = MemorySandboxRepository::default(); + for id in ["sandbox-1", "sandbox-2", "sandbox-3"] { + repository.insert(creating_record(id)).await.unwrap(); + } + let mut terminal = creating_record("terminal"); + terminal + .mark_failed(LifecycleFailure::RuntimeFailed) + .unwrap(); + repository.insert(terminal).await.unwrap(); + + let first = repository + .list_reconcilable(None, NonZeroU32::new(2).unwrap()) + .await + .unwrap(); + assert_eq!(first.records.len(), 2); + assert!(first.next.is_some()); + + let second = repository + .list_reconcilable(first.next.as_ref(), NonZeroU32::new(2).unwrap()) + .await + .unwrap(); + assert_eq!(second.records.len(), 1); + assert_eq!(second.records[0].sandbox_id().as_str(), "sandbox-3"); + assert!(second.next.is_none()); +} + +fn execution_lease(record: &SandboxRecord, generation: u64) -> ExecutionLease { + ExecutionLease { + execution_id: ExecutionId::new("execution-1").unwrap(), + generation: ExecutionGeneration::new(generation).unwrap(), + plan: record.plan().clone(), + resources: record.resources().clone(), + started_at: instant(1), + } +} + +#[test] +fn lifecycle_transitions_are_generation_fenced() { + let mut record = creating_record("sandbox-1"); + assert_eq!(record.generation(), SandboxGeneration::INITIAL); + assert_eq!(record.public_state(), None); + + assert_eq!( + record.mark_running(execution_lease(&record, 1)).unwrap(), + SandboxGeneration::new(2).unwrap() + ); + assert_eq!(record.public_state(), Some(PublicSandboxState::Running)); + let started_at = record.started_at(); + + record + .replace_expiry(instant(0) + Duration::seconds(600)) + .unwrap(); + record.begin_pause().unwrap(); + record.mark_paused(execution_lease(&record, 2)).unwrap(); + assert_eq!(record.public_state(), Some(PublicSandboxState::Paused)); + record.begin_resume().unwrap(); + record.mark_running(execution_lease(&record, 3)).unwrap(); + + assert_eq!(record.generation(), SandboxGeneration::new(7).unwrap()); + assert_eq!(record.started_at(), started_at); + assert_eq!(record.execution_generation().unwrap().get(), 3); +} + +#[test] +fn pause_and_resume_reject_stale_execution_generations() { + let mut record = creating_record("sandbox-1"); + record.mark_running(execution_lease(&record, 1)).unwrap(); + record.begin_pause().unwrap(); + + assert_eq!( + record.mark_paused(execution_lease(&record, 1)).unwrap_err(), + LifecycleError::ExecutionGenerationMismatch + ); + assert_eq!(record.state(), LifecycleState::Pausing); + + record.mark_paused(execution_lease(&record, 2)).unwrap(); + record.begin_resume().unwrap(); + assert_eq!( + record + .mark_running(execution_lease(&record, 2)) + .unwrap_err(), + LifecycleError::ExecutionGenerationMismatch + ); + assert_eq!(record.state(), LifecycleState::Resuming); +} + +#[test] +fn failed_pause_and_resume_attempts_restore_the_stable_state() { + let mut record = creating_record("sandbox-1"); + record.mark_running(execution_lease(&record, 1)).unwrap(); + let running_generation = record.generation(); + + record.begin_pause().unwrap(); + record.abort_pause().unwrap(); + assert_eq!(record.state(), LifecycleState::Running); + assert!(record.generation() > running_generation); + assert_eq!(record.execution_generation().unwrap().get(), 1); + + record.begin_pause().unwrap(); + record.mark_paused(execution_lease(&record, 2)).unwrap(); + record.begin_resume().unwrap(); + record.abort_resume().unwrap(); + assert_eq!(record.state(), LifecycleState::Paused); + assert_eq!(record.execution_generation().unwrap().get(), 2); +} + +#[test] +fn invalid_transition_does_not_mutate_record() { + let mut record = creating_record("sandbox-1"); + let generation = record.generation(); + + let error = record.begin_resume().unwrap_err(); + + assert_eq!(error, LifecycleError::MissingExecution); + assert_eq!(record.state(), LifecycleState::Creating); + assert_eq!(record.generation(), generation); +} + +#[test] +fn persisted_control_identifiers_preserve_invariants() { + assert!(serde_json::from_str::("\"\"").is_err()); + for invalid in [ + "Uppercase", + "-leading", + "trailing-", + "contains.dot", + "contains/slash", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] { + assert!(SandboxId::new(invalid).is_err(), "accepted {invalid}"); + } + assert!(serde_json::from_str::("0").is_err()); + assert!(serde_json::from_str::( + r#"{"key_version":0,"ciphertext":[],"digest":[]}"# + ) + .is_err()); +} + +#[test] +fn records_written_before_route_policies_and_envd_modes_use_broker_defaults() { + let record = creating_record("legacy-route-record"); + let mut value = serde_json::to_value(record).unwrap(); + value.as_object_mut().unwrap().remove("routing"); + value.as_object_mut().unwrap().remove("envd_mode"); + + let restored: SandboxRecord = serde_json::from_value(value).unwrap(); + assert_eq!(restored.envd_mode(), EnvdMode::Broker); + assert_eq!( + restored.routing().token_scope(crate::routing::ENVD_PORT), + Some(TokenScope::Envd) + ); + assert_eq!(restored.routing().ports().count(), 1); +} + +#[test] +fn runtime_plan_mismatch_does_not_publish_execution() { + let mut record = creating_record("sandbox-1"); + let config = BoxConfig::default(); + let mut lease = execution_lease(&record, 1); + lease.plan = resolve_execution(&config).unwrap(); + + assert_eq!( + record.mark_running(lease).unwrap_err(), + LifecycleError::ExecutionPlanMismatch + ); + assert_eq!(record.state(), LifecycleState::Creating); + assert_eq!(record.execution_id(), None); + assert_eq!(record.generation(), SandboxGeneration::INITIAL); +} + +#[test] +fn killed_record_is_terminal() { + let mut record = creating_record("sandbox-1"); + record.mark_running(execution_lease(&record, 1)).unwrap(); + record.begin_kill().unwrap(); + record.mark_killed().unwrap(); + + assert!(record.is_terminal()); + assert!(matches!( + record.begin_kill(), + Err(LifecycleError::InvalidTransition { .. }) + )); +} + +#[tokio::test] +async fn repository_compare_and_swap_rejects_stale_generation() { + let repository = MemorySandboxRepository::default(); + let mut original = creating_record("sandbox-1"); + repository.insert(original.clone()).await.unwrap(); + let stale_generation = original.generation(); + + original + .mark_running(execution_lease(&original, 1)) + .unwrap(); + assert_eq!( + repository + .compare_and_swap(original.sandbox_id(), stale_generation, original.clone()) + .await + .unwrap(), + CompareAndSwapResult::Updated + ); + + let mut stale = original.clone(); + stale.replace_expiry(instant(30)).unwrap(); + let stale_id = stale.sandbox_id().clone(); + assert_eq!( + repository + .compare_and_swap(&stale_id, stale_generation, stale) + .await + .unwrap(), + CompareAndSwapResult::Conflict { + actual_generation: original.generation(), + } + ); +} + +#[tokio::test] +async fn repository_list_port_preserves_cursor_and_filters() { + let repository = MemorySandboxRepository::default(); + for id in ["sandbox-1", "sandbox-2"] { + let mut record = creating_record(id); + record.mark_running(execution_lease(&record, 1)).unwrap(); + repository.insert(record).await.unwrap(); + } + let first = repository + .list(&SandboxListFilter { + owner_id: "fixture-client".to_string(), + metadata: BTreeMap::from([("team".to_string(), "fixture".to_string())]), + states: BTreeSet::from([PublicSandboxState::Running]), + limit: NonZeroU32::new(1).unwrap(), + after: None, + }) + .await + .unwrap(); + assert_eq!(first.records.len(), 1); + assert_eq!(first.records[0].sandbox_id().as_str(), "sandbox-1"); + + let second = repository + .list(&SandboxListFilter { + owner_id: "fixture-client".to_string(), + metadata: BTreeMap::new(), + states: BTreeSet::new(), + limit: NonZeroU32::new(1).unwrap(), + after: first.next, + }) + .await + .unwrap(); + assert_eq!(second.records.len(), 1); + assert_eq!(second.records[0].sandbox_id().as_str(), "sandbox-2"); + assert!(second.next.is_none()); +} + +struct FixedClock(DateTime); + +impl Clock for FixedClock { + fn now(&self) -> DateTime { + self.0 + } +} + +struct FixedTokenIssuer; + +#[async_trait] +impl TokenIssuer for FixedTokenIssuer { + async fn issue(&self, scope: TokenScope) -> TokenIssuerResult { + let marker = match scope { + TokenScope::Envd => 1, + TokenScope::Traffic => 2, + TokenScope::Volume => 3, + }; + Ok(IssuedToken { + secret: SecretToken::new(format!("secret-{marker}")).unwrap(), + stored: stored_token(marker), + }) + } +} + +#[tokio::test] +async fn deterministic_ports_do_not_expose_token_material_in_debug() { + let clock = FixedClock(instant(9)); + let issued = FixedTokenIssuer.issue(TokenScope::Envd).await.unwrap(); + + assert_eq!(clock.now(), instant(9)); + assert_eq!(issued.secret.expose_secret(), "secret-1"); + let debug = format!("{issued:?}"); + assert!(!debug.contains("secret-1")); + assert!(debug.contains("REDACTED")); +} + +struct ObjectSafeExecutionManager; + +#[async_trait] +impl a3s_box_core::ExecutionManager for ObjectSafeExecutionManager { + async fn create_and_start( + &self, + _request: a3s_box_core::CreateExecutionRequest, + _operation_id: &OperationId, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::Unavailable("fixture".to_string())) + } + + async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult { + Ok(ExecutionStatus { + execution_id: execution_id.clone(), + generation: ExecutionGeneration::INITIAL, + state: ExecutionState::Running, + plan: creating_record("manager-check").plan().clone(), + }) + } + + async fn pause( + &self, + execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _keep_memory: bool, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::NotFound(execution_id.clone())) + } + + async fn resume( + &self, + execution_id: &ExecutionId, + _generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::NotFound(execution_id.clone())) + } + + async fn kill( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + Ok(KillOutcome::AlreadyStopped) + } + + async fn reconcile( + &self, + _operation_id: &OperationId, + ) -> ExecutionManagerResult { + Ok(ReconcileOutcome::Absent) + } +} + +#[tokio::test] +async fn execution_manager_port_is_object_safe() { + let manager: &dyn a3s_box_core::ExecutionManager = &ObjectSafeExecutionManager; + let execution_id = ExecutionId::new("execution-1").unwrap(); + + assert_eq!( + manager.inspect(&execution_id).await.unwrap().state, + ExecutionState::Running + ); + assert!(matches!( + manager + .pause(&execution_id, ExecutionGeneration::INITIAL, true) + .await, + Err(ExecutionManagerError::NotFound(_)) + )); +} diff --git a/src/compat/src/control/token_keyring.rs b/src/compat/src/control/token_keyring.rs new file mode 100644 index 00000000..2d30ffac --- /dev/null +++ b/src/compat/src/control/token_keyring.rs @@ -0,0 +1,419 @@ +use std::collections::BTreeMap; +use std::fmt; + +use async_trait::async_trait; +use ring::aead::{self, Aad, LessSafeKey, Nonce, UnboundKey}; +use ring::hmac; +use ring::rand::{SecureRandom, SystemRandom}; + +use super::{ + IssuedToken, SecretToken, StoredToken, TokenIssuer, TokenIssuerError, TokenIssuerResult, + TokenResolver, TokenScope, TokenVerifier, +}; + +const KEY_BYTES: usize = 32; +const NONCE_BYTES: usize = 12; +const TOKEN_BYTES: usize = 32; +const TOKEN_AAD_DOMAIN: &[u8] = b"a3s-box-e2b-token-v1"; +const TOKEN_DIGEST_DOMAIN: &[u8] = b"a3s-box-e2b-token-digest-v1"; + +#[derive(Clone)] +pub struct TokenKeyMaterial { + version: u32, + encryption_key: [u8; KEY_BYTES], + digest_key: [u8; KEY_BYTES], +} + +impl TokenKeyMaterial { + pub fn new(version: u32, encryption_key: &[u8], digest_key: &[u8]) -> TokenIssuerResult { + if version == 0 { + return Err(TokenIssuerError::InvalidMaterial); + } + let encryption_key = encryption_key + .try_into() + .map_err(|_| TokenIssuerError::InvalidMaterial)?; + let digest_key = digest_key + .try_into() + .map_err(|_| TokenIssuerError::InvalidMaterial)?; + Ok(Self { + version, + encryption_key, + digest_key, + }) + } + + pub const fn version(&self) -> u32 { + self.version + } +} + +impl fmt::Debug for TokenKeyMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TokenKeyMaterial") + .field("version", &self.version) + .field("encryption_key", &"[REDACTED]") + .field("digest_key", &"[REDACTED]") + .finish() + } +} + +pub struct RotatingTokenProvider { + active_version: u32, + keys: BTreeMap, + random: SystemRandom, +} + +impl RotatingTokenProvider { + pub fn new( + active_version: u32, + keys: impl IntoIterator, + ) -> TokenIssuerResult { + let mut by_version = BTreeMap::new(); + for key in keys { + let version = key.version(); + if by_version.insert(version, key).is_some() { + return Err(TokenIssuerError::InvalidMaterial); + } + } + if active_version == 0 || !by_version.contains_key(&active_version) { + return Err(TokenIssuerError::UnknownKeyVersion(active_version)); + } + Ok(Self { + active_version, + keys: by_version, + random: SystemRandom::new(), + }) + } + + pub const fn active_version(&self) -> u32 { + self.active_version + } + + fn key(&self, version: u32) -> TokenIssuerResult<&TokenKeyMaterial> { + self.keys + .get(&version) + .ok_or(TokenIssuerError::UnknownKeyVersion(version)) + } + + fn store(&self, scope: TokenScope, secret: &SecretToken) -> TokenIssuerResult { + let key = self.key(self.active_version)?; + let mut nonce_bytes = [0_u8; NONCE_BYTES]; + self.random + .fill(&mut nonce_bytes) + .map_err(|_| TokenIssuerError::Unavailable("secure random generation failed".into()))?; + + let mut sealed = secret.expose_secret().as_bytes().to_vec(); + encryption_key(key)? + .seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce_bytes), + Aad::from(aad(key.version(), scope)), + &mut sealed, + ) + .map_err(|_| TokenIssuerError::Unavailable("token encryption failed".into()))?; + + let mut ciphertext = Vec::with_capacity(NONCE_BYTES + sealed.len()); + ciphertext.extend_from_slice(&nonce_bytes); + ciphertext.extend_from_slice(&sealed); + let digest = token_digest(key, scope, secret.expose_secret()); + StoredToken::new(key.version(), ciphertext, digest) + .map_err(|_| TokenIssuerError::InvalidMaterial) + } + + fn open(&self, scope: TokenScope, stored: &StoredToken) -> TokenIssuerResult { + let key = self.key(stored.key_version())?; + let (nonce, sealed) = stored + .ciphertext() + .split_at_checked(NONCE_BYTES) + .ok_or(TokenIssuerError::InvalidMaterial)?; + let nonce: [u8; NONCE_BYTES] = nonce + .try_into() + .map_err(|_| TokenIssuerError::InvalidMaterial)?; + let mut sealed = sealed.to_vec(); + let plaintext = encryption_key(key)? + .open_in_place( + Nonce::assume_unique_for_key(nonce), + Aad::from(aad(key.version(), scope)), + &mut sealed, + ) + .map_err(|_| TokenIssuerError::InvalidMaterial)?; + let secret = + std::str::from_utf8(plaintext).map_err(|_| TokenIssuerError::InvalidMaterial)?; + let secret = SecretToken::new(secret)?; + if !verify_token_digest(key, scope, &secret, stored.digest()) { + return Err(TokenIssuerError::InvalidMaterial); + } + Ok(secret) + } +} + +impl fmt::Debug for RotatingTokenProvider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RotatingTokenProvider") + .field("active_version", &self.active_version) + .field("configured_versions", &self.keys.keys()) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl TokenIssuer for RotatingTokenProvider { + async fn issue(&self, scope: TokenScope) -> TokenIssuerResult { + let mut random = [0_u8; TOKEN_BYTES]; + self.random + .fill(&mut random) + .map_err(|_| TokenIssuerError::Unavailable("secure random generation failed".into()))?; + let secret = SecretToken::new(hex::encode(random))?; + let stored = self.store(scope, &secret)?; + Ok(IssuedToken { secret, stored }) + } +} + +#[async_trait] +impl TokenResolver for RotatingTokenProvider { + async fn resolve( + &self, + scope: TokenScope, + stored: &StoredToken, + ) -> TokenIssuerResult { + self.open(scope, stored) + } +} + +#[async_trait] +impl TokenVerifier for RotatingTokenProvider { + async fn verify( + &self, + scope: TokenScope, + presented: &SecretToken, + stored: &StoredToken, + ) -> TokenIssuerResult { + let key = self.key(stored.key_version())?; + Ok(verify_token_digest(key, scope, presented, stored.digest())) + } +} + +fn encryption_key(key: &TokenKeyMaterial) -> TokenIssuerResult { + UnboundKey::new(&aead::AES_256_GCM, &key.encryption_key) + .map(LessSafeKey::new) + .map_err(|_| TokenIssuerError::InvalidMaterial) +} + +fn aad(version: u32, scope: TokenScope) -> Vec { + let mut value = Vec::with_capacity(TOKEN_AAD_DOMAIN.len() + 5); + value.extend_from_slice(TOKEN_AAD_DOMAIN); + value.extend_from_slice(&version.to_be_bytes()); + value.push(scope_marker(scope)); + value +} + +fn token_digest(key: &TokenKeyMaterial, scope: TokenScope, secret: &str) -> Vec { + let digest_key = hmac::Key::new(hmac::HMAC_SHA256, &key.digest_key); + hmac::sign(&digest_key, &digest_input(key.version(), scope, secret)) + .as_ref() + .to_vec() +} + +fn verify_token_digest( + key: &TokenKeyMaterial, + scope: TokenScope, + secret: &SecretToken, + expected: &[u8], +) -> bool { + let digest_key = hmac::Key::new(hmac::HMAC_SHA256, &key.digest_key); + hmac::verify( + &digest_key, + &digest_input(key.version(), scope, secret.expose_secret()), + expected, + ) + .is_ok() +} + +fn digest_input(version: u32, scope: TokenScope, secret: &str) -> Vec { + let mut value = Vec::with_capacity(TOKEN_DIGEST_DOMAIN.len() + secret.len() + 5); + value.extend_from_slice(TOKEN_DIGEST_DOMAIN); + value.extend_from_slice(&version.to_be_bytes()); + value.push(scope_marker(scope)); + value.extend_from_slice(secret.as_bytes()); + value +} + +const fn scope_marker(scope: TokenScope) -> u8 { + match scope { + TokenScope::Envd => 1, + TokenScope::Traffic => 2, + TokenScope::Volume => 3, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(version: u32, marker: u8) -> TokenKeyMaterial { + TokenKeyMaterial::new(version, &[marker; KEY_BYTES], &[marker + 1; KEY_BYTES]).unwrap() + } + + #[tokio::test] + async fn issued_tokens_are_encrypted_hashed_and_scope_bound() { + let provider = RotatingTokenProvider::new(1, [key(1, 7)]).unwrap(); + let issued = provider.issue(TokenScope::Envd).await.unwrap(); + + assert_eq!(issued.stored.key_version(), 1); + assert_ne!( + issued.stored.ciphertext(), + issued.secret.expose_secret().as_bytes() + ); + assert!(!issued + .stored + .ciphertext() + .windows(issued.secret.expose_secret().len()) + .any(|window| window == issued.secret.expose_secret().as_bytes())); + assert_eq!(issued.stored.digest().len(), 32); + assert_eq!( + provider + .resolve(TokenScope::Envd, &issued.stored) + .await + .unwrap() + .expose_secret(), + issued.secret.expose_secret() + ); + assert!(provider + .verify(TokenScope::Envd, &issued.secret, &issued.stored) + .await + .unwrap()); + assert!(!provider + .verify(TokenScope::Traffic, &issued.secret, &issued.stored) + .await + .unwrap()); + assert!(matches!( + provider.resolve(TokenScope::Traffic, &issued.stored).await, + Err(TokenIssuerError::InvalidMaterial) + )); + } + + #[tokio::test] + async fn key_rotation_issues_with_active_key_and_resolves_retained_versions() { + let first = RotatingTokenProvider::new(1, [key(1, 11)]).unwrap(); + let old = first.issue(TokenScope::Traffic).await.unwrap(); + + let rotated = RotatingTokenProvider::new(2, [key(1, 11), key(2, 21)]).unwrap(); + let current = rotated.issue(TokenScope::Traffic).await.unwrap(); + + assert_eq!(current.stored.key_version(), 2); + assert_eq!( + rotated + .resolve(TokenScope::Traffic, &old.stored) + .await + .unwrap() + .expose_secret(), + old.secret.expose_secret() + ); + assert_eq!( + rotated + .resolve(TokenScope::Traffic, ¤t.stored) + .await + .unwrap() + .expose_secret(), + current.secret.expose_secret() + ); + + let retired = RotatingTokenProvider::new(2, [key(2, 21)]).unwrap(); + assert!(matches!( + retired.resolve(TokenScope::Traffic, &old.stored).await, + Err(TokenIssuerError::UnknownKeyVersion(1)) + )); + } + + #[tokio::test] + async fn tampered_ciphertext_and_digest_are_rejected() { + let provider = RotatingTokenProvider::new(3, [key(3, 31)]).unwrap(); + let issued = provider.issue(TokenScope::Envd).await.unwrap(); + + let mut ciphertext = issued.stored.ciphertext().to_vec(); + ciphertext[NONCE_BYTES] ^= 1; + let tampered_ciphertext = + StoredToken::new(3, ciphertext, issued.stored.digest().to_vec()).unwrap(); + assert!(matches!( + provider + .resolve(TokenScope::Envd, &tampered_ciphertext) + .await, + Err(TokenIssuerError::InvalidMaterial) + )); + + let mut digest = issued.stored.digest().to_vec(); + digest[0] ^= 1; + let tampered_digest = + StoredToken::new(3, issued.stored.ciphertext().to_vec(), digest).unwrap(); + assert!(!provider + .verify(TokenScope::Envd, &issued.secret, &tampered_digest) + .await + .unwrap()); + assert!(matches!( + provider.resolve(TokenScope::Envd, &tampered_digest).await, + Err(TokenIssuerError::InvalidMaterial) + )); + } + + #[tokio::test] + async fn stored_tokens_round_trip_and_randomize_repeated_plaintext() { + let provider = RotatingTokenProvider::new(5, [key(5, 51)]).unwrap(); + let secret = SecretToken::new("fixed-token-material").unwrap(); + let first = provider.store(TokenScope::Traffic, &secret).unwrap(); + let second = provider.store(TokenScope::Traffic, &secret).unwrap(); + + assert_ne!(first.ciphertext(), second.ciphertext()); + assert_eq!(first.digest(), second.digest()); + + let encoded = serde_json::to_vec(&first).unwrap(); + assert!(!encoded + .windows(secret.expose_secret().len()) + .any(|window| window == secret.expose_secret().as_bytes())); + let decoded: StoredToken = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded, first); + assert_eq!( + provider + .resolve(TokenScope::Traffic, &decoded) + .await + .unwrap() + .expose_secret(), + secret.expose_secret() + ); + } + + #[test] + fn invalid_keyring_configuration_fails_closed() { + assert!(matches!( + TokenKeyMaterial::new(0, &[1; KEY_BYTES], &[2; KEY_BYTES]), + Err(TokenIssuerError::InvalidMaterial) + )); + assert!(matches!( + TokenKeyMaterial::new(1, &[1; KEY_BYTES - 1], &[2; KEY_BYTES]), + Err(TokenIssuerError::InvalidMaterial) + )); + assert!(matches!( + RotatingTokenProvider::new(1, std::iter::empty()), + Err(TokenIssuerError::UnknownKeyVersion(1)) + )); + assert!(matches!( + RotatingTokenProvider::new(2, [key(1, 1)]), + Err(TokenIssuerError::UnknownKeyVersion(2)) + )); + assert!(matches!( + RotatingTokenProvider::new(1, [key(1, 1), key(1, 2)]), + Err(TokenIssuerError::InvalidMaterial) + )); + } + + #[test] + fn keyring_debug_output_redacts_key_material() { + let material = key(4, 41); + let provider = RotatingTokenProvider::new(4, [material.clone()]).unwrap(); + + assert!(!format!("{material:?}").contains(&hex::encode([41_u8; KEY_BYTES]))); + let debug = format!("{provider:?}"); + assert!(debug.contains("configured_versions")); + assert!(!debug.contains(&hex::encode([41_u8; KEY_BYTES]))); + } +} diff --git a/src/compat/src/control/validation.rs b/src/compat/src/control/validation.rs new file mode 100644 index 00000000..82af0858 --- /dev/null +++ b/src/compat/src/control/validation.rs @@ -0,0 +1,68 @@ +use super::{LifecycleError, LifecycleState, SandboxRecord}; + +pub(crate) fn validate_persisted_record(record: &SandboxRecord) -> Result<(), LifecycleError> { + if record.owner_id().trim().is_empty() + || record.template_id().trim().is_empty() + || record.envd_version().trim().is_empty() + { + return Err(LifecycleError::InvalidPersistedState( + "owner ID, template ID, and envd version must be non-empty".to_string(), + )); + } + if record.expires_at() < record.created_at() { + return Err(LifecycleError::InvalidExpiry); + } + record.routing().validate().map_err(|error| { + LifecycleError::InvalidPersistedState(format!("invalid route policy: {error}")) + })?; + crate::volume::validate_mounts(record.volume_mounts()).map_err(|error| { + LifecycleError::InvalidPersistedState(format!("invalid volume mounts: {error}")) + })?; + if record.execution_id().is_some() != record.execution_generation().is_some() { + return Err(LifecycleError::InvalidPersistedState( + "execution ID and generation must be present together".to_string(), + )); + } + let requires_execution = matches!( + record.state(), + LifecycleState::Running + | LifecycleState::Pausing + | LifecycleState::Paused + | LifecycleState::Resuming + ); + if requires_execution && record.execution_id().is_none() { + return Err(LifecycleError::InvalidPersistedState(format!( + "state {:?} requires a runtime execution", + record.state() + ))); + } + if record.execution_id().is_some() && record.started_at().is_none() { + return Err(LifecycleError::InvalidPersistedState( + "a runtime execution requires a start timestamp".to_string(), + )); + } + if record + .started_at() + .is_some_and(|started_at| started_at < record.created_at()) + { + return Err(LifecycleError::InvalidPersistedState( + "sandbox start timestamp precedes creation".to_string(), + )); + } + if record.state() == LifecycleState::Failed && record.failure().is_none() { + return Err(LifecycleError::InvalidPersistedState( + "failed sandbox is missing its failure category".to_string(), + )); + } + if record.failure().is_some() + && !matches!( + record.state(), + LifecycleState::Failed | LifecycleState::Killing | LifecycleState::Killed + ) + { + return Err(LifecycleError::InvalidPersistedState( + "non-failed sandbox retains a failure category".to_string(), + )); + } + Ok(()) +} diff --git a/src/compat/src/digest.rs b/src/compat/src/digest.rs new file mode 100644 index 00000000..5db17603 --- /dev/null +++ b/src/compat/src/digest.rs @@ -0,0 +1,5 @@ +use sha2::{Digest, Sha256}; + +pub(crate) fn sha256(bytes: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) +} diff --git a/src/compat/src/envd/connect.rs b/src/compat/src/envd/connect.rs new file mode 100644 index 00000000..39ebf9d5 --- /dev/null +++ b/src/compat/src/envd/connect.rs @@ -0,0 +1,205 @@ +//! Minimal Connect JSON framing for the pinned E2B envd clients. + +use axum::body::Body; +use axum::http::header::CONTENT_TYPE; +use axum::http::{HeaderValue, Request, Response, StatusCode}; +use hyper::body::{Bytes, HttpBody}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::{json, Value}; + +const CONTENT_TYPE_UNARY_JSON: &str = "application/json"; +const CONTENT_TYPE_STREAM_JSON: &str = "application/connect+json"; +const END_STREAM_FLAG: u8 = 0x02; +const MAX_REQUEST_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone)] +pub(super) struct ConnectFailure { + code: &'static str, + message: String, + status: StatusCode, +} + +impl ConnectFailure { + pub(super) fn invalid_argument(message: impl Into) -> Self { + Self::new("invalid_argument", message, StatusCode::BAD_REQUEST) + } + + pub(super) fn not_found(message: impl Into) -> Self { + Self::new("not_found", message, StatusCode::NOT_FOUND) + } + + pub(super) fn failed_precondition(message: impl Into) -> Self { + Self::new("failed_precondition", message, StatusCode::BAD_REQUEST) + } + + pub(super) fn unimplemented(message: impl Into) -> Self { + Self::new("unimplemented", message, StatusCode::NOT_IMPLEMENTED) + } + + pub(super) fn resource_exhausted(message: impl Into) -> Self { + Self::new("resource_exhausted", message, StatusCode::TOO_MANY_REQUESTS) + } + + pub(super) fn unavailable(message: impl Into) -> Self { + Self::new("unavailable", message, StatusCode::SERVICE_UNAVAILABLE) + } + + pub(super) fn internal(message: impl Into) -> Self { + Self::new("internal", message, StatusCode::INTERNAL_SERVER_ERROR) + } + + fn new(code: &'static str, message: impl Into, status: StatusCode) -> Self { + Self { + code, + message: message.into(), + status, + } + } + + pub(super) fn unary_response(&self) -> Response { + response( + self.status, + CONTENT_TYPE_UNARY_JSON, + Body::from(json!({ "code": self.code, "message": self.message }).to_string()), + ) + } + + pub(super) fn end_stream_frame(&self) -> Vec { + encode_json_frame( + END_STREAM_FLAG, + &json!({ + "error": { + "code": self.code, + "message": self.message, + } + }), + ) + } +} + +pub(super) async fn decode_unary(request: Request) -> Result +where + T: DeserializeOwned, +{ + require_content_type(&request, CONTENT_TYPE_UNARY_JSON)?; + let bytes = read_body(request.into_body()).await?; + serde_json::from_slice(&bytes).map_err(|error| { + ConnectFailure::invalid_argument(format!("invalid Connect JSON request: {error}")) + }) +} + +pub(super) async fn decode_stream(request: Request) -> Result +where + T: DeserializeOwned, +{ + require_content_type(&request, CONTENT_TYPE_STREAM_JSON)?; + let bytes = read_body(request.into_body()).await?; + if bytes.len() < 5 { + return Err(ConnectFailure::invalid_argument( + "Connect stream request is missing its envelope", + )); + } + let flags = bytes[0]; + if flags != 0 { + return Err(ConnectFailure::invalid_argument(format!( + "unsupported Connect request envelope flags: {flags:#04x}" + ))); + } + let length = u32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]) as usize; + if length != bytes.len() - 5 { + return Err(ConnectFailure::invalid_argument( + "Connect request envelope length does not match its payload", + )); + } + serde_json::from_slice(&bytes[5..]).map_err(|error| { + ConnectFailure::invalid_argument(format!("invalid Connect JSON request: {error}")) + }) +} + +pub(super) fn unary_ok(value: &T) -> Response +where + T: Serialize, +{ + match serde_json::to_vec(value) { + Ok(body) => response(StatusCode::OK, CONTENT_TYPE_UNARY_JSON, Body::from(body)), + Err(error) => ConnectFailure::internal(format!( + "failed to serialize Connect JSON response: {error}" + )) + .unary_response(), + } +} + +pub(super) fn stream_response(body: Body) -> Response { + response(StatusCode::OK, CONTENT_TYPE_STREAM_JSON, body) +} + +pub(super) fn stream_error(error: &ConnectFailure) -> Response { + stream_response(Body::from(error.end_stream_frame())) +} + +pub(super) fn data_frame(value: &Value) -> Bytes { + Bytes::from(encode_json_frame(0, value)) +} + +pub(super) fn success_end_stream_frame() -> Bytes { + Bytes::from(encode_json_frame(END_STREAM_FLAG, &json!({}))) +} + +fn encode_json_frame(flags: u8, value: &Value) -> Vec { + // serde_json::Value serialization is infallible for values constructed by + // this module. Keep the fallback a valid Connect error envelope anyway. + let payload = serde_json::to_vec(value).unwrap_or_else(|_| { + br#"{"error":{"code":"internal","message":"response serialization failed"}}"#.to_vec() + }); + let length = u32::try_from(payload.len()).unwrap_or(u32::MAX); + let mut frame = Vec::with_capacity(payload.len().saturating_add(5)); + frame.push(flags); + frame.extend_from_slice(&length.to_be_bytes()); + frame.extend_from_slice(&payload); + frame +} + +async fn read_body(mut body: Body) -> Result, ConnectFailure> { + let mut bytes = Vec::new(); + while let Some(chunk) = body.data().await { + let chunk = chunk.map_err(|error| { + ConnectFailure::invalid_argument(format!("failed to read request body: {error}")) + })?; + if bytes.len().saturating_add(chunk.len()) > MAX_REQUEST_BYTES { + return Err(ConnectFailure::invalid_argument(format!( + "Connect request exceeds the {MAX_REQUEST_BYTES}-byte limit" + ))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +fn require_content_type( + request: &Request, + expected: &'static str, +) -> Result<(), ConnectFailure> { + let actual = request + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .map(str::trim); + if actual == Some(expected) { + Ok(()) + } else { + Err(ConnectFailure::invalid_argument(format!( + "expected Content-Type {expected}" + ))) + } +} + +fn response(status: StatusCode, content_type: &'static str, body: Body) -> Response { + let mut response = Response::new(body); + *response.status_mut() = status; + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); + response +} diff --git a/src/compat/src/envd/mod.rs b/src/compat/src/envd/mod.rs new file mode 100644 index 00000000..b25124fb --- /dev/null +++ b/src/compat/src/envd/mod.rs @@ -0,0 +1,145 @@ +mod connect; +mod process; + +use std::sync::Arc; + +use a3s_box_core::{ + ExecutionGeneration, ExecutionId, ExecutionManager, ExecutionManagerError, + ExecutionSessionManager, ExecutionState, +}; +use axum::body::Body; +use axum::http::header::{ALLOW, CONTENT_TYPE}; +use axum::http::{HeaderValue, Method, Request, Response, StatusCode}; +use tracing::debug; + +use crate::routing::RouteLease; + +/// Host-side implementation of the pinned envd HTTP surface. +/// +/// The broker receives only requests that already passed sandbox route and +/// token validation. Runtime state is checked again against the immutable +/// execution generation in that route lease before a response is returned. +#[derive(Clone)] +pub struct EnvdBroker { + executions: Arc, + processes: process::ProcessBroker, +} + +impl EnvdBroker { + pub fn new( + executions: Arc, + sessions: Arc, + ) -> Self { + Self { + executions, + processes: process::ProcessBroker::new(sessions), + } + } + + pub(crate) async fn handle( + &self, + request: Request, + lease: &RouteLease, + ) -> Response { + let path = request.uri().path().to_string(); + if path.starts_with("/process.Process/") { + return self + .processes + .handle(request, lease.execution_id(), lease.execution_generation()) + .await; + } + self.dispatch( + request.method(), + &path, + lease.execution_id(), + lease.execution_generation(), + ) + .await + } + + pub(crate) fn inactive_health(&self) -> Response { + sandbox_not_running() + } + + async fn dispatch( + &self, + method: &Method, + path: &str, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> Response { + if path != "/health" { + return json_response( + StatusCode::NOT_FOUND, + "ENVD_ROUTE_NOT_FOUND", + "envd route not found", + ); + } + if method != Method::GET { + let mut response = json_response( + StatusCode::METHOD_NOT_ALLOWED, + "METHOD_NOT_ALLOWED", + "method not allowed", + ); + response + .headers_mut() + .insert(ALLOW, HeaderValue::from_static("GET")); + return response; + } + + match self.executions.inspect(execution_id).await { + Ok(status) + if status.execution_id == *execution_id + && status.generation == generation + && status.state == ExecutionState::Running => + { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NO_CONTENT; + response + } + Ok(status) => { + debug!( + execution_id = %execution_id, + lease_generation = generation.get(), + observed_execution_id = %status.execution_id, + observed_generation = status.generation.get(), + observed_state = ?status.state, + "envd health rejected stale or inactive runtime evidence" + ); + sandbox_not_running() + } + Err(ExecutionManagerError::NotFound(_) | ExecutionManagerError::Conflict { .. }) => { + sandbox_not_running() + } + Err(error) => { + debug!(%execution_id, %error, "envd health runtime inspection failed"); + json_response( + StatusCode::SERVICE_UNAVAILABLE, + "ENVD_UNAVAILABLE", + "envd is temporarily unavailable", + ) + } + } + } +} + +fn sandbox_not_running() -> Response { + json_response( + StatusCode::BAD_GATEWAY, + "SANDBOX_NOT_RUNNING", + "sandbox is not running", + ) +} + +fn json_response(status: StatusCode, code: &'static str, message: &'static str) -> Response { + let body = serde_json::json!({ "code": code, "message": message }).to_string(); + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + response +} + +#[cfg(test)] +mod tests; diff --git a/src/compat/src/envd/process.rs b/src/compat/src/envd/process.rs new file mode 100644 index 00000000..1a0451e6 --- /dev/null +++ b/src/compat/src/envd/process.rs @@ -0,0 +1,961 @@ +//! Generation-scoped E2B Process service backed by A3S execution sessions. + +use std::collections::{BTreeMap, HashMap}; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use a3s_box_core::pty::PtyRequest; +use a3s_box_core::{ + ExecEvent, ExecRequest, ExecutionGeneration, ExecutionId, ExecutionManagerError, + ExecutionProcess, ExecutionProcessInput, ExecutionSessionManager, StreamType, +}; +use axum::body::Body; +use axum::http::header::AUTHORIZATION; +use axum::http::{HeaderMap, Method, Request, Response}; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::{broadcast, RwLock}; + +use super::connect::{ + data_frame, decode_stream, decode_unary, stream_error, stream_response, + success_end_stream_frame, unary_ok, ConnectFailure, +}; + +const DEFAULT_PROCESS_TIMEOUT_MS: u64 = 60_000; +const MAX_PROCESSES_PER_GENERATION: usize = 1024; +const PROCESS_EVENT_CAPACITY: usize = 4096; +const PROCESS_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); +const CONNECT_TIMEOUT_HEADER: &str = "connect-timeout-ms"; +const DEFAULT_PROCESS_USER: &str = "user"; + +#[derive(Clone)] +pub(super) struct ProcessBroker { + sessions: Arc, + registry: Arc>, + next_pid: Arc, +} + +impl ProcessBroker { + pub(super) fn new(sessions: Arc) -> Self { + Self { + sessions, + registry: Arc::new(RwLock::new(ProcessRegistry::default())), + next_pid: Arc::new(AtomicU32::new(1000)), + } + } + + pub(super) async fn handle( + &self, + request: Request, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> Response { + let path = request.uri().path().to_string(); + if request.method() != Method::POST { + return ConnectFailure::invalid_argument("Connect procedures require POST") + .unary_response(); + } + let key = ProcessGeneration::new(execution_id, generation); + self.drop_stale_generations(&key).await; + match path.as_str() { + "/process.Process/Start" => self.start(request, key).await, + "/process.Process/Connect" => self.connect(request, &key).await, + "/process.Process/List" => self.list(request, &key).await, + "/process.Process/SendInput" => self.send_input(request, &key).await, + "/process.Process/CloseStdin" => self.close_stdin(request, &key).await, + "/process.Process/SendSignal" => self.send_signal(request, &key).await, + "/process.Process/Update" => self.update(request, &key).await, + "/process.Process/StreamInput" => stream_error(&ConnectFailure::unimplemented( + "ordered client-streaming process input is not implemented", + )), + _ => ConnectFailure::not_found("Process procedure not found").unary_response(), + } + } + + async fn start(&self, request: Request, key: ProcessGeneration) -> Response { + let user = match process_user(request.headers()) { + Ok(user) => user, + Err(error) => return stream_error(&error), + }; + let timeout_ns = match process_timeout_ns(request.headers()) { + Ok(timeout) => timeout, + Err(error) => return stream_error(&error), + }; + let request: StartRequest = match decode_stream(request).await { + Ok(request) => request, + Err(error) => return stream_error(&error), + }; + let config = match request.process { + Some(config) => config, + None => { + return stream_error(&ConnectFailure::invalid_argument( + "StartRequest.process is required", + )) + } + }; + if let Err(error) = config.validate() { + return stream_error(&error); + } + let tag = match normalize_tag(request.tag) { + Ok(tag) => tag, + Err(error) => return stream_error(&error), + }; + let process = if let Some(pty) = request.pty { + let size = match pty.validated_size() { + Ok(size) => size, + Err(error) => return stream_error(&error), + }; + self.sessions + .start_pty( + &key.execution_id, + key.generation(), + PtyRequest { + cmd: config.argv(), + env: config.environment(), + working_dir: config.cwd.clone(), + rootfs: None, + user, + cols: size.cols, + rows: size.rows, + }, + ) + .await + .map(|process| (process, true)) + } else { + self.sessions + .start_process( + &key.execution_id, + key.generation(), + ExecRequest { + cmd: config.argv(), + timeout_ns, + env: config.environment(), + working_dir: config.cwd.clone(), + rootfs: None, + stdin: None, + stdin_streaming: request.stdin.unwrap_or(false), + user, + streaming: true, + }, + ) + .await + .map(|process| (process, false)) + }; + let (process, pty) = match process { + Ok(process) => process, + Err(error) => return stream_error(&manager_failure(error)), + }; + match self.register(key, config, tag, pty, process).await { + Ok((pid, subscription)) => process_stream(pid, subscription), + Err(error) => stream_error(&error), + } + } + + async fn connect(&self, request: Request, key: &ProcessGeneration) -> Response { + let request: ConnectRequest = match decode_stream(request).await { + Ok(request) => request, + Err(error) => return stream_error(&error), + }; + let entry = match self.entry(key, &request.process).await { + Ok(entry) => entry, + Err(error) => return stream_error(&error), + }; + process_stream(entry.pid, entry.subscribe()) + } + + async fn list(&self, request: Request, key: &ProcessGeneration) -> Response { + if let Err(error) = decode_unary::(request).await { + return error.unary_response(); + } + let mut processes = self + .registry + .read() + .await + .generations + .get(key) + .map(|entries| { + entries + .values() + .filter(|entry| entry.is_running()) + .map(|entry| ProcessInfo { + config: entry.config.clone(), + pid: entry.pid, + tag: entry.tag.clone(), + }) + .collect::>() + }) + .unwrap_or_default(); + processes.sort_by_key(|process| process.pid); + unary_ok(&ListResponse { processes }) + } + + async fn send_input(&self, request: Request, key: &ProcessGeneration) -> Response { + let request: SendInputRequest = match decode_unary(request).await { + Ok(request) => request, + Err(error) => return error.unary_response(), + }; + let entry = match self.entry(key, &request.process).await { + Ok(entry) => entry, + Err(error) => return error.unary_response(), + }; + let input = match request.input.and_then(|input| input.into_input()) { + Some(input) => input, + None => { + return ConnectFailure::invalid_argument( + "SendInputRequest.input must contain stdin or PTY data", + ) + .unary_response() + } + }; + let data = match input.decode() { + Ok(data) => data, + Err(error) => return error.unary_response(), + }; + if input.is_pty() != entry.pty { + return ConnectFailure::failed_precondition(if entry.pty { + "PTY processes require PTY input" + } else { + "non-PTY processes require stdin input" + }) + .unary_response(); + } + match entry.input.write_stdin(&data).await { + Ok(()) => unary_ok(&EmptyResponse {}), + Err(error) => manager_failure(error).unary_response(), + } + } + + async fn close_stdin(&self, request: Request, key: &ProcessGeneration) -> Response { + let request: CloseStdinRequest = match decode_unary(request).await { + Ok(request) => request, + Err(error) => return error.unary_response(), + }; + let entry = match self.entry(key, &request.process).await { + Ok(entry) => entry, + Err(error) => return error.unary_response(), + }; + if entry.pty { + return ConnectFailure::failed_precondition( + "CloseStdin is valid only for non-PTY processes", + ) + .unary_response(); + } + match entry.input.close_stdin().await { + Ok(()) => unary_ok(&EmptyResponse {}), + Err(error) => manager_failure(error).unary_response(), + } + } + + async fn send_signal(&self, request: Request, key: &ProcessGeneration) -> Response { + let request: SendSignalRequest = match decode_unary(request).await { + Ok(request) => request, + Err(error) => return error.unary_response(), + }; + if !request.signal.is_sigkill() { + return ConnectFailure::unimplemented( + "this execution transport currently supports SIGNAL_SIGKILL only", + ) + .unary_response(); + } + let entry = match self.entry(key, &request.process).await { + Ok(entry) => entry, + Err(error) => return error.unary_response(), + }; + match entry.input.cancel().await { + Ok(()) => unary_ok(&EmptyResponse {}), + Err(error) => manager_failure(error).unary_response(), + } + } + + async fn update(&self, request: Request, key: &ProcessGeneration) -> Response { + let request: UpdateRequest = match decode_unary(request).await { + Ok(request) => request, + Err(error) => return error.unary_response(), + }; + let entry = match self.entry(key, &request.process).await { + Ok(entry) => entry, + Err(error) => return error.unary_response(), + }; + if !entry.pty { + return ConnectFailure::failed_precondition("UpdateRequest.pty requires a PTY process") + .unary_response(); + } + let size = match request.pty.and_then(|pty| pty.size) { + Some(size) => match size.validate() { + Ok(size) => size, + Err(error) => return error.unary_response(), + }, + None => { + return ConnectFailure::invalid_argument("UpdateRequest.pty.size is required") + .unary_response() + } + }; + match entry.input.resize_pty(size.cols, size.rows).await { + Ok(()) => unary_ok(&EmptyResponse {}), + Err(error) => manager_failure(error).unary_response(), + } + } + + async fn register( + &self, + key: ProcessGeneration, + config: ProcessConfig, + tag: Option, + pty: bool, + process: ExecutionProcess, + ) -> Result<(u32, ProcessSubscription), ConnectFailure> { + let input = process.input(); + let mut registry = self.registry.write().await; + let entries = registry.generations.entry(key.clone()).or_default(); + if entries.len() >= MAX_PROCESSES_PER_GENERATION { + drop(registry); + let _ = input.cancel().await; + return Err(ConnectFailure::resource_exhausted(format!( + "process limit of {MAX_PROCESSES_PER_GENERATION} reached" + ))); + } + let pid = self.allocate_pid(entries)?; + let entry = Arc::new(ProcessEntry::new(pid, config, tag, pty, input)); + let subscription = entry.subscribe(); + entries.insert(pid, entry.clone()); + drop(registry); + + let registry = self.registry.clone(); + tokio::spawn(async move { + pump_process(process, entry.clone()).await; + remove_process(®istry, &key, pid, &entry).await; + }); + Ok((pid, subscription)) + } + + fn allocate_pid( + &self, + entries: &HashMap>, + ) -> Result { + for _ in 0..=MAX_PROCESSES_PER_GENERATION { + let candidate = self.next_pid.fetch_add(1, Ordering::Relaxed); + if candidate != 0 && !entries.contains_key(&candidate) { + return Ok(candidate); + } + } + Err(ConnectFailure::resource_exhausted( + "unable to allocate a synthetic process ID", + )) + } + + async fn entry( + &self, + key: &ProcessGeneration, + selector: &Option, + ) -> Result, ConnectFailure> { + let selector = selector + .as_ref() + .ok_or_else(|| ConnectFailure::invalid_argument("process selector is required"))?; + let registry = self.registry.read().await; + let entries = registry + .generations + .get(key) + .ok_or_else(|| ConnectFailure::not_found("process not found"))?; + match selector.selection()? { + Selection::Pid(pid) => entries + .get(&pid) + .filter(|entry| entry.is_running()) + .cloned() + .ok_or_else(|| ConnectFailure::not_found(format!("process {pid} not found"))), + Selection::Tag(tag) => entries + .values() + .filter(|entry| entry.is_running() && entry.tag.as_deref() == Some(tag)) + .min_by_key(|entry| entry.pid) + .cloned() + .ok_or_else(|| ConnectFailure::not_found(format!("process tag {tag:?} not found"))), + } + } + + async fn drop_stale_generations(&self, current: &ProcessGeneration) { + self.registry.write().await.generations.retain(|key, _| { + key.execution_id != current.execution_id || key.generation == current.generation + }); + } +} + +#[derive(Default)] +struct ProcessRegistry { + generations: HashMap>>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ProcessGeneration { + execution_id: ExecutionId, + generation: ExecutionGeneration, +} + +impl ProcessGeneration { + fn new(execution_id: &ExecutionId, generation: ExecutionGeneration) -> Self { + Self { + execution_id: execution_id.clone(), + generation, + } + } + + fn generation(&self) -> ExecutionGeneration { + self.generation + } +} + +impl Hash for ProcessGeneration { + fn hash(&self, state: &mut H) { + self.execution_id.hash(state); + self.generation.get().hash(state); + } +} + +struct ProcessEntry { + pid: u32, + config: ProcessConfig, + tag: Option, + pty: bool, + input: Arc, + events: broadcast::Sender, + terminal: std::sync::Mutex>, +} + +impl ProcessEntry { + fn new( + pid: u32, + config: ProcessConfig, + tag: Option, + pty: bool, + input: Arc, + ) -> Self { + let (events, _) = broadcast::channel(PROCESS_EVENT_CAPACITY); + Self { + pid, + config, + tag, + pty, + input, + events, + terminal: std::sync::Mutex::new(None), + } + } + + fn subscribe(&self) -> ProcessSubscription { + let receiver = self.events.subscribe(); + let terminal = self + .terminal + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + ProcessSubscription { receiver, terminal } + } + + fn is_running(&self) -> bool { + self.terminal + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_none() + } + + fn publish(&self, event: BrokerEvent) { + let _ = self.events.send(event); + } + + fn finish(&self, event: BrokerEvent) { + let mut terminal = self + .terminal + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if terminal.is_none() { + *terminal = Some(event.clone()); + let _ = self.events.send(event); + } + } +} + +struct ProcessSubscription { + receiver: broadcast::Receiver, + terminal: Option, +} + +impl ProcessSubscription { + async fn next(&mut self) -> Result { + if let Some(event) = self.terminal.take() { + return Ok(event); + } + tokio::select! { + event = self.receiver.recv() => match event { + Ok(event) => Ok(event), + Err(broadcast::error::RecvError::Lagged(count)) => Err( + ConnectFailure::internal(format!( + "process subscriber fell behind by {count} events" + )), + ), + Err(broadcast::error::RecvError::Closed) => Err( + ConnectFailure::unavailable( + "process event stream closed before an exit event", + ), + ), + }, + () = tokio::time::sleep(PROCESS_KEEPALIVE_INTERVAL) => Ok(BrokerEvent::KeepAlive), + } + } +} + +#[derive(Clone)] +enum BrokerEvent { + Stdout(Vec), + Stderr(Vec), + Pty(Vec), + KeepAlive, + End { exit_code: i32 }, + Failure(ConnectFailure), +} + +impl BrokerEvent { + fn is_terminal(&self) -> bool { + matches!(self, Self::End { .. } | Self::Failure(_)) + } + + fn response_json(&self) -> Option { + match self { + Self::Stdout(data) => Some(process_event("data", json!({ "stdout": encode(data) }))), + Self::Stderr(data) => Some(process_event("data", json!({ "stderr": encode(data) }))), + Self::Pty(data) => Some(process_event("data", json!({ "pty": encode(data) }))), + Self::KeepAlive => Some(process_event("keepalive", json!({}))), + Self::End { exit_code } => Some(process_event( + "end", + json!({ + "exitCode": exit_code, + "exited": true, + "status": "exited", + }), + )), + Self::Failure(_) => None, + } + } +} + +async fn pump_process(mut process: ExecutionProcess, entry: Arc) { + loop { + match process.next_event().await { + Ok(Some(ExecEvent::Chunk(chunk))) => { + let event = if entry.pty { + BrokerEvent::Pty(chunk.data) + } else { + match chunk.stream { + StreamType::Stdout => BrokerEvent::Stdout(chunk.data), + StreamType::Stderr => BrokerEvent::Stderr(chunk.data), + } + }; + entry.publish(event); + } + Ok(Some(ExecEvent::FlushAck)) => {} + Ok(Some(ExecEvent::Exit(exit))) => { + entry.finish(BrokerEvent::End { + exit_code: exit.exit_code, + }); + return; + } + Ok(None) => { + entry.finish(BrokerEvent::Failure(ConnectFailure::unavailable( + "execution stream closed before an exit event", + ))); + return; + } + Err(error) => { + entry.finish(BrokerEvent::Failure(manager_failure(error))); + return; + } + } + } +} + +async fn remove_process( + registry: &RwLock, + key: &ProcessGeneration, + pid: u32, + expected: &Arc, +) { + let mut registry = registry.write().await; + let remove_generation = if let Some(entries) = registry.generations.get_mut(key) { + if entries + .get(&pid) + .is_some_and(|entry| Arc::ptr_eq(entry, expected)) + { + entries.remove(&pid); + } + entries.is_empty() + } else { + false + }; + if remove_generation { + registry.generations.remove(key); + } +} + +fn process_stream(pid: u32, mut subscription: ProcessSubscription) -> Response { + let (mut sender, body) = Body::channel(); + tokio::spawn(async move { + let start = data_frame(&process_event("start", json!({ "pid": pid }))); + if sender.send_data(start).await.is_err() { + return; + } + loop { + let event = match subscription.next().await { + Ok(event) => event, + Err(error) => { + let _ = sender.send_data(error.end_stream_frame().into()).await; + return; + } + }; + if let BrokerEvent::Failure(error) = &event { + let _ = sender.send_data(error.end_stream_frame().into()).await; + return; + } + let terminal = event.is_terminal(); + if let Some(value) = event.response_json() { + if sender.send_data(data_frame(&value)).await.is_err() { + return; + } + } + if terminal { + let _ = sender.send_data(success_end_stream_frame()).await; + return; + } + } + }); + stream_response(body) +} + +fn process_event(kind: &'static str, value: Value) -> Value { + let mut event = serde_json::Map::new(); + event.insert(kind.to_string(), value); + json!({ "event": Value::Object(event) }) +} + +fn encode(data: &[u8]) -> String { + STANDARD.encode(data) +} + +fn manager_failure(error: ExecutionManagerError) -> ConnectFailure { + match error { + ExecutionManagerError::InvalidRequest(message) => ConnectFailure::invalid_argument(message), + ExecutionManagerError::NotFound(execution_id) => { + ConnectFailure::not_found(format!("execution {execution_id} not found")) + } + ExecutionManagerError::Conflict { message, .. } => { + ConnectFailure::failed_precondition(message) + } + ExecutionManagerError::Unavailable(message) => ConnectFailure::unavailable(message), + ExecutionManagerError::Internal(message) => ConnectFailure::internal(message), + } +} + +fn process_timeout_ns(headers: &HeaderMap) -> Result { + let timeout_ms = match headers.get(CONNECT_TIMEOUT_HEADER) { + Some(value) => value + .to_str() + .map_err(|_| ConnectFailure::invalid_argument("Connect timeout is not UTF-8"))? + .parse::() + .map_err(|_| { + ConnectFailure::invalid_argument("Connect timeout must be milliseconds") + })?, + None => DEFAULT_PROCESS_TIMEOUT_MS, + }; + timeout_ms.checked_mul(1_000_000).ok_or_else(|| { + ConnectFailure::invalid_argument("Connect timeout is too large to represent") + }) +} + +fn process_user(headers: &HeaderMap) -> Result, ConnectFailure> { + let Some(value) = headers.get(AUTHORIZATION) else { + // envd 0.4.0 and newer applies the user selected during /init when a + // request omits Basic authentication. The pinned A3S E2B runtime uses + // the upstream SDK default, `user`; applying it here preserves that + // behavior while the host-side broker owns the Process service. + return Ok(Some(DEFAULT_PROCESS_USER.to_string())); + }; + let value = value + .to_str() + .map_err(|_| ConnectFailure::invalid_argument("Authorization is not UTF-8"))?; + let (scheme, encoded) = value.split_once(' ').ok_or_else(|| { + ConnectFailure::invalid_argument("Authorization must use Basic user selection") + })?; + if !scheme.eq_ignore_ascii_case("basic") { + return Err(ConnectFailure::invalid_argument( + "Authorization must use Basic user selection", + )); + } + let decoded = STANDARD + .decode(encoded) + .map_err(|_| ConnectFailure::invalid_argument("invalid Basic Authorization payload"))?; + let decoded = String::from_utf8(decoded) + .map_err(|_| ConnectFailure::invalid_argument("Basic user is not UTF-8"))?; + let (user, password) = decoded.split_once(':').ok_or_else(|| { + ConnectFailure::invalid_argument("Basic user selection must contain a colon") + })?; + if user.is_empty() || user.len() > 128 || user.contains('\0') || !password.is_empty() { + return Err(ConnectFailure::invalid_argument( + "Basic user selection is invalid", + )); + } + Ok(Some(user.to_string())) +} + +fn normalize_tag(tag: Option) -> Result, ConnectFailure> { + match tag { + Some(tag) if tag.trim().is_empty() || tag.len() > 128 || tag.contains('\0') => Err( + ConnectFailure::invalid_argument("process tag must be 1 to 128 safe characters"), + ), + tag => Ok(tag), + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProcessConfig { + cmd: String, + #[serde(default)] + args: Vec, + #[serde(default)] + envs: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, +} + +impl ProcessConfig { + fn validate(&self) -> Result<(), ConnectFailure> { + if self.cmd.trim().is_empty() || self.cmd.contains('\0') { + return Err(ConnectFailure::invalid_argument( + "process command cannot be empty or contain NUL", + )); + } + if self.args.iter().any(|argument| argument.contains('\0')) { + return Err(ConnectFailure::invalid_argument( + "process arguments cannot contain NUL", + )); + } + if self.envs.iter().any(|(key, value)| { + key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') + }) { + return Err(ConnectFailure::invalid_argument( + "process environment contains an invalid name or value", + )); + } + if self.cwd.as_deref().is_some_and(|cwd| cwd.contains('\0')) { + return Err(ConnectFailure::invalid_argument( + "process working directory cannot contain NUL", + )); + } + Ok(()) + } + + fn argv(&self) -> Vec { + std::iter::once(self.cmd.clone()) + .chain(self.args.iter().cloned()) + .collect() + } + + fn environment(&self) -> Vec { + self.envs + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StartRequest { + #[serde(default)] + process: Option, + #[serde(default)] + pty: Option, + #[serde(default)] + tag: Option, + #[serde(default)] + stdin: Option, +} + +#[derive(Debug, Deserialize)] +struct Pty { + #[serde(default)] + size: Option, +} + +impl Pty { + fn validated_size(self) -> Result { + self.size + .ok_or_else(|| ConnectFailure::invalid_argument("PTY.size is required"))? + .validate() + } +} + +#[derive(Debug, Deserialize)] +struct PtySize { + cols: u32, + rows: u32, +} + +impl PtySize { + fn validate(self) -> Result { + let cols = u16::try_from(self.cols) + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| ConnectFailure::invalid_argument("PTY columns must be 1 to 65535"))?; + let rows = u16::try_from(self.rows) + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| ConnectFailure::invalid_argument("PTY rows must be 1 to 65535"))?; + Ok(ValidatedPtySize { cols, rows }) + } +} + +struct ValidatedPtySize { + cols: u16, + rows: u16, +} + +#[derive(Debug, Default, Deserialize)] +struct EmptyRequest {} + +#[derive(Debug, Serialize)] +struct EmptyResponse {} + +#[derive(Debug, Serialize)] +struct ListResponse { + processes: Vec, +} + +#[derive(Debug, Serialize)] +struct ProcessInfo { + config: ProcessConfig, + pid: u32, + #[serde(skip_serializing_if = "Option::is_none")] + tag: Option, +} + +#[derive(Debug, Deserialize)] +struct ConnectRequest { + #[serde(default)] + process: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct ProcessSelector { + #[serde(default)] + pid: Option, + #[serde(default)] + tag: Option, +} + +impl ProcessSelector { + fn selection(&self) -> Result, ConnectFailure> { + match (self.pid, self.tag.as_deref()) { + (Some(pid), None) if pid > 0 => Ok(Selection::Pid(pid)), + (None, Some(tag)) if !tag.is_empty() => Ok(Selection::Tag(tag)), + _ => Err(ConnectFailure::invalid_argument( + "process selector must contain exactly one non-empty pid or tag", + )), + } + } +} + +enum Selection<'a> { + Pid(u32), + Tag(&'a str), +} + +#[derive(Debug, Deserialize)] +struct SendInputRequest { + #[serde(default)] + process: Option, + #[serde(default)] + input: Option, +} + +#[derive(Debug, Deserialize)] +struct ProcessInput { + #[serde(default)] + stdin: Option, + #[serde(default)] + pty: Option, +} + +impl ProcessInput { + fn into_input(self) -> Option { + match (self.stdin, self.pty) { + (Some(data), None) => Some(EncodedInput::Stdin(data)), + (None, Some(data)) => Some(EncodedInput::Pty(data)), + _ => None, + } + } +} + +enum EncodedInput { + Stdin(String), + Pty(String), +} + +impl EncodedInput { + fn decode(&self) -> Result, ConnectFailure> { + STANDARD.decode(self.value()).map_err(|_| { + ConnectFailure::invalid_argument("process input is not valid protobuf JSON base64") + }) + } + + fn is_pty(&self) -> bool { + matches!(self, Self::Pty(_)) + } + + fn value(&self) -> &str { + match self { + Self::Stdin(value) | Self::Pty(value) => value, + } + } +} + +#[derive(Debug, Deserialize)] +struct CloseStdinRequest { + #[serde(default)] + process: Option, +} + +#[derive(Debug, Deserialize)] +struct SendSignalRequest { + #[serde(default)] + process: Option, + signal: Signal, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Signal { + Name(String), + Number(i32), +} + +impl Signal { + fn is_sigkill(&self) -> bool { + match self { + Self::Name(name) => name == "SIGNAL_SIGKILL", + Self::Number(number) => *number == 9, + } + } +} + +#[derive(Debug, Deserialize)] +struct UpdateRequest { + #[serde(default)] + process: Option, + #[serde(default)] + pty: Option, +} + +#[cfg(test)] +#[path = "process_tests.rs"] +mod tests; diff --git a/src/compat/src/envd/process_tests.rs b/src/compat/src/envd/process_tests.rs new file mode 100644 index 00000000..5c2f51aa --- /dev/null +++ b/src/compat/src/envd/process_tests.rs @@ -0,0 +1,320 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use a3s_box_core::pty::PtyRequest; +use a3s_box_core::{ + ExecChunk, ExecEvent, ExecExit, ExecOutput, ExecRequest, ExecutionGeneration, ExecutionId, + ExecutionManagerError, ExecutionManagerResult, ExecutionProcess, ExecutionProcessInput, + ExecutionProcessStream, ExecutionSessionManager, FileRequest, FileResponse, StreamType, +}; +use async_trait::async_trait; +use axum::body::Body; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::{Request, StatusCode}; +use hyper::body::{to_bytes, HttpBody}; +use serde_json::{json, Value}; + +use super::{process_user, ProcessBroker}; + +#[derive(Default)] +struct TestInput { + writes: Mutex>>, + closed: Mutex, + cancelled: Mutex, + sizes: Mutex>, +} + +#[async_trait] +impl ExecutionProcessInput for TestInput { + async fn write_stdin(&self, data: &[u8]) -> ExecutionManagerResult<()> { + self.writes.lock().unwrap().push(data.to_vec()); + Ok(()) + } + + async fn close_stdin(&self) -> ExecutionManagerResult<()> { + *self.closed.lock().unwrap() = true; + Ok(()) + } + + async fn cancel(&self) -> ExecutionManagerResult<()> { + *self.cancelled.lock().unwrap() = true; + Ok(()) + } + + async fn resize_pty(&self, cols: u16, rows: u16) -> ExecutionManagerResult<()> { + self.sizes.lock().unwrap().push((cols, rows)); + Ok(()) + } +} + +struct TestProcess { + events: VecDeque, + input: Arc, +} + +#[async_trait] +impl ExecutionProcessStream for TestProcess { + fn input(&self) -> Arc { + self.input.clone() + } + + async fn next_event(&mut self) -> ExecutionManagerResult> { + if let Some(event) = self.events.pop_front() { + return Ok(Some(event)); + } + std::future::pending().await + } +} + +#[derive(Default)] +struct TestSessions { + queued_events: Mutex>>, + requests: Mutex>, + inputs: Mutex>>, +} + +impl TestSessions { + fn queue(&self, events: Vec) { + self.queued_events.lock().unwrap().push_back(events); + } + + fn latest_input(&self) -> Arc { + self.inputs.lock().unwrap().last().unwrap().clone() + } +} + +#[async_trait] +impl ExecutionSessionManager for TestSessions { + async fn execute( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: ExecRequest, + ) -> ExecutionManagerResult { + Err(unsupported()) + } + + async fn start_process( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + request: ExecRequest, + ) -> ExecutionManagerResult { + self.requests + .lock() + .unwrap() + .push((execution_id.to_string(), generation.get(), request)); + let input = Arc::new(TestInput::default()); + self.inputs.lock().unwrap().push(input.clone()); + let events = self + .queued_events + .lock() + .unwrap() + .pop_front() + .unwrap_or_default() + .into(); + Ok(Box::new(TestProcess { events, input })) + } + + async fn start_pty( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: PtyRequest, + ) -> ExecutionManagerResult { + Err(unsupported()) + } + + async fn transfer_file( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: FileRequest, + ) -> ExecutionManagerResult { + Err(unsupported()) + } +} + +fn unsupported() -> ExecutionManagerError { + ExecutionManagerError::Unavailable("unsupported test operation".to_string()) +} + +fn execution_id() -> ExecutionId { + ExecutionId::new("execution-process-test").unwrap() +} + +fn stream_request(path: &str, value: Value) -> Request { + let payload = serde_json::to_vec(&value).unwrap(); + let mut body = Vec::with_capacity(payload.len() + 5); + body.push(0); + body.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + body.extend_from_slice(&payload); + Request::post(path) + .header(CONTENT_TYPE, "application/connect+json") + .header(AUTHORIZATION, "Basic dXNlcjo=") + .header("connect-timeout-ms", "2500") + .body(Body::from(body)) + .unwrap() +} + +fn unary_request(path: &str, value: Value) -> Request { + Request::post(path) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(value.to_string())) + .unwrap() +} + +fn decode_frames(bytes: &[u8]) -> Vec<(u8, Value)> { + let mut frames = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + let flags = bytes[offset]; + let length = u32::from_be_bytes([ + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + bytes[offset + 4], + ]) as usize; + let start = offset + 5; + let end = start + length; + frames.push((flags, serde_json::from_slice(&bytes[start..end]).unwrap())); + offset = end; + } + frames +} + +#[test] +fn missing_user_header_selects_the_pinned_envd_default() { + assert_eq!( + process_user(&axum::http::HeaderMap::new()) + .unwrap() + .as_deref(), + Some("user") + ); +} + +fn pid_from_start_frame(bytes: &[u8]) -> u32 { + let frames = decode_frames(bytes); + frames[0].1["event"]["start"]["pid"].as_u64().unwrap() as u32 +} + +#[tokio::test] +async fn start_maps_the_pinned_json_request_and_streams_ordered_events() { + let sessions = Arc::new(TestSessions::default()); + sessions.queue(vec![ + ExecEvent::Chunk(ExecChunk { + stream: StreamType::Stdout, + data: b"hello".to_vec(), + }), + ExecEvent::Chunk(ExecChunk { + stream: StreamType::Stderr, + data: b"warning".to_vec(), + }), + ExecEvent::Exit(ExecExit { + exit_code: 0, + oom_killed: false, + }), + ]); + let broker = ProcessBroker::new(sessions.clone()); + let response = broker + .handle( + stream_request( + "/process.Process/Start", + json!({ + "process": { + "cmd": "/bin/bash", + "args": ["-l", "-c", "printf hello"], + "envs": {"ALPHA": "one"}, + "cwd": "/tmp" + }, + "stdin": true, + "tag": "job-one" + }), + ), + &execution_id(), + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "application/connect+json"); + let frames = decode_frames(&to_bytes(response.into_body()).await.unwrap()); + assert_eq!(frames.len(), 5); + assert_eq!(frames[0].0, 0); + assert!(frames[0].1["event"]["start"]["pid"].is_number()); + assert_eq!(frames[1].1["event"]["data"]["stdout"], "aGVsbG8="); + assert_eq!(frames[2].1["event"]["data"]["stderr"], "d2FybmluZw=="); + assert_eq!(frames[3].1["event"]["end"]["exitCode"], 0); + assert_eq!(frames[4], (2, json!({}))); + + let requests = sessions.requests.lock().unwrap(); + let (id, generation, request) = &requests[0]; + assert_eq!(id, "execution-process-test"); + assert_eq!(*generation, 1); + assert_eq!(request.cmd, ["/bin/bash", "-l", "-c", "printf hello"]); + assert_eq!(request.timeout_ns, 2_500_000_000); + assert_eq!(request.env, ["ALPHA=one"]); + assert_eq!(request.working_dir.as_deref(), Some("/tmp")); + assert_eq!(request.user.as_deref(), Some("user")); + assert!(request.stdin_streaming); +} + +#[tokio::test] +async fn list_and_input_are_scoped_to_the_exact_execution_generation() { + let sessions = Arc::new(TestSessions::default()); + sessions.queue(Vec::new()); + let broker = ProcessBroker::new(sessions.clone()); + let mut response = broker + .handle( + stream_request( + "/process.Process/Start", + json!({ + "process": {"cmd": "/bin/cat", "args": [], "envs": {}}, + "stdin": true, + "tag": "interactive" + }), + ), + &execution_id(), + ExecutionGeneration::INITIAL, + ) + .await; + let first = response.body_mut().data().await.unwrap().unwrap(); + let pid = pid_from_start_frame(&first); + drop(response); + + let listed = broker + .handle( + unary_request("/process.Process/List", json!({})), + &execution_id(), + ExecutionGeneration::INITIAL, + ) + .await; + let listed: Value = + serde_json::from_slice(&to_bytes(listed.into_body()).await.unwrap()).unwrap(); + assert_eq!(listed["processes"][0]["pid"], pid); + assert_eq!(listed["processes"][0]["tag"], "interactive"); + + let sent = broker + .handle( + unary_request( + "/process.Process/SendInput", + json!({"process": {"pid": pid}, "input": {"stdin": "aGVsbG8="}}), + ), + &execution_id(), + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(sent.status(), StatusCode::OK); + assert_eq!(sessions.latest_input().writes.lock().unwrap()[0], b"hello"); + + let stale = broker + .handle( + unary_request( + "/process.Process/SendInput", + json!({"process": {"pid": pid}, "input": {"stdin": "eA=="}}), + ), + &execution_id(), + ExecutionGeneration::new(2).unwrap(), + ) + .await; + assert_eq!(stale.status(), StatusCode::NOT_FOUND); +} diff --git a/src/compat/src/envd/tests.rs b/src/compat/src/envd/tests.rs new file mode 100644 index 00000000..50dd62c0 --- /dev/null +++ b/src/compat/src/envd/tests.rs @@ -0,0 +1,227 @@ +use std::sync::{Arc, Mutex}; + +use a3s_box_core::pty::PtyRequest; +use a3s_box_core::{ + resolve_execution, BoxConfig, ExecOutput, ExecRequest, ExecutionGeneration, ExecutionId, + ExecutionLease, ExecutionManager, ExecutionManagerError, ExecutionManagerResult, + ExecutionProcess, ExecutionSessionManager, ExecutionState, ExecutionStatus, FileRequest, + FileResponse, KillOutcome, OperationId, ReconcileOutcome, +}; +use async_trait::async_trait; +use axum::http::{Method, StatusCode}; + +use super::EnvdBroker; + +#[derive(Clone)] +enum Inspection { + Status(ExecutionStatus), + NotFound, + Unavailable, +} + +struct InspectOnlyManager { + inspection: Mutex, +} + +impl InspectOnlyManager { + fn new(inspection: Inspection) -> Self { + Self { + inspection: Mutex::new(inspection), + } + } +} + +#[async_trait] +impl ExecutionManager for InspectOnlyManager { + async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult { + match self.inspection.lock().unwrap().clone() { + Inspection::Status(status) => Ok(status), + Inspection::NotFound => Err(ExecutionManagerError::NotFound(execution_id.clone())), + Inspection::Unavailable => Err(ExecutionManagerError::Unavailable( + "test inspector unavailable".to_string(), + )), + } + } + + async fn pause( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _keep_memory: bool, + ) -> ExecutionManagerResult { + Err(unsupported()) + } + + async fn resume( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + Err(unsupported()) + } + + async fn kill( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + Err(unsupported()) + } + + async fn reconcile( + &self, + _operation_id: &OperationId, + ) -> ExecutionManagerResult { + Err(unsupported()) + } +} + +#[async_trait] +impl ExecutionSessionManager for InspectOnlyManager { + async fn execute( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: ExecRequest, + ) -> ExecutionManagerResult { + Err(unsupported()) + } + + async fn start_process( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: ExecRequest, + ) -> ExecutionManagerResult { + Err(unsupported()) + } + + async fn start_pty( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: PtyRequest, + ) -> ExecutionManagerResult { + Err(unsupported()) + } + + async fn transfer_file( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: FileRequest, + ) -> ExecutionManagerResult { + Err(unsupported()) + } +} + +fn unsupported() -> ExecutionManagerError { + ExecutionManagerError::Unavailable("unsupported test operation".to_string()) +} + +fn status(state: ExecutionState, generation: ExecutionGeneration) -> ExecutionStatus { + ExecutionStatus { + execution_id: ExecutionId::new("execution-envd-1").unwrap(), + generation, + state, + plan: resolve_execution(&BoxConfig::default()).unwrap(), + } +} + +fn broker(inspection: Inspection) -> EnvdBroker { + let manager = Arc::new(InspectOnlyManager::new(inspection)); + EnvdBroker::new(manager.clone(), manager) +} + +#[tokio::test] +async fn health_requires_exact_running_execution_generation() { + let execution_id = ExecutionId::new("execution-envd-1").unwrap(); + let response = broker(Inspection::Status(status( + ExecutionState::Running, + ExecutionGeneration::INITIAL, + ))) + .dispatch( + &Method::GET, + "/health", + &execution_id, + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = broker(Inspection::Status(status( + ExecutionState::Running, + ExecutionGeneration::new(2).unwrap(), + ))) + .dispatch( + &Method::GET, + "/health", + &execution_id, + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + + let response = broker(Inspection::Status(status( + ExecutionState::Stopped, + ExecutionGeneration::INITIAL, + ))) + .dispatch( + &Method::GET, + "/health", + &execution_id, + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); +} + +#[tokio::test] +async fn health_distinguishes_missing_runtime_from_inspector_outage() { + let execution_id = ExecutionId::new("execution-envd-1").unwrap(); + let missing = broker(Inspection::NotFound) + .dispatch( + &Method::GET, + "/health", + &execution_id, + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(missing.status(), StatusCode::BAD_GATEWAY); + + let unavailable = broker(Inspection::Unavailable) + .dispatch( + &Method::GET, + "/health", + &execution_id, + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(unavailable.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn broker_rejects_unimplemented_routes_and_methods_without_inspection() { + let execution_id = ExecutionId::new("execution-envd-1").unwrap(); + let broker = broker(Inspection::Unavailable); + let missing = broker + .dispatch( + &Method::GET, + "/metrics", + &execution_id, + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + let wrong_method = broker + .dispatch( + &Method::POST, + "/health", + &execution_id, + ExecutionGeneration::INITIAL, + ) + .await; + assert_eq!(wrong_method.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(wrong_method.headers()["allow"], "GET"); +} diff --git a/src/compat/src/exports.rs b/src/compat/src/exports.rs new file mode 100644 index 00000000..b12fc59d --- /dev/null +++ b/src/compat/src/exports.rs @@ -0,0 +1,369 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::model::{PackageExports, PublicExportInventory, SourceLock}; + +pub(crate) fn read_public_exports( + root: &Path, + source_lock: &SourceLock, +) -> Result { + let e2b_source = source_lock + .sources + .get("e2b") + .context("upstream lock is missing the e2b source")?; + let interpreter_source = source_lock + .sources + .get("code-interpreter") + .context("upstream lock is missing the code-interpreter source")?; + + let python_e2b_source = read(root.join("spec/e2b/public-exports/python-init.py"))?; + let python_e2b_symbols = parse_python_all(&python_e2b_source); + let python_e2b = PackageExports { + language: "python".to_string(), + package: "e2b".to_string(), + version: package_version(e2b_source, "python")?, + symbols: python_e2b_symbols.clone(), + type_only_symbols: Vec::new(), + reexports: Vec::new(), + has_default_export: false, + }; + + let mut typescript_e2b = parse_typescript_exports(&read( + root.join("spec/e2b/public-exports/typescript-index.ts"), + )?); + let template_exports = parse_typescript_exports(&read( + root.join("spec/e2b/public-exports/typescript-template-index.ts"), + )?); + merge_typescript_exports(&mut typescript_e2b, &template_exports); + let typescript_e2b = PackageExports { + language: "typescript".to_string(), + package: "e2b".to_string(), + version: package_version(e2b_source, "typescript")?, + symbols: typescript_e2b.symbols, + type_only_symbols: typescript_e2b.type_only_symbols, + reexports: typescript_e2b.reexports, + has_default_export: typescript_e2b.has_default_export, + }; + + let python_interpreter_source = + read(root.join("spec/code-interpreter/public-exports/python-init.py"))?; + let (explicit_python_interpreter, python_reexports) = + parse_python_import_exports(&python_interpreter_source); + let python_interpreter_symbols = python_e2b_symbols + .iter() + .chain(explicit_python_interpreter.iter()) + .cloned() + .collect::>() + .into_iter() + .collect(); + let python_interpreter = PackageExports { + language: "python".to_string(), + package: "e2b-code-interpreter".to_string(), + version: package_version(interpreter_source, "python")?, + symbols: python_interpreter_symbols, + type_only_symbols: Vec::new(), + reexports: python_reexports, + has_default_export: false, + }; + + let mut typescript_interpreter = parse_typescript_exports(&read( + root.join("spec/code-interpreter/public-exports/typescript-index.ts"), + )?); + typescript_interpreter + .symbols + .extend(typescript_e2b.symbols.iter().cloned()); + typescript_interpreter + .type_only_symbols + .extend(typescript_e2b.type_only_symbols.iter().cloned()); + typescript_interpreter.normalize(); + let typescript_interpreter = PackageExports { + language: "typescript".to_string(), + package: "@e2b/code-interpreter".to_string(), + version: package_version(interpreter_source, "typescript")?, + symbols: typescript_interpreter.symbols, + type_only_symbols: typescript_interpreter.type_only_symbols, + reexports: typescript_interpreter.reexports, + has_default_export: typescript_interpreter.has_default_export, + }; + + let packages = BTreeMap::from([ + ("python-code-interpreter".to_string(), python_interpreter), + ("python-e2b".to_string(), python_e2b), + ( + "typescript-code-interpreter".to_string(), + typescript_interpreter, + ), + ("typescript-e2b".to_string(), typescript_e2b), + ]); + Ok(PublicExportInventory { + schema_version: 1, + compatibility_id: source_lock.compatibility.id.clone(), + packages, + }) +} + +fn package_version(source: &crate::model::UpstreamSource, language: &str) -> Result { + source + .packages + .get(language) + .cloned() + .with_context(|| format!("upstream source is missing the {language} package version")) +} + +fn read(path: impl AsRef) -> Result { + let path = path.as_ref(); + std::fs::read_to_string(path) + .with_context(|| format!("failed to read public export source {}", path.display())) +} + +fn parse_python_all(source: &str) -> Vec { + let Some(start) = source.find("__all__ = [") else { + return Vec::new(); + }; + let remainder = &source[start + "__all__ = [".len()..]; + let Some(end) = remainder.find(']') else { + return Vec::new(); + }; + quoted_symbols(&remainder[..end]) +} + +fn parse_python_import_exports(source: &str) -> (Vec, Vec) { + let mut symbols = BTreeSet::new(); + let mut reexports = BTreeSet::new(); + let mut multiline_target: Option = None; + for raw_line in source.lines() { + let line = raw_line.trim(); + if multiline_target.is_some() { + if line == ")" { + multiline_target = None; + continue; + } + for symbol in line.trim_end_matches(',').split(',') { + insert_python_symbol(&mut symbols, symbol); + } + continue; + } + let Some(rest) = line.strip_prefix("from ") else { + continue; + }; + let Some((module, imported)) = rest.split_once(" import ") else { + continue; + }; + if imported == "*" { + reexports.insert(module.to_string()); + } else if imported == "(" { + multiline_target = Some(module.to_string()); + } else { + for symbol in imported.split(',') { + insert_python_symbol(&mut symbols, symbol); + } + } + } + ( + symbols.into_iter().collect(), + reexports.into_iter().collect(), + ) +} + +fn insert_python_symbol(symbols: &mut BTreeSet, value: &str) { + let value = value.trim().trim_end_matches(','); + if value.is_empty() || value.starts_with('_') { + return; + } + let exported = value + .split_once(" as ") + .map(|(_, alias)| alias) + .unwrap_or(value); + symbols.insert(exported.to_string()); +} + +fn quoted_symbols(value: &str) -> Vec { + let mut symbols = BTreeSet::new(); + for line in value.lines() { + let line = line.trim().trim_end_matches(',').trim(); + if line.len() >= 2 + && ((line.starts_with('"') && line.ends_with('"')) + || (line.starts_with('\'') && line.ends_with('\''))) + { + symbols.insert(line[1..line.len() - 1].to_string()); + } + } + symbols.into_iter().collect() +} + +#[derive(Default)] +struct TypeScriptExports { + symbols: Vec, + type_only_symbols: Vec, + reexports: Vec, + has_default_export: bool, +} + +impl TypeScriptExports { + fn normalize(&mut self) { + self.symbols.sort(); + self.symbols.dedup(); + self.type_only_symbols.sort(); + self.type_only_symbols.dedup(); + self.reexports.sort(); + self.reexports.dedup(); + } +} + +fn parse_typescript_exports(source: &str) -> TypeScriptExports { + let mut exports = TypeScriptExports::default(); + let mut block: Option<(bool, String)> = None; + for raw_line in source.lines() { + let line = strip_typescript_comment(raw_line).trim(); + if let Some((type_only, content)) = block.as_mut() { + content.push(' '); + content.push_str(line); + if line.contains('}') { + parse_typescript_export_block(content, *type_only, &mut exports); + block = None; + } + continue; + } + if line.starts_with("export default ") { + exports.has_default_export = true; + continue; + } + if let Some(target) = parse_star_reexport(line) { + exports.reexports.push(target); + continue; + } + if line.starts_with("export type {") || line.starts_with("export {") { + let type_only = line.starts_with("export type {"); + if line.contains('}') { + parse_typescript_export_block(line, type_only, &mut exports); + } else { + block = Some((type_only, line.to_string())); + } + continue; + } + parse_typescript_declaration(line, &mut exports); + } + exports.normalize(); + exports +} + +fn parse_typescript_export_block( + block: &str, + block_type_only: bool, + exports: &mut TypeScriptExports, +) { + let Some(open) = block.find('{') else { + return; + }; + let Some(close) = block.rfind('}') else { + return; + }; + for item in block[open + 1..close].split(',') { + let item = item.trim(); + if item.is_empty() { + continue; + } + let item_type_only = block_type_only || item.starts_with("type "); + let item = item.strip_prefix("type ").unwrap_or(item).trim(); + let exported = item + .split_once(" as ") + .map(|(_, alias)| alias.trim()) + .unwrap_or(item); + if exported.is_empty() { + continue; + } + if item_type_only { + exports.type_only_symbols.push(exported.to_string()); + } else { + exports.symbols.push(exported.to_string()); + } + } +} + +fn parse_typescript_declaration(line: &str, exports: &mut TypeScriptExports) { + let declarations = [ + ("export abstract class ", false), + ("export async function ", false), + ("export class ", false), + ("export function ", false), + ("export const ", false), + ("export let ", false), + ("export enum ", false), + ("export interface ", true), + ("export type ", true), + ]; + for (prefix, type_only) in declarations { + let Some(remainder) = line.strip_prefix(prefix) else { + continue; + }; + let name = remainder + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .next() + .unwrap_or_default(); + if !name.is_empty() { + if type_only { + exports.type_only_symbols.push(name.to_string()); + } else { + exports.symbols.push(name.to_string()); + } + } + return; + } +} + +fn parse_star_reexport(line: &str) -> Option { + let rest = line.strip_prefix("export * from ")?.trim(); + let target = rest.trim_end_matches(';').trim(); + if target.len() < 2 { + return None; + } + Some(target[1..target.len() - 1].to_string()) +} + +fn strip_typescript_comment(line: &str) -> &str { + line.split_once("//").map(|(code, _)| code).unwrap_or(line) +} + +fn merge_typescript_exports(target: &mut TypeScriptExports, source: &TypeScriptExports) { + target.symbols.extend(source.symbols.iter().cloned()); + target + .type_only_symbols + .extend(source.type_only_symbols.iter().cloned()); + target.reexports.extend(source.reexports.iter().cloned()); + target.has_default_export |= source.has_default_export; + target.normalize(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_python_all_and_import_reexports() { + assert_eq!( + parse_python_all("__all__ = [\n \"Sandbox\",\n \"AsyncSandbox\",\n]"), + ["AsyncSandbox", "Sandbox"] + ); + let (symbols, reexports) = parse_python_import_exports( + "from e2b import *\nfrom .models import (\n Result,\n Logs,\n)\n", + ); + assert_eq!(symbols, ["Logs", "Result"]); + assert_eq!(reexports, ["e2b"]); + } + + #[test] + fn parses_typescript_value_type_and_star_exports() { + let exports = parse_typescript_exports( + "export { Sandbox, type Opts } from './sandbox'\n\ + export type { Result } from './result'\n\ + export * from 'e2b'\n\ + export default Sandbox\n", + ); + assert_eq!(exports.symbols, ["Sandbox"]); + assert_eq!(exports.type_only_symbols, ["Opts", "Result"]); + assert_eq!(exports.reexports, ["e2b"]); + assert!(exports.has_default_export); + } +} diff --git a/src/compat/src/fixture.rs b/src/compat/src/fixture.rs new file mode 100644 index 00000000..9f5c1685 --- /dev/null +++ b/src/compat/src/fixture.rs @@ -0,0 +1,522 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use serde::Serialize; + +use crate::digest::sha256; +use crate::exports::read_public_exports; +use crate::model::{CompatibilityManifest, ContractInventory, SourceLock}; +use crate::openapi::{read_json_schema, read_openapi}; +use crate::proto::read_protobuf_contracts; + +const CONTROL_OPENAPI: &str = "spec/e2b/openapi.yml"; +const ENVD_OPENAPI: &str = "spec/e2b/envd/envd.yaml"; +const VOLUME_OPENAPI: &str = "spec/e2b/openapi-volumecontent.yml"; +const PROCESS_PROTO: &str = "spec/e2b/envd/process/process.proto"; +const FILESYSTEM_PROTO: &str = "spec/e2b/envd/filesystem/filesystem.proto"; +const MCP_SCHEMA: &str = "spec/e2b/mcp-server.json"; + +#[derive(Debug, Clone)] +pub struct FixturePaths { + root: PathBuf, +} + +impl FixturePaths { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + pub fn repository_default() -> Self { + Self::new( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("compat/e2b"), + ) + } + + fn source_lock(&self) -> PathBuf { + self.root.join("upstream.lock.json") + } + + fn contract_inventory(&self) -> PathBuf { + self.root.join("inventory/contracts.json") + } + + fn public_exports(&self) -> PathBuf { + self.root.join("inventory/public-exports.json") + } + + fn manifest(&self) -> PathBuf { + self.root.join("manifests/v1.json") + } +} + +struct GeneratedFixture { + contracts: Vec, + public_exports: Vec, + manifest: Vec, +} + +pub fn generate_fixture(paths: &FixturePaths) -> Result<()> { + let fixture = build_fixture(paths)?; + write_generated(&paths.contract_inventory(), &fixture.contracts)?; + write_generated(&paths.public_exports(), &fixture.public_exports)?; + write_generated(&paths.manifest(), &fixture.manifest)?; + Ok(()) +} + +pub fn verify_fixture(paths: &FixturePaths) -> Result<()> { + let fixture = build_fixture(paths)?; + verify_generated( + &paths.contract_inventory(), + &fixture.contracts, + "contract inventory", + )?; + verify_generated( + &paths.public_exports(), + &fixture.public_exports, + "public export inventory", + )?; + verify_generated( + &paths.manifest(), + &fixture.manifest, + "compatibility manifest", + )?; + Ok(()) +} + +fn build_fixture(paths: &FixturePaths) -> Result { + let source_lock_bytes = std::fs::read(paths.source_lock()).with_context(|| { + format!( + "failed to read E2B upstream lock {}", + paths.source_lock().display() + ) + })?; + let source_lock: SourceLock = + serde_json::from_slice(&source_lock_bytes).context("failed to parse E2B upstream lock")?; + validate_source_lock(paths, &source_lock)?; + + let control_plane_tags = source_lock + .compatibility + .control_plane_tags + .iter() + .cloned() + .collect::>(); + let openapi = vec![ + read_openapi( + &paths.root.join(CONTROL_OPENAPI), + "control-plane", + Some(&control_plane_tags), + )?, + read_openapi(&paths.root.join(ENVD_OPENAPI), "envd", None)?, + read_openapi(&paths.root.join(VOLUME_OPENAPI), "volume-content", None)?, + ]; + let protobuf = read_protobuf_contracts( + &paths.root.join("spec/e2b/envd"), + &["filesystem/filesystem.proto", "process/process.proto"], + )?; + let mcp = read_json_schema(&paths.root.join(MCP_SCHEMA))?; + let contract_inventory = ContractInventory { + schema_version: 1, + compatibility_id: source_lock.compatibility.id.clone(), + openapi, + protobuf, + mcp, + }; + let public_export_inventory = read_public_exports(&paths.root, &source_lock)?; + let contracts = pretty_json(&contract_inventory)?; + let public_exports = pretty_json(&public_export_inventory)?; + let manifest = build_manifest( + &source_lock, + &contract_inventory, + &contracts, + &public_exports, + )?; + + Ok(GeneratedFixture { + contracts, + public_exports, + manifest: pretty_json(&manifest)?, + }) +} + +fn validate_source_lock(paths: &FixturePaths, source_lock: &SourceLock) -> Result<()> { + if source_lock.schema_version != 1 { + bail!( + "unsupported E2B upstream lock schema version {}", + source_lock.schema_version + ); + } + if source_lock.compatibility.id.trim().is_empty() + || source_lock.compatibility.version.trim().is_empty() + { + bail!("E2B upstream lock compatibility identity and version must be non-empty"); + } + if source_lock.compatibility.control_plane_tags.is_empty() { + bail!("E2B upstream lock must select at least one public control-plane tag"); + } + for required in ["e2b", "code-interpreter"] { + let source = source_lock + .sources + .get(required) + .with_context(|| format!("E2B upstream lock is missing source {required}"))?; + if source.repository.trim().is_empty() || source.commit.len() != 40 { + bail!("E2B upstream source {required} has an invalid repository or commit"); + } + for language in ["python", "typescript"] { + if source + .packages + .get(language) + .is_none_or(|version| version.trim().is_empty()) + { + bail!("E2B upstream source {required} is missing {language} package version"); + } + } + } + + let mut artifact_ids = BTreeSet::new(); + for artifact in &source_lock.artifacts { + if !artifact_ids.insert(artifact.id.as_str()) { + bail!("duplicate official client artifact id {}", artifact.id); + } + let source = source_lock.sources.get(&artifact.source).with_context(|| { + format!( + "official client artifact {} names unknown source {}", + artifact.id, artifact.source + ) + })?; + let expected_version = source.packages.get(&artifact.language).with_context(|| { + format!( + "official client artifact {} has unknown language {}", + artifact.id, artifact.language + ) + })?; + if artifact.version != *expected_version { + bail!( + "official client artifact {} version {} does not match pinned {} version {}", + artifact.id, + artifact.version, + artifact.language, + expected_version + ); + } + if artifact.package.trim().is_empty() + || !artifact.url.starts_with("https://") + || !is_sha256(&artifact.sha256) + { + bail!( + "official client artifact {} has invalid metadata", + artifact.id + ); + } + if artifact.language == "typescript" + && artifact + .integrity + .as_deref() + .is_none_or(|integrity| !integrity.starts_with("sha512-")) + { + bail!( + "official TypeScript client artifact {} is missing npm integrity", + artifact.id + ); + } + } + for required in [ + "python-e2b-wheel", + "python-code-interpreter-wheel", + "typescript-e2b-tarball", + "typescript-code-interpreter-tarball", + ] { + if !artifact_ids.contains(required) { + bail!("E2B upstream lock is missing official client artifact {required}"); + } + } + + let mut locked_paths = BTreeSet::new(); + for file in &source_lock.files { + validate_relative_path(&file.local_path)?; + if !locked_paths.insert(file.local_path.as_str()) { + bail!("duplicate E2B upstream lock path {}", file.local_path); + } + if !source_lock.sources.contains_key(&file.source) { + bail!( + "E2B upstream lock file {} names unknown source {}", + file.local_path, + file.source + ); + } + if file.source_path.trim().is_empty() { + bail!( + "E2B upstream lock file {} has an empty source path", + file.local_path + ); + } + let bytes = std::fs::read(paths.root.join(&file.local_path)).with_context(|| { + format!( + "failed to read vendored E2B source {}", + paths.root.join(&file.local_path).display() + ) + })?; + let actual = sha256(&bytes); + if actual != file.sha256 { + bail!( + "vendored E2B source {} digest mismatch: expected {}, got {}", + file.local_path, + file.sha256, + actual + ); + } + } + for required in [ + CONTROL_OPENAPI, + ENVD_OPENAPI, + VOLUME_OPENAPI, + PROCESS_PROTO, + FILESYSTEM_PROTO, + MCP_SCHEMA, + ] { + if !locked_paths.contains(required) { + bail!("E2B upstream lock is missing required contract {required}"); + } + } + Ok(()) +} + +fn validate_relative_path(path: &str) -> Result<()> { + let path = Path::new(path); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + bail!( + "E2B upstream lock contains unsafe local path {}", + path.display() + ); + } + Ok(()) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 71 + && value + .strip_prefix("sha256:") + .is_some_and(|digest| digest.bytes().all(|byte| byte.is_ascii_hexdigit())) +} + +fn build_manifest( + source_lock: &SourceLock, + inventory: &ContractInventory, + contract_inventory_bytes: &[u8], + public_export_bytes: &[u8], +) -> Result { + let e2b = source_lock + .sources + .get("e2b") + .context("E2B source disappeared after lock validation")?; + let interpreter = source_lock + .sources + .get("code-interpreter") + .context("code-interpreter source disappeared after lock validation")?; + let file_digests = source_lock + .files + .iter() + .map(|file| (file.local_path.as_str(), file.sha256.clone())) + .collect::>(); + let proto_digests = inventory + .protobuf + .iter() + .map(|file| (file.path.as_str(), file.descriptor_digest.clone())) + .collect::>(); + + Ok(CompatibilityManifest { + schema_version: 1, + compatibility_id: source_lock.compatibility.id.clone(), + status: "contract-fixture".to_string(), + full_compatibility: false, + e2b_git_commit: e2b.commit.clone(), + code_interpreter_git_commit: interpreter.commit.clone(), + python_e2b_version: package(e2b, "python")?, + typescript_e2b_version: package(e2b, "typescript")?, + python_code_interpreter_version: package(interpreter, "python")?, + typescript_code_interpreter_version: package(interpreter, "typescript")?, + control_openapi_digest: locked_digest(&file_digests, CONTROL_OPENAPI)?, + envd_openapi_digest: locked_digest(&file_digests, ENVD_OPENAPI)?, + volume_content_openapi_digest: locked_digest(&file_digests, VOLUME_OPENAPI)?, + process_descriptor_digest: descriptor_digest(&proto_digests, "process/process.proto")?, + filesystem_descriptor_digest: descriptor_digest( + &proto_digests, + "filesystem/filesystem.proto", + )?, + mcp_schema_digest: locked_digest(&file_digests, MCP_SCHEMA)?, + contract_inventory_digest: sha256(contract_inventory_bytes), + public_export_inventory_digest: sha256(public_export_bytes), + client_artifact_digests: source_lock + .artifacts + .iter() + .map(|artifact| (artifact.id.clone(), artifact.sha256.clone())) + .collect(), + a3s_compat_version: source_lock.compatibility.version.clone(), + }) +} + +fn package(source: &crate::model::UpstreamSource, language: &str) -> Result { + source + .packages + .get(language) + .cloned() + .with_context(|| format!("validated source lost {language} package version")) +} + +fn locked_digest(digests: &BTreeMap<&str, String>, path: &str) -> Result { + digests + .get(path) + .cloned() + .with_context(|| format!("validated E2B upstream lock lost contract {path}")) +} + +fn descriptor_digest(digests: &BTreeMap<&str, String>, path: &str) -> Result { + digests + .get(path) + .cloned() + .with_context(|| format!("generated Protobuf inventory lost descriptor {path}")) +} + +fn pretty_json(value: &impl Serialize) -> Result> { + let mut bytes = serde_json::to_vec_pretty(value).context("failed to serialize fixture JSON")?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn write_generated(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create generated fixture directory {}", + parent.display() + ) + })?; + } + std::fs::write(path, bytes) + .with_context(|| format!("failed to write generated fixture {}", path.display())) +} + +fn verify_generated(path: &Path, expected: &[u8], description: &str) -> Result<()> { + let actual = std::fs::read(path).with_context(|| { + format!( + "missing generated E2B {description} {}; run a3s-box-e2b-contract generate", + path.display() + ) + })?; + if actual != expected { + bail!( + "generated E2B {description} is stale at {}; run a3s-box-e2b-contract generate", + path.display() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_in_e2b_fixture_is_current() { + verify_fixture(&FixturePaths::repository_default()) + .expect("checked-in E2B compatibility fixture should be current"); + } + + #[test] + fn pinned_fixture_covers_protocol_and_cross_language_entrypoints() { + let paths = FixturePaths::repository_default(); + let contracts: serde_json::Value = serde_json::from_slice( + &std::fs::read(paths.contract_inventory()).expect("read contract inventory"), + ) + .expect("parse contract inventory"); + let openapi = contracts["openapi"].as_array().expect("OpenAPI contracts"); + let control = openapi + .iter() + .find(|contract| contract["name"] == "control-plane") + .expect("control-plane contract"); + let operations = control["operations"].as_array().expect("HTTP operations"); + assert!(operations.iter().any(|operation| { + operation["method"] == "POST" && operation["path"] == "/sandboxes" + })); + assert!(operations.iter().any(|operation| { + operation["method"] == "DELETE" && operation["path"] == "/sandboxes/{sandboxID}" + })); + assert!(!operations.iter().any(|operation| { + operation["path"] + .as_str() + .is_some_and(|path| path.starts_with("/admin") || path.starts_with("/nodes")) + })); + let envd = openapi + .iter() + .find(|contract| contract["name"] == "envd") + .expect("envd contract"); + assert!(envd["authentication_headers"] + .as_array() + .expect("envd authentication headers") + .iter() + .any(|header| header == "X-Access-Token")); + + let protobuf = contracts["protobuf"] + .as_array() + .expect("Protobuf contracts"); + let process = protobuf + .iter() + .find(|contract| contract["path"] == "process/process.proto") + .expect("Process contract"); + let process_methods = process["services"][0]["methods"] + .as_array() + .expect("Process methods"); + assert!(process_methods + .iter() + .any(|method| method["name"] == "Start" && method["server_streaming"] == true)); + assert!(process_methods.iter().any(|method| { + method["name"] == "StreamInput" && method["client_streaming"] == true + })); + + let exports: serde_json::Value = serde_json::from_slice( + &std::fs::read(paths.public_exports()).expect("read public export inventory"), + ) + .expect("parse public export inventory"); + let packages = &exports["packages"]; + assert!(has_symbol(&packages["python-e2b"]["symbols"], "Sandbox")); + assert!(has_symbol( + &packages["python-e2b"]["symbols"], + "AsyncSandbox" + )); + assert!(has_symbol( + &packages["typescript-e2b"]["symbols"], + "Sandbox" + )); + assert!(has_symbol( + &packages["typescript-e2b"]["type_only_symbols"], + "SandboxInfo" + )); + assert!(has_symbol( + &packages["python-code-interpreter"]["symbols"], + "Execution" + )); + assert!(has_symbol( + &packages["typescript-code-interpreter"]["type_only_symbols"], + "Execution" + )); + } + + fn has_symbol(symbols: &serde_json::Value, expected: &str) -> bool { + symbols + .as_array() + .is_some_and(|symbols| symbols.iter().any(|symbol| symbol == expected)) + } + + #[test] + fn rejects_parent_directory_in_locked_path() { + assert!(validate_relative_path("../secret").is_err()); + assert!(validate_relative_path("/absolute").is_err()); + assert!(validate_relative_path("spec/e2b/openapi.yml").is_ok()); + } +} diff --git a/src/compat/src/gateway/mod.rs b/src/compat/src/gateway/mod.rs new file mode 100644 index 00000000..f7294064 --- /dev/null +++ b/src/compat/src/gateway/mod.rs @@ -0,0 +1,251 @@ +mod proxy; +mod tls; + +use std::net::SocketAddr; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use a3s_box_core::{ExecutionManager, ExecutionPortConnector, ExecutionSessionManager}; +use hyper::server::conn::Http; +use hyper::service::service_fn; +use rustls::ServerConfig; +use thiserror::Error; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{watch, OwnedSemaphorePermit, Semaphore}; +use tokio::task::JoinSet; +use tokio::time; +use tokio_rustls::TlsAcceptor; +use tracing::{debug, warn}; + +use crate::routing::{RouteLeaseService, SandboxRouteParser}; + +pub use proxy::DataPlaneProxy; + +// httpcore starts HTTP/2 connections with a single stream and only raises the +// limit after receiving an explicit SETTINGS_MAX_CONCURRENT_STREAMS value. +// Advertising the limit keeps long-lived Connect streams from blocking unary +// requests made through the official E2B Python SDK. +const HTTP2_MAX_CONCURRENT_STREAMS: u32 = 128; + +/// Startup-validated TLS listener and bounded proxy settings. +#[derive(Debug, Clone)] +pub struct DataPlaneGatewayConfig { + pub(crate) listen: SocketAddr, + pub(crate) certificate_path: PathBuf, + pub(crate) private_key_path: PathBuf, + pub(crate) max_connections: NonZeroUsize, + pub(crate) handshake_timeout: Duration, + pub(crate) connect_timeout: Duration, + pub(crate) drain_timeout: Duration, +} + +impl DataPlaneGatewayConfig { + pub const fn listen(&self) -> SocketAddr { + self.listen + } + + pub const fn max_connections(&self) -> NonZeroUsize { + self.max_connections + } + + pub const fn handshake_timeout(&self) -> Duration { + self.handshake_timeout + } + + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } + + pub const fn drain_timeout(&self) -> Duration { + self.drain_timeout + } +} + +#[derive(Clone)] +pub struct DataPlaneGateway { + config: DataPlaneGatewayConfig, + tls: Arc, + proxy: DataPlaneProxy, +} + +impl DataPlaneGateway { + pub async fn build( + config: DataPlaneGatewayConfig, + parser: SandboxRouteParser, + leases: RouteLeaseService, + executions: Arc, + sessions: Arc, + connector: Arc, + ) -> DataPlaneGatewayResult { + let tls = + tls::load_server_config(&config.certificate_path, &config.private_key_path).await?; + let proxy = DataPlaneProxy::new( + parser, + leases, + executions, + sessions, + connector, + config.connect_timeout, + ); + Ok(Self { config, tls, proxy }) + } + + pub const fn listen(&self) -> SocketAddr { + self.config.listen + } + + pub fn proxy(&self) -> DataPlaneProxy { + self.proxy.clone() + } + + pub async fn serve( + self, + listener: TcpListener, + mut shutdown: watch::Receiver, + ) -> DataPlaneGatewayResult<()> { + let semaphore = Arc::new(Semaphore::new(self.config.max_connections.get())); + let acceptor = TlsAcceptor::from(self.tls.clone()); + let mut connections = JoinSet::new(); + + loop { + let permit = tokio::select! { + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { + break; + } + continue; + } + permit = semaphore.clone().acquire_owned() => { + permit.map_err(|_| DataPlaneGatewayError::ConnectionLimiterClosed)? + } + }; + let accepted = tokio::select! { + changed = shutdown.changed() => { + drop(permit); + if changed.is_err() || *shutdown.borrow() { + break; + } + continue; + } + accepted = listener.accept() => accepted, + }; + let (socket, peer) = accepted.map_err(DataPlaneGatewayError::Accept)?; + let acceptor = acceptor.clone(); + let proxy = self.proxy.clone(); + let connection_shutdown = shutdown.clone(); + let handshake_timeout = self.config.handshake_timeout; + connections.spawn(async move { + serve_connection( + socket, + peer, + acceptor, + proxy, + handshake_timeout, + connection_shutdown, + permit, + ) + .await; + }); + + while connections.try_join_next().is_some() {} + } + + drain_connections(&mut connections, self.config.drain_timeout).await; + Ok(()) + } +} + +#[allow(clippy::too_many_arguments)] +async fn serve_connection( + socket: TcpStream, + peer: SocketAddr, + acceptor: TlsAcceptor, + proxy: DataPlaneProxy, + handshake_timeout: Duration, + mut shutdown: watch::Receiver, + _permit: OwnedSemaphorePermit, +) { + let tls = match time::timeout(handshake_timeout, acceptor.accept(socket)).await { + Ok(Ok(tls)) => tls, + Ok(Err(error)) => { + debug!(%peer, %error, "sandbox data-plane TLS handshake rejected"); + return; + } + Err(_) => { + debug!(%peer, "sandbox data-plane TLS handshake timed out"); + return; + } + }; + let service = service_fn(move |request| { + let proxy = proxy.clone(); + async move { Ok::<_, std::convert::Infallible>(proxy.handle(request).await) } + }); + let mut http = Http::new(); + http.http1_keep_alive(true) + .http1_half_close(true) + .http2_adaptive_window(true) + .http2_max_concurrent_streams(HTTP2_MAX_CONCURRENT_STREAMS); + let connection = http.serve_connection(tls, service).with_upgrades(); + tokio::pin!(connection); + tokio::select! { + result = &mut connection => { + if let Err(error) = result { + debug!(%peer, %error, "sandbox data-plane connection closed with an HTTP error"); + } + } + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { + connection.as_mut().graceful_shutdown(); + if let Err(error) = connection.await { + debug!(%peer, %error, "sandbox data-plane connection failed while draining"); + } + } + } + } +} + +async fn drain_connections(connections: &mut JoinSet<()>, timeout: Duration) { + let drain = async { while connections.join_next().await.is_some() {} }; + if time::timeout(timeout, drain).await.is_err() { + let remaining = connections.len(); + warn!( + remaining, + "aborting sandbox data-plane connections after drain timeout" + ); + connections.abort_all(); + while connections.join_next().await.is_some() {} + } +} + +#[derive(Debug, Error)] +pub enum DataPlaneGatewayError { + #[error("failed to read TLS certificate {path}: {source}")] + ReadCertificate { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to read TLS private key {path}: {source}")] + ReadPrivateKey { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("TLS certificate file contains no certificates: {0}")] + MissingCertificate(PathBuf), + #[error("TLS private key file contains no supported private key: {0}")] + MissingPrivateKey(PathBuf), + #[error("invalid TLS certificate or private key: {0}")] + InvalidTls(#[source] rustls::Error), + #[error("failed to accept a sandbox data-plane connection: {0}")] + Accept(#[source] std::io::Error), + #[error("sandbox data-plane connection limiter closed unexpectedly")] + ConnectionLimiterClosed, +} + +pub type DataPlaneGatewayResult = std::result::Result; + +#[cfg(test)] +mod tests; diff --git a/src/compat/src/gateway/proxy.rs b/src/compat/src/gateway/proxy.rs new file mode 100644 index 00000000..0e7c034c --- /dev/null +++ b/src/compat/src/gateway/proxy.rs @@ -0,0 +1,475 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use a3s_box_core::{ + ExecutionManager, ExecutionManagerError, ExecutionPortConnector, ExecutionPortStream, + ExecutionSessionManager, +}; +use axum::body::Body; +use axum::http::header::{ + ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, + ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE, CONNECTION, CONTENT_TYPE, HOST, ORIGIN, + TE, TRAILER, TRANSFER_ENCODING, UPGRADE, +}; +use axum::http::{ + HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Uri, Version, +}; +use hyper::client::conn; +use thiserror::Error; +use tokio::io::copy_bidirectional; +use tracing::debug; + +use crate::control::EnvdMode; +use crate::envd::EnvdBroker; +use crate::routing::{ + EnvdHealthResolution, ParsedSandboxRoute, RouteLeaseError, RouteLeaseService, RouteParseError, + SandboxRouteParser, ENVD_ACCESS_TOKEN_HEADER, ENVD_PORT, SANDBOX_ID_HEADER, + SANDBOX_PORT_HEADER, TRAFFIC_ACCESS_TOKEN_HEADER, +}; + +const ACCESS_CONTROL_REQUEST_METHOD: &str = "access-control-request-method"; +const ACCESS_CONTROL_REQUEST_HEADERS: &str = "access-control-request-headers"; +const FORWARDED: &str = "forwarded"; +const X_FORWARDED_FOR: &str = "x-forwarded-for"; +const X_FORWARDED_HOST: &str = "x-forwarded-host"; +const X_FORWARDED_PORT: &str = "x-forwarded-port"; +const X_FORWARDED_PROTO: &str = "x-forwarded-proto"; +const PROXY_CONNECTION: &str = "proxy-connection"; +const PROXY_AUTHENTICATE: &str = "proxy-authenticate"; +const PROXY_AUTHORIZATION: &str = "proxy-authorization"; +const KEEP_ALIVE: &str = "keep-alive"; +const EXPOSED_HEADERS: &str = + "Grpc-Status, Grpc-Message, Grpc-Status-Details-Bin, Connect-Content-Encoding, Trailer"; +static NEXT_PROXY_REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone)] +pub struct DataPlaneProxy { + parser: SandboxRouteParser, + leases: RouteLeaseService, + envd: EnvdBroker, + connector: Arc, + connect_timeout: Duration, +} + +impl DataPlaneProxy { + pub(crate) fn new( + parser: SandboxRouteParser, + leases: RouteLeaseService, + executions: Arc, + sessions: Arc, + connector: Arc, + connect_timeout: Duration, + ) -> Self { + Self { + parser, + leases, + envd: EnvdBroker::new(executions, sessions), + connector, + connect_timeout, + } + } + + pub async fn handle(&self, mut request: Request) -> Response { + let request_id = NEXT_PROXY_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + debug!( + request_id, + method = %request.method(), + path = request.uri().path(), + version = ?request.version(), + "sandbox data-plane request received" + ); + let cors = request.headers().contains_key(ORIGIN); + let route = match self.parser.parse_uri(request.uri(), request.headers()) { + Ok(route) => route, + Err(error) => return with_cors(error_response(ProxyFailure::Route(error)), cors), + }; + if is_cors_preflight(&request) { + return preflight_response(request.headers()); + } + + if is_envd_health(&request, &route) { + let resolution = match self + .leases + .resolve_envd_health(&route, request.headers()) + .await + { + Ok(resolution) => resolution, + Err(error) => return with_cors(error_response(ProxyFailure::Lease(error)), cors), + }; + let response = match resolution { + EnvdHealthResolution::Running(lease) => { + if lease.envd_mode() == EnvdMode::Runtime { + return with_cors( + self.proxy_runtime(&mut request, &lease, request_id).await, + cors, + ); + } + self.envd.handle(request, &lease).await + } + EnvdHealthResolution::Inactive => self.envd.inactive_health(), + }; + return with_cors(response, cors); + } + + let lease = match self.leases.resolve(&route, request.headers()).await { + Ok(lease) => lease, + Err(error) => return with_cors(error_response(ProxyFailure::Lease(error)), cors), + }; + if lease.port().get() == ENVD_PORT && lease.envd_mode() == EnvdMode::Broker { + return with_cors(self.envd.handle(request, &lease).await, cors); + } + with_cors( + self.proxy_runtime(&mut request, &lease, request_id).await, + cors, + ) + } + + async fn proxy_runtime( + &self, + request: &mut Request, + lease: &crate::routing::RouteLease, + request_id: u64, + ) -> Response { + debug!( + request_id, + execution_id = %lease.execution_id(), + execution_generation = lease.execution_generation().get(), + port = lease.port().get(), + "sandbox data-plane route resolved" + ); + let stream = match self + .connector + .connect_port( + lease.execution_id(), + lease.execution_generation(), + lease.port(), + self.connect_timeout, + ) + .await + { + Ok(stream) => { + debug!(request_id, "sandbox data-plane upstream connected"); + stream + } + Err(error) => return error_response(ProxyFailure::Connect(error)), + }; + + match proxy_upstream(request, stream).await { + Ok(response) => { + debug!( + request_id, + status = %response.status(), + "sandbox data-plane upstream response headers received" + ); + response + } + Err(error) => { + debug!(request_id, %error, "sandbox data-plane upstream request failed"); + error_response(error) + } + } + } +} + +fn is_envd_health(request: &Request, route: &ParsedSandboxRoute) -> bool { + route.port.get() == ENVD_PORT + && request.method() == Method::GET + && request.uri().path() == "/health" +} + +async fn proxy_upstream( + request: &mut Request, + stream: ExecutionPortStream, +) -> Result, ProxyFailure> { + let downstream_version = request.version(); + let upgrade = is_upgrade(request.headers(), downstream_version); + let downstream_upgrade = upgrade.then(|| hyper::upgrade::on(&mut *request)); + normalize_http1_upstream(request)?; + sanitize_request_headers(request.headers_mut(), downstream_version, upgrade); + // Downstream ALPN does not describe the plaintext Sandbox origin. E2B's + // data plane translates both HTTP/1.1 and HTTP/2 clients to HTTP/1.1 here. + *request.version_mut() = Version::HTTP_11; + + let (mut sender, connection) = conn::Builder::new().handshake(stream).await?; + tokio::spawn(async move { + if let Err(error) = connection.await { + debug!(%error, "sandbox data-plane upstream connection closed"); + } + }); + let outbound = std::mem::replace(request, Request::new(Body::empty())); + let mut response = sender.send_request(outbound).await?; + let switched_protocols = response.status() == StatusCode::SWITCHING_PROTOCOLS && upgrade; + sanitize_response_headers( + response.headers_mut(), + downstream_version, + switched_protocols, + ); + + if let Some(downstream_upgrade) = downstream_upgrade.filter(|_| switched_protocols) { + let upstream_upgrade = hyper::upgrade::on(&mut response); + tokio::spawn(async move { + let (mut downstream, mut upstream) = + match tokio::try_join!(downstream_upgrade, upstream_upgrade) { + Ok(upgrades) => upgrades, + Err(error) => { + debug!(%error, "sandbox data-plane protocol upgrade failed"); + return; + } + }; + if let Err(error) = copy_bidirectional(&mut downstream, &mut upstream).await { + debug!(%error, "sandbox data-plane upgraded stream closed"); + } + }); + } + Ok(response) +} + +fn normalize_http1_upstream(request: &mut Request) -> Result<(), ProxyFailure> { + let authority = request + .uri() + .authority() + .map(|value| value.as_str().to_string()); + let path_and_query = request + .uri() + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/") + .to_string(); + if !request.headers().contains_key(HOST) { + let authority = authority.ok_or(ProxyFailure::InvalidUpstreamUri)?; + let host = + HeaderValue::from_str(&authority).map_err(|_| ProxyFailure::InvalidUpstreamUri)?; + request.headers_mut().insert(HOST, host); + } + *request.uri_mut() = path_and_query + .parse::() + .map_err(|_| ProxyFailure::InvalidUpstreamUri)?; + Ok(()) +} + +fn sanitize_request_headers(headers: &mut HeaderMap, version: Version, upgrade: bool) { + for name in [ + ENVD_ACCESS_TOKEN_HEADER, + TRAFFIC_ACCESS_TOKEN_HEADER, + SANDBOX_ID_HEADER, + SANDBOX_PORT_HEADER, + FORWARDED, + X_FORWARDED_FOR, + X_FORWARDED_HOST, + X_FORWARDED_PORT, + X_FORWARDED_PROTO, + ] { + headers.remove(name); + } + let original_host = headers.get(HOST).cloned(); + strip_hop_headers(headers, version, upgrade); + headers.insert(X_FORWARDED_PROTO, HeaderValue::from_static("https")); + if let Some(host) = original_host { + headers.insert(X_FORWARDED_HOST, host); + } +} + +fn sanitize_response_headers(headers: &mut HeaderMap, version: Version, upgrade: bool) { + strip_hop_headers(headers, version, upgrade); +} + +fn strip_hop_headers(headers: &mut HeaderMap, version: Version, upgrade: bool) { + let nominated = connection_header_names(headers); + for name in nominated { + if !upgrade || (name != UPGRADE && name != CONNECTION) { + headers.remove(name); + } + } + for name in [ + HeaderName::from_static(PROXY_CONNECTION), + HeaderName::from_static(PROXY_AUTHENTICATE), + HeaderName::from_static(PROXY_AUTHORIZATION), + HeaderName::from_static(KEEP_ALIVE), + TRANSFER_ENCODING, + ] { + headers.remove(name); + } + if !upgrade || version == Version::HTTP_2 { + headers.remove(CONNECTION); + headers.remove(UPGRADE); + } + if version == Version::HTTP_2 && headers.get(TE) != Some(&HeaderValue::from_static("trailers")) + { + headers.remove(TE); + } + if version == Version::HTTP_2 { + // HTTP/2 carries trailers without the HTTP/1.1 Trailer declaration. + headers.remove(TRAILER); + } +} + +fn connection_header_names(headers: &HeaderMap) -> Vec { + headers + .get_all(CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|value| value.trim().parse::().ok()) + .collect() +} + +fn is_upgrade(headers: &HeaderMap, version: Version) -> bool { + version != Version::HTTP_2 + && headers.contains_key(UPGRADE) + && headers + .get_all(CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|value| value.trim().eq_ignore_ascii_case("upgrade")) +} + +fn is_cors_preflight(request: &Request) -> bool { + request.method() == Method::OPTIONS + && request.headers().contains_key(ORIGIN) + && request + .headers() + .contains_key(ACCESS_CONTROL_REQUEST_METHOD) +} + +fn preflight_response(request_headers: &HeaderMap) -> Response { + let method = request_headers + .get(ACCESS_CONTROL_REQUEST_METHOD) + .cloned() + .unwrap_or_else(|| HeaderValue::from_static("GET, POST, PUT, PATCH, DELETE, OPTIONS")); + let headers = request_headers + .get(ACCESS_CONTROL_REQUEST_HEADERS) + .cloned() + .unwrap_or_else(|| { + HeaderValue::from_static( + "Authorization, Content-Type, X-Access-Token, E2B-Traffic-Access-Token, E2b-Sandbox-Id, E2b-Sandbox-Port, Connect-Protocol-Version, Connect-Timeout-Ms", + ) + }); + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NO_CONTENT; + response + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); + response + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_METHODS, method); + response + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_HEADERS, headers); + response + .headers_mut() + .insert(ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("600")); + response +} + +fn with_cors(mut response: Response, enabled: bool) -> Response { + if enabled { + response + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*")); + response.headers_mut().insert( + ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_static(EXPOSED_HEADERS), + ); + } + response +} + +fn error_response(error: ProxyFailure) -> Response { + let (status, code, message) = error.public_error(); + let body = serde_json::json!({ "code": code, "message": message }).to_string(); + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + response +} + +#[derive(Debug, Error)] +enum ProxyFailure { + #[error(transparent)] + Route(#[from] RouteParseError), + #[error(transparent)] + Lease(#[from] RouteLeaseError), + #[error(transparent)] + Connect(#[from] ExecutionManagerError), + #[error("sandbox upstream URI is invalid")] + InvalidUpstreamUri, + #[error("sandbox upstream HTTP transport failed: {0}")] + Upstream(#[from] hyper::Error), +} + +impl ProxyFailure { + fn public_error(&self) -> (StatusCode, &'static str, &'static str) { + match self { + Self::Route(RouteParseError::UnsupportedHost) => ( + StatusCode::NOT_FOUND, + "ROUTE_NOT_FOUND", + "Sandbox route not found", + ), + Self::Route(_) => ( + StatusCode::BAD_REQUEST, + "INVALID_ROUTE", + "Sandbox route is invalid", + ), + Self::Lease( + RouteLeaseError::MissingToken + | RouteLeaseError::InvalidToken + | RouteLeaseError::Unauthorized, + ) => ( + StatusCode::UNAUTHORIZED, + "UNAUTHORIZED", + "Sandbox access token is invalid", + ), + Self::Lease( + RouteLeaseError::NotFound + | RouteLeaseError::Inactive + | RouteLeaseError::Expired + | RouteLeaseError::PortDenied, + ) => ( + StatusCode::NOT_FOUND, + "ROUTE_NOT_FOUND", + "Sandbox route not found", + ), + Self::Lease(RouteLeaseError::InvalidRecord) => ( + StatusCode::BAD_GATEWAY, + "INVALID_SANDBOX_STATE", + "Sandbox runtime state is invalid", + ), + Self::Lease(RouteLeaseError::Repository(_) | RouteLeaseError::Token(_)) => ( + StatusCode::SERVICE_UNAVAILABLE, + "ROUTE_UNAVAILABLE", + "Sandbox route is temporarily unavailable", + ), + Self::Connect(ExecutionManagerError::InvalidRequest(_)) => ( + StatusCode::BAD_REQUEST, + "INVALID_UPSTREAM", + "Sandbox upstream request is invalid", + ), + Self::Connect(ExecutionManagerError::NotFound(_)) => ( + StatusCode::NOT_FOUND, + "RUNTIME_NOT_FOUND", + "Sandbox runtime not found", + ), + Self::Connect(ExecutionManagerError::Conflict { .. }) => ( + StatusCode::CONFLICT, + "STALE_RUNTIME", + "Sandbox runtime generation changed", + ), + Self::Connect(ExecutionManagerError::Unavailable(_)) => ( + StatusCode::SERVICE_UNAVAILABLE, + "UPSTREAM_UNAVAILABLE", + "Sandbox upstream is unavailable", + ), + Self::Connect(ExecutionManagerError::Internal(_)) + | Self::InvalidUpstreamUri + | Self::Upstream(_) => ( + StatusCode::BAD_GATEWAY, + "UPSTREAM_FAILURE", + "Sandbox upstream request failed", + ), + } + } +} diff --git a/src/compat/src/gateway/tests.rs b/src/compat/src/gateway/tests.rs new file mode 100644 index 00000000..6d0efa42 --- /dev/null +++ b/src/compat/src/gateway/tests.rs @@ -0,0 +1,818 @@ +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::num::{NonZeroU16, NonZeroUsize}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use a3s_box_core::pty::PtyRequest; +use a3s_box_core::{ + resolve_execution, BoxConfig, ExecOutput, ExecRequest, ExecutionGeneration, ExecutionId, + ExecutionIsolation, ExecutionLease, ExecutionManager, ExecutionManagerError, + ExecutionManagerResult, ExecutionPortConnector, ExecutionPortStream, ExecutionProcess, + ExecutionSessionManager, ExecutionState, ExecutionStatus, FileRequest, FileResponse, + KillOutcome, OperationId, ReconcileOutcome, +}; +use async_trait::async_trait; +use axum::body::Body; +use axum::http::header::{HOST, ORIGIN}; +use axum::http::{HeaderValue, Method, Request, Response, StatusCode, Version}; +use chrono::{DateTime, TimeZone, Utc}; +use hyper::body::{to_bytes, Bytes, HttpBody}; +use hyper::service::service_fn; +use rustls::pki_types::ServerName; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::control::{ + Clock, EnvdMode, LifecyclePolicy, MemorySandboxRepository, NewSandboxRecord, OnTimeoutAction, + RotatingTokenProvider, SandboxCredentials, SandboxId, SandboxRecord, SandboxRepository, + SecretToken, TokenIssuer, TokenKeyMaterial, TokenScope, +}; +use crate::routing::{ + RouteLeaseService, SandboxDomain, SandboxRouteParser, SandboxRoutePolicy, + CODE_INTERPRETER_PORT, ENVD_ACCESS_TOKEN_HEADER, ENVD_PORT, SANDBOX_ID_HEADER, + SANDBOX_PORT_HEADER, TRAFFIC_ACCESS_TOKEN_HEADER, +}; + +use super::{ + DataPlaneGateway, DataPlaneGatewayConfig, DataPlaneProxy, HTTP2_MAX_CONCURRENT_STREAMS, +}; + +struct FixedClock(DateTime); + +impl Clock for FixedClock { + fn now(&self) -> DateTime { + self.0 + } +} + +#[derive(Clone)] +struct TcpConnector { + address: SocketAddr, + calls: Arc>>, +} + +#[async_trait] +impl ExecutionPortConnector for TcpConnector { + async fn connect_port( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + port: NonZeroU16, + _timeout: Duration, + ) -> ExecutionManagerResult { + self.calls + .lock() + .unwrap() + .push((execution_id.to_string(), generation.get(), port.get())); + let stream = TcpStream::connect(self.address) + .await + .map_err(|error| ExecutionManagerError::Unavailable(error.to_string()))?; + Ok(Box::pin(stream)) + } +} + +struct RunningExecutionManager { + status: ExecutionStatus, +} + +#[async_trait] +impl ExecutionManager for RunningExecutionManager { + async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult { + if execution_id != &self.status.execution_id { + return Err(ExecutionManagerError::NotFound(execution_id.clone())); + } + Ok(self.status.clone()) + } + + async fn pause( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _keep_memory: bool, + ) -> ExecutionManagerResult { + Err(unsupported_manager_operation()) + } + + async fn resume( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + Err(unsupported_manager_operation()) + } + + async fn kill( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + Err(unsupported_manager_operation()) + } + + async fn reconcile( + &self, + _operation_id: &OperationId, + ) -> ExecutionManagerResult { + Err(unsupported_manager_operation()) + } +} + +#[async_trait] +impl ExecutionSessionManager for RunningExecutionManager { + async fn execute( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: ExecRequest, + ) -> ExecutionManagerResult { + Err(unsupported_manager_operation()) + } + + async fn start_process( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: ExecRequest, + ) -> ExecutionManagerResult { + Err(unsupported_manager_operation()) + } + + async fn start_pty( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: PtyRequest, + ) -> ExecutionManagerResult { + Err(unsupported_manager_operation()) + } + + async fn transfer_file( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _request: FileRequest, + ) -> ExecutionManagerResult { + Err(unsupported_manager_operation()) + } +} + +fn unsupported_manager_operation() -> ExecutionManagerError { + ExecutionManagerError::Unavailable("unsupported test manager operation".to_string()) +} + +struct Harness { + proxy: DataPlaneProxy, + parser: SandboxRouteParser, + leases: RouteLeaseService, + repository: Arc, + executions: Arc, + connector: Arc, + sandbox_id: SandboxId, + envd_token: SecretToken, + traffic_token: SecretToken, + upstream: tokio::task::JoinHandle<()>, +} + +impl Harness { + async fn new() -> Self { + Self::new_with_envd_mode(EnvdMode::Broker).await + } + + async fn new_with_envd_mode(envd_mode: EnvdMode) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let upstream = tokio::spawn(async move { + loop { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + tokio::spawn(async move { + let service = service_fn(upstream_response); + let mut http = hyper::server::conn::Http::new(); + http.http1_only(true); + let _ = http.serve_connection(socket, service).with_upgrades().await; + }); + } + }); + + let now = Utc + .with_ymd_and_hms(2026, 7, 15, 16, 0, 0) + .single() + .unwrap(); + let tokens = Arc::new( + RotatingTokenProvider::new(1, [TokenKeyMaterial::new(1, &[7; 32], &[8; 32]).unwrap()]) + .unwrap(), + ); + let envd = tokens.issue(TokenScope::Envd).await.unwrap(); + let traffic = tokens.issue(TokenScope::Traffic).await.unwrap(); + let sandbox_id = SandboxId::new("sandbox-gateway-1").unwrap(); + let mut config = BoxConfig { + isolation: ExecutionIsolation::Sandbox, + image: "alpine:3.20".to_string(), + ..BoxConfig::default() + }; + config.network = a3s_box_core::NetworkMode::None; + let plan = resolve_execution(&config).unwrap(); + let routing = SandboxRoutePolicy::default() + .with_port(CODE_INTERPRETER_PORT, TokenScope::Traffic) + .unwrap(); + let mut record = SandboxRecord::creating(NewSandboxRecord { + sandbox_id: sandbox_id.clone(), + operation_id: OperationId::new("operation-gateway-1").unwrap(), + owner_id: "owner-gateway".to_string(), + template_id: "gateway-template".to_string(), + plan: plan.clone(), + resources: config.resources.clone(), + lifecycle: LifecyclePolicy { + on_timeout: OnTimeoutAction::Kill, + auto_resume: false, + keep_memory_on_pause: false, + }, + created_at: now, + expires_at: now + chrono::Duration::minutes(5), + metadata: BTreeMap::new(), + envd_version: "0.1.3".to_string(), + envd_mode, + secure: true, + allow_internet_access: Some(false), + credentials: SandboxCredentials { + envd: envd.stored, + traffic: traffic.stored, + }, + routing, + }) + .unwrap(); + record + .mark_running(ExecutionLease { + execution_id: ExecutionId::new("execution-gateway-1").unwrap(), + generation: ExecutionGeneration::INITIAL, + plan: plan.clone(), + resources: config.resources, + started_at: now, + }) + .unwrap(); + let executions = Arc::new(RunningExecutionManager { + status: ExecutionStatus { + execution_id: ExecutionId::new("execution-gateway-1").unwrap(), + generation: ExecutionGeneration::INITIAL, + state: ExecutionState::Running, + plan: plan.clone(), + }, + }); + let repository = Arc::new(MemorySandboxRepository::default()); + repository.insert(record).await.unwrap(); + let leases = RouteLeaseService::new(repository.clone(), tokens, Arc::new(FixedClock(now))); + let connector = Arc::new(TcpConnector { + address, + calls: Arc::new(Mutex::new(Vec::new())), + }); + let parser = SandboxRouteParser::new(SandboxDomain::new("box.example.com").unwrap()); + let proxy = DataPlaneProxy::new( + parser.clone(), + leases.clone(), + executions.clone(), + executions.clone(), + connector.clone(), + Duration::from_secs(2), + ); + Self { + proxy, + parser, + leases, + repository, + executions, + connector, + sandbox_id, + envd_token: envd.secret, + traffic_token: traffic.secret, + upstream, + } + } + + fn direct_request( + &self, + port: u16, + token_header: &'static str, + token: &SecretToken, + ) -> Request { + Request::builder() + .method(Method::POST) + .uri("/echo?value=one") + .header(HOST, format!("{port}-{}.box.example.com", self.sandbox_id)) + .header(token_header, token.expose_secret()) + .header("x-forwarded-for", "203.0.113.5") + .body(Body::from("hello-data-plane")) + .unwrap() + } + + fn call_count(&self) -> usize { + self.connector.calls.lock().unwrap().len() + } + + fn health_request(&self, token: &SecretToken) -> Request { + Request::builder() + .uri("/health") + .header( + HOST, + format!("{}-{}.box.example.com", ENVD_PORT, self.sandbox_id), + ) + .header(ENVD_ACCESS_TOKEN_HEADER, token.expose_secret()) + .body(Body::empty()) + .unwrap() + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.upstream.abort(); + } +} + +async fn upstream_response( + request: Request, +) -> Result, std::convert::Infallible> { + let method = request.method().to_string(); + let uri = request.uri().to_string(); + let version = format!("{:?}", request.version()); + let headers = request.headers().clone(); + let body = to_bytes(request.into_body()).await.unwrap(); + let value = serde_json::json!({ + "method": method, + "uri": uri, + "version": version, + "host": headers.get(HOST).and_then(|value| value.to_str().ok()), + "body": String::from_utf8_lossy(&body), + "envdTokenForwarded": headers.contains_key(ENVD_ACCESS_TOKEN_HEADER), + "trafficTokenForwarded": headers.contains_key(TRAFFIC_ACCESS_TOKEN_HEADER), + "sandboxIdForwarded": headers.contains_key(SANDBOX_ID_HEADER), + "sandboxPortForwarded": headers.contains_key(SANDBOX_PORT_HEADER), + "forwardedProto": headers.get("x-forwarded-proto").and_then(|value| value.to_str().ok()), + "forwardedHost": headers.get("x-forwarded-host").and_then(|value| value.to_str().ok()), + "forwardedFor": headers.get("x-forwarded-for").and_then(|value| value.to_str().ok()), + }); + let mut response = Response::new(Body::from(value.to_string())); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + Ok(response) +} + +async fn concurrent_stream_upstream_response( + request: Request, + open_streams: Arc>>, +) -> Result, std::convert::Infallible> { + let path = request.uri().path().to_string(); + let _ = to_bytes(request.into_body()).await.unwrap(); + match path.as_str() { + "/process.Process/Start" => { + let (mut sender, body) = Body::channel(); + tokio::spawn(async move { + if sender + .send_data(Bytes::from_static(b"open-start-stream")) + .await + .is_ok() + { + open_streams.lock().unwrap().push(sender); + } + }); + Ok(Response::new(body)) + } + "/process.Process/List" => Ok(Response::new(Body::from("list-response"))), + _ => { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NOT_FOUND; + Ok(response) + } + } +} + +#[tokio::test] +async fn translates_downstream_http2_to_plaintext_http1_upstream() { + let harness = Harness::new().await; + let mut request = harness.direct_request( + CODE_INTERPRETER_PORT, + TRAFFIC_ACCESS_TOKEN_HEADER, + &harness.traffic_token, + ); + let authority = request.headers_mut().remove(HOST).unwrap(); + *request.uri_mut() = format!("https://{}/echo?value=one", authority.to_str().unwrap()) + .parse() + .unwrap(); + *request.version_mut() = Version::HTTP_2; + + let response = harness.proxy.handle(request).await; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body()).await.unwrap()).unwrap(); + assert_eq!(body["version"], "HTTP/1.1"); + assert_eq!(body["host"], authority.to_str().unwrap()); + assert_eq!(harness.call_count(), 1); +} + +#[tokio::test] +async fn authenticated_direct_route_proxies_stream_and_strips_edge_credentials() { + let harness = Harness::new().await; + let request = harness.direct_request( + CODE_INTERPRETER_PORT, + TRAFFIC_ACCESS_TOKEN_HEADER, + &harness.traffic_token, + ); + let response = harness.proxy.handle(request).await; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body()).await.unwrap()).unwrap(); + assert_eq!(body["method"], "POST"); + assert_eq!(body["uri"], "/echo?value=one"); + assert_eq!(body["body"], "hello-data-plane"); + assert_eq!(body["envdTokenForwarded"], false); + assert_eq!(body["trafficTokenForwarded"], false); + assert_eq!(body["sandboxIdForwarded"], false); + assert_eq!(body["sandboxPortForwarded"], false); + assert_eq!(body["forwardedProto"], "https"); + assert!(body["forwardedHost"] + .as_str() + .unwrap() + .starts_with("49999-sandbox-gateway-1")); + assert!(body["forwardedFor"].is_null()); + assert_eq!(harness.call_count(), 1); +} + +#[tokio::test] +async fn runtime_envd_routes_health_process_and_filesystem_to_the_sandbox() { + let harness = Harness::new_with_envd_mode(EnvdMode::Runtime).await; + + let health = harness + .proxy + .handle(harness.health_request(&harness.envd_token)) + .await; + assert_eq!(health.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(health.into_body()).await.unwrap()).unwrap(); + assert_eq!(body["uri"], "/health"); + assert_eq!(body["envdTokenForwarded"], false); + + for path in [ + "/process.Process/Start", + "/filesystem.Filesystem/MakeDir", + "/files?path=%2Ftmp%2Ffixture", + ] { + let request = Request::builder() + .method(Method::POST) + .uri(path) + .header( + HOST, + format!("{}-{}.box.example.com", ENVD_PORT, harness.sandbox_id), + ) + .header(ENVD_ACCESS_TOKEN_HEADER, harness.envd_token.expose_secret()) + .header(SANDBOX_ID_HEADER, harness.sandbox_id.as_str()) + .header(SANDBOX_PORT_HEADER, ENVD_PORT.to_string()) + .body(Body::from("runtime-envd")) + .unwrap(); + let response = harness.proxy.handle(request).await; + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body()).await.unwrap()).unwrap(); + assert_eq!(body["uri"], path); + assert_eq!(body["envdTokenForwarded"], false); + assert_eq!(body["sandboxIdForwarded"], false); + assert_eq!(body["sandboxPortForwarded"], false); + } + + let calls = harness.connector.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 4); + assert!(calls.iter().all(|(_, _, port)| *port == ENVD_PORT)); +} + +#[tokio::test] +async fn token_scope_is_checked_before_opening_an_upstream_connection() { + let harness = Harness::new().await; + let swapped = harness.direct_request( + CODE_INTERPRETER_PORT, + TRAFFIC_ACCESS_TOKEN_HEADER, + &harness.envd_token, + ); + assert_eq!( + harness.proxy.handle(swapped).await.status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!(harness.call_count(), 0); + + let valid = harness.direct_request( + CODE_INTERPRETER_PORT, + TRAFFIC_ACCESS_TOKEN_HEADER, + &harness.traffic_token, + ); + assert_eq!(harness.proxy.handle(valid).await.status(), StatusCode::OK); + assert_eq!(harness.call_count(), 1); +} + +#[tokio::test] +async fn shared_routes_and_browser_preflight_use_the_same_validated_parser() { + let harness = Harness::new().await; + let preflight = Request::builder() + .method(Method::OPTIONS) + .uri("/health") + .header(HOST, "sandbox.box.example.com") + .header(SANDBOX_ID_HEADER, harness.sandbox_id.as_str()) + .header(SANDBOX_PORT_HEADER, ENVD_PORT.to_string()) + .header(ORIGIN, "https://app.example.com") + .header("access-control-request-method", "GET") + .header("access-control-request-headers", "X-Access-Token") + .body(Body::empty()) + .unwrap(); + let response = harness.proxy.handle(preflight).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); + assert_eq!(harness.call_count(), 0); + + let shared = Request::builder() + .uri("/health") + .header(HOST, "sandbox.box.example.com") + .header(SANDBOX_ID_HEADER, harness.sandbox_id.as_str()) + .header(SANDBOX_PORT_HEADER, ENVD_PORT.to_string()) + .header(ENVD_ACCESS_TOKEN_HEADER, harness.envd_token.expose_secret()) + .body(Body::empty()) + .unwrap(); + assert_eq!( + harness.proxy.handle(shared).await.status(), + StatusCode::NO_CONTENT + ); + assert_eq!(harness.call_count(), 0); +} + +#[tokio::test] +async fn authenticated_terminal_health_returns_false_without_reopening_traffic_routes() { + let harness = Harness::new_with_envd_mode(EnvdMode::Runtime).await; + let mut record = harness + .repository + .get(&harness.sandbox_id) + .await + .unwrap() + .unwrap(); + let expected = record.generation(); + record.begin_kill().unwrap(); + harness + .repository + .compare_and_swap(&harness.sandbox_id, expected, record.clone()) + .await + .unwrap(); + let expected = record.generation(); + record.mark_killed().unwrap(); + harness + .repository + .compare_and_swap(&harness.sandbox_id, expected, record) + .await + .unwrap(); + + let inactive = harness + .proxy + .handle(harness.health_request(&harness.envd_token)) + .await; + assert_eq!(inactive.status(), StatusCode::BAD_GATEWAY); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(inactive.into_body()).await.unwrap()).unwrap(); + assert_eq!(body["code"], "SANDBOX_NOT_RUNNING"); + + let unauthorized = harness + .proxy + .handle(harness.health_request(&harness.traffic_token)) + .await; + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let traffic = harness.direct_request( + CODE_INTERPRETER_PORT, + TRAFFIC_ACCESS_TOKEN_HEADER, + &harness.traffic_token, + ); + assert_eq!( + harness.proxy.handle(traffic).await.status(), + StatusCode::NOT_FOUND + ); + assert_eq!(harness.call_count(), 0); +} + +#[tokio::test] +async fn wildcard_tls_listener_serves_an_authenticated_route() { + let harness = Harness::new().await; + let temporary = tempfile::tempdir().unwrap(); + let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(vec![ + "*.box.example.com".to_string(), + "sandbox.box.example.com".to_string(), + ]) + .unwrap(); + let certificate_path = temporary.path().join("certificate.pem"); + let private_key_path = temporary.path().join("private-key.pem"); + std::fs::write(&certificate_path, cert.pem()).unwrap(); + std::fs::write(&private_key_path, key_pair.serialize_pem()).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let config = DataPlaneGatewayConfig { + listen: address, + certificate_path, + private_key_path, + max_connections: NonZeroUsize::new(16).unwrap(), + handshake_timeout: Duration::from_secs(2), + connect_timeout: Duration::from_secs(2), + drain_timeout: Duration::from_secs(2), + }; + let gateway = DataPlaneGateway::build( + config, + harness.parser.clone(), + harness.leases.clone(), + harness.executions.clone(), + harness.executions.clone(), + harness.connector.clone(), + ) + .await + .unwrap(); + let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false); + let gateway_task = tokio::spawn(gateway.serve(listener, shutdown_receiver)); + + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert.der().clone()).unwrap(); + let mut client_config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + client_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); + let host = format!("{}-{}.box.example.com", ENVD_PORT, harness.sandbox_id); + let tcp = TcpStream::connect(address).await.unwrap(); + let tls = connector + .connect(ServerName::try_from(host.clone()).unwrap(), tcp) + .await + .unwrap(); + let (mut sender, connection) = hyper::client::conn::handshake(tls).await.unwrap(); + let client_task = tokio::spawn(connection); + let request = Request::builder() + .uri("/health") + .header(HOST, host) + .header(ENVD_ACCESS_TOKEN_HEADER, harness.envd_token.expose_secret()) + .body(Body::empty()) + .unwrap(); + let response = sender.send_request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let _ = to_bytes(response.into_body()).await.unwrap(); + drop(sender); + client_task.await.unwrap().unwrap(); + shutdown_sender.send(true).unwrap(); + gateway_task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn http2_connection_multiplexes_unary_request_while_stream_is_open() { + let harness = Harness::new_with_envd_mode(EnvdMode::Runtime).await; + let open_streams = Arc::new(Mutex::new(Vec::new())); + let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream_address = upstream_listener.local_addr().unwrap(); + let upstream_streams = open_streams.clone(); + let upstream_task = tokio::spawn(async move { + loop { + let Ok((socket, _)) = upstream_listener.accept().await else { + return; + }; + let connection_streams = upstream_streams.clone(); + tokio::spawn(async move { + let service = service_fn(move |request| { + concurrent_stream_upstream_response(request, connection_streams.clone()) + }); + let mut http = hyper::server::conn::Http::new(); + http.http1_only(true); + let _ = http.serve_connection(socket, service).await; + }); + } + }); + let connector = Arc::new(TcpConnector { + address: upstream_address, + calls: Arc::new(Mutex::new(Vec::new())), + }); + + let temporary = tempfile::tempdir().unwrap(); + let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(vec![ + "*.box.example.com".to_string(), + "sandbox.box.example.com".to_string(), + ]) + .unwrap(); + let certificate_path = temporary.path().join("certificate.pem"); + let private_key_path = temporary.path().join("private-key.pem"); + std::fs::write(&certificate_path, cert.pem()).unwrap(); + std::fs::write(&private_key_path, key_pair.serialize_pem()).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let config = DataPlaneGatewayConfig { + listen: address, + certificate_path, + private_key_path, + max_connections: NonZeroUsize::new(16).unwrap(), + handshake_timeout: Duration::from_secs(2), + connect_timeout: Duration::from_secs(2), + drain_timeout: Duration::from_secs(2), + }; + let gateway = DataPlaneGateway::build( + config, + harness.parser.clone(), + harness.leases.clone(), + harness.executions.clone(), + harness.executions.clone(), + connector.clone(), + ) + .await + .unwrap(); + let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false); + let gateway_task = tokio::spawn(gateway.serve(listener, shutdown_receiver)); + + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert.der().clone()).unwrap(); + let mut client_config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + client_config.alpn_protocols = vec![b"h2".to_vec()]; + let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); + let host = format!("{}-{}.box.example.com", ENVD_PORT, harness.sandbox_id); + + let probe_tcp = TcpStream::connect(address).await.unwrap(); + let mut probe_tls = tls_connector + .connect(ServerName::try_from(host.clone()).unwrap(), probe_tcp) + .await + .unwrap(); + probe_tls + .write_all(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n\0\0\0\x04\0\0\0\0\0") + .await + .unwrap(); + let mut frame_header = [0_u8; 9]; + probe_tls.read_exact(&mut frame_header).await.unwrap(); + let payload_length = usize::from(frame_header[0]) << 16 + | usize::from(frame_header[1]) << 8 + | usize::from(frame_header[2]); + assert_eq!(frame_header[3], 0x04, "first HTTP/2 frame was not SETTINGS"); + assert_eq!(frame_header[4] & 0x01, 0, "first SETTINGS frame was an ACK"); + let mut settings = vec![0_u8; payload_length]; + probe_tls.read_exact(&mut settings).await.unwrap(); + let advertised_limit = settings.chunks_exact(6).find_map(|setting| { + (u16::from_be_bytes([setting[0], setting[1]]) == 0x03) + .then(|| u32::from_be_bytes([setting[2], setting[3], setting[4], setting[5]])) + }); + assert_eq!(advertised_limit, Some(HTTP2_MAX_CONCURRENT_STREAMS)); + drop(probe_tls); + + let tcp = TcpStream::connect(address).await.unwrap(); + let tls = tls_connector + .connect(ServerName::try_from(host.clone()).unwrap(), tcp) + .await + .unwrap(); + assert_eq!(tls.get_ref().1.alpn_protocol(), Some(b"h2".as_slice())); + let mut builder = hyper::client::conn::Builder::new(); + builder.http2_only(true); + let (mut sender, connection) = builder.handshake(tls).await.unwrap(); + let client_task = tokio::spawn(connection); + + let start_request = Request::builder() + .method(Method::POST) + .uri(format!("https://{host}/process.Process/Start")) + .header(ENVD_ACCESS_TOKEN_HEADER, harness.envd_token.expose_secret()) + .body(Body::from("start")) + .unwrap(); + let mut start_response = + tokio::time::timeout(Duration::from_secs(2), sender.send_request(start_request)) + .await + .unwrap() + .unwrap(); + assert_eq!(start_response.status(), StatusCode::OK); + let first_chunk = + tokio::time::timeout(Duration::from_secs(2), start_response.body_mut().data()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(first_chunk, Bytes::from_static(b"open-start-stream")); + + let list_request = Request::builder() + .method(Method::POST) + .uri(format!("https://{host}/process.Process/List")) + .header(ENVD_ACCESS_TOKEN_HEADER, harness.envd_token.expose_secret()) + .body(Body::from("list")) + .unwrap(); + let list_response = + tokio::time::timeout(Duration::from_secs(2), sender.send_request(list_request)) + .await + .unwrap() + .unwrap(); + assert_eq!(list_response.status(), StatusCode::OK); + assert_eq!( + to_bytes(list_response.into_body()).await.unwrap(), + Bytes::from_static(b"list-response") + ); + assert_eq!(connector.calls.lock().unwrap().len(), 2); + + open_streams.lock().unwrap().clear(); + drop(start_response); + drop(sender); + client_task.abort(); + shutdown_sender.send(true).unwrap(); + gateway_task.await.unwrap().unwrap(); + upstream_task.abort(); +} diff --git a/src/compat/src/gateway/tls.rs b/src/compat/src/gateway/tls.rs new file mode 100644 index 00000000..8415c732 --- /dev/null +++ b/src/compat/src/gateway/tls.rs @@ -0,0 +1,91 @@ +use std::io::Cursor; +use std::path::Path; +use std::sync::Arc; + +use rustls::ServerConfig; + +use super::{DataPlaneGatewayError, DataPlaneGatewayResult}; + +const MAX_TLS_FILE_BYTES: u64 = 4 * 1024 * 1024; + +pub(super) async fn load_server_config( + certificate_path: &Path, + private_key_path: &Path, +) -> DataPlaneGatewayResult> { + let certificate_bytes = read_limited(certificate_path, true).await?; + let private_key_bytes = read_limited(private_key_path, false).await?; + + let certificates = rustls_pemfile::certs(&mut Cursor::new(certificate_bytes)) + .collect::, _>>() + .map_err(|source| DataPlaneGatewayError::ReadCertificate { + path: certificate_path.to_path_buf(), + source, + })?; + if certificates.is_empty() { + return Err(DataPlaneGatewayError::MissingCertificate( + certificate_path.to_path_buf(), + )); + } + let private_key = rustls_pemfile::private_key(&mut Cursor::new(private_key_bytes)) + .map_err(|source| DataPlaneGatewayError::ReadPrivateKey { + path: private_key_path.to_path_buf(), + source, + })? + .ok_or_else(|| DataPlaneGatewayError::MissingPrivateKey(private_key_path.to_path_buf()))?; + + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, private_key) + .map_err(DataPlaneGatewayError::InvalidTls)?; + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(Arc::new(config)) +} + +async fn read_limited(path: &Path, certificate: bool) -> DataPlaneGatewayResult> { + let metadata = tokio::fs::metadata(path).await.map_err(|source| { + if certificate { + DataPlaneGatewayError::ReadCertificate { + path: path.to_path_buf(), + source, + } + } else { + DataPlaneGatewayError::ReadPrivateKey { + path: path.to_path_buf(), + source, + } + } + })?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_TLS_FILE_BYTES { + let source = std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "TLS file must be a non-empty regular file no larger than {MAX_TLS_FILE_BYTES} bytes" + ), + ); + return Err(if certificate { + DataPlaneGatewayError::ReadCertificate { + path: path.to_path_buf(), + source, + } + } else { + DataPlaneGatewayError::ReadPrivateKey { + path: path.to_path_buf(), + source, + } + }); + } + tokio::fs::read(path).await.map_err(|source| { + if certificate { + DataPlaneGatewayError::ReadCertificate { + path: path.to_path_buf(), + source, + } + } else { + DataPlaneGatewayError::ReadPrivateKey { + path: path.to_path_buf(), + source, + } + } + }) +} diff --git a/src/compat/src/http/account.rs b/src/compat/src/http/account.rs new file mode 100644 index 00000000..97a13f58 --- /dev/null +++ b/src/compat/src/http/account.rs @@ -0,0 +1,552 @@ +use std::fmt; +use std::num::NonZeroU32; +use std::str::FromStr; + +use async_trait::async_trait; +use ring::pbkdf2; +use ring::rand::{SecureRandom, SystemRandom}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{ + AuthenticatedAccount, AuthenticationError, AuthenticationResult, CredentialScheme, + CredentialVerifier, PresentedCredential, +}; + +const HASH_ALGORITHM: &str = "pbkdf2-sha256"; +const DEFAULT_ITERATIONS: u32 = 210_000; +const MINIMUM_ITERATIONS: u32 = 100_000; +const SALT_BYTES: usize = 16; +const DIGEST_BYTES: usize = 32; +const MAX_CREDENTIAL_BYTES: usize = 4096; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct CredentialHash { + iterations: NonZeroU32, + salt: [u8; SALT_BYTES], + digest: [u8; DIGEST_BYTES], +} + +impl CredentialHash { + pub fn generate(secret: &str) -> CredentialHashResult { + validate_secret_length(secret)?; + let mut salt = [0_u8; SALT_BYTES]; + SystemRandom::new() + .fill(&mut salt) + .map_err(|_| CredentialHashError::RandomUnavailable)?; + Self::derive(secret, DEFAULT_ITERATIONS, &salt) + } + + pub fn derive(secret: &str, iterations: u32, salt: &[u8]) -> CredentialHashResult { + validate_secret_length(secret)?; + if iterations < MINIMUM_ITERATIONS { + return Err(CredentialHashError::InvalidIterations); + } + let iterations = + NonZeroU32::new(iterations).ok_or(CredentialHashError::InvalidIterations)?; + let salt: [u8; SALT_BYTES] = salt + .try_into() + .map_err(|_| CredentialHashError::InvalidSalt)?; + let mut digest = [0_u8; DIGEST_BYTES]; + pbkdf2::derive( + pbkdf2::PBKDF2_HMAC_SHA256, + iterations, + &salt, + secret.as_bytes(), + &mut digest, + ); + Ok(Self { + iterations, + salt, + digest, + }) + } + + pub fn verify(&self, secret: &str) -> bool { + if validate_secret_length(secret).is_err() { + return false; + } + pbkdf2::verify( + pbkdf2::PBKDF2_HMAC_SHA256, + self.iterations, + &self.salt, + secret.as_bytes(), + &self.digest, + ) + .is_ok() + } +} + +impl fmt::Display for CredentialHash { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{HASH_ALGORITHM}${}${}${}", + self.iterations, + hex::encode(self.salt), + hex::encode(self.digest) + ) + } +} + +impl fmt::Debug for CredentialHash { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CredentialHash") + .field("algorithm", &HASH_ALGORITHM) + .field("iterations", &self.iterations) + .field("salt", &"[REDACTED]") + .field("digest", &"[REDACTED]") + .finish() + } +} + +impl FromStr for CredentialHash { + type Err = CredentialHashError; + + fn from_str(value: &str) -> Result { + let mut fields = value.split('$'); + if fields.next() != Some(HASH_ALGORITHM) { + return Err(CredentialHashError::InvalidEncoding); + } + let iterations = fields + .next() + .ok_or(CredentialHashError::InvalidEncoding)? + .parse::() + .map_err(|_| CredentialHashError::InvalidEncoding)?; + if iterations < MINIMUM_ITERATIONS { + return Err(CredentialHashError::InvalidIterations); + } + let iterations = + NonZeroU32::new(iterations).ok_or(CredentialHashError::InvalidIterations)?; + let salt = hex::decode(fields.next().ok_or(CredentialHashError::InvalidEncoding)?) + .map_err(|_| CredentialHashError::InvalidEncoding)?; + let digest = hex::decode(fields.next().ok_or(CredentialHashError::InvalidEncoding)?) + .map_err(|_| CredentialHashError::InvalidEncoding)?; + if fields.next().is_some() { + return Err(CredentialHashError::InvalidEncoding); + } + Ok(Self { + iterations, + salt: salt + .try_into() + .map_err(|_| CredentialHashError::InvalidSalt)?, + digest: digest + .try_into() + .map_err(|_| CredentialHashError::InvalidDigest)?, + }) + } +} + +impl TryFrom for CredentialHash { + type Error = CredentialHashError; + + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl From for String { + fn from(value: CredentialHash) -> Self { + value.to_string() + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CredentialHashError { + #[error("credential is empty or too large")] + InvalidSecret, + #[error("credential hash iteration count is below the production minimum")] + InvalidIterations, + #[error("credential hash salt is invalid")] + InvalidSalt, + #[error("credential hash digest is invalid")] + InvalidDigest, + #[error("credential hash encoding is invalid")] + InvalidEncoding, + #[error("secure random generation is unavailable")] + RandomUnavailable, + #[error("account identity is invalid")] + InvalidAccount, + #[error("compatibility API key must match e2b_[0-9a-f]+")] + InvalidApiKey, + #[error("at least one hashed account credential is required")] + MissingCredentials, +} + +pub type CredentialHashResult = std::result::Result; + +#[derive(Clone)] +pub struct HashedAccountCredential { + scheme: CredentialScheme, + owner_id: String, + client_id: String, + hash: CredentialHash, +} + +impl HashedAccountCredential { + pub fn new( + scheme: CredentialScheme, + owner_id: impl Into, + client_id: impl Into, + hash: CredentialHash, + ) -> CredentialHashResult { + let owner_id = owner_id.into(); + let client_id = client_id.into(); + if owner_id.trim().is_empty() || client_id.trim().is_empty() { + return Err(CredentialHashError::InvalidAccount); + } + Ok(Self { + scheme, + owner_id, + client_id, + hash, + }) + } + + pub fn from_secret( + scheme: CredentialScheme, + owner_id: impl Into, + client_id: impl Into, + secret: &str, + ) -> CredentialHashResult { + if scheme == CredentialScheme::ApiKey && !is_compatibility_api_key(secret) { + return Err(CredentialHashError::InvalidApiKey); + } + Self::new( + scheme, + owner_id, + client_id, + CredentialHash::generate(secret)?, + ) + } + + pub const fn scheme(&self) -> CredentialScheme { + self.scheme + } + + pub fn owner_id(&self) -> &str { + &self.owner_id + } + + pub fn client_id(&self) -> &str { + &self.client_id + } + + pub fn hash(&self) -> &CredentialHash { + &self.hash + } +} + +impl fmt::Debug for HashedAccountCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HashedAccountCredential") + .field("scheme", &self.scheme) + .field("owner_id", &self.owner_id) + .field("client_id", &self.client_id) + .field("hash", &self.hash) + .finish() + } +} + +pub struct HashedCredentialVerifier { + credentials: Vec, +} + +impl HashedCredentialVerifier { + pub fn new( + credentials: impl IntoIterator, + ) -> CredentialHashResult { + let credentials: Vec<_> = credentials.into_iter().collect(); + if credentials.is_empty() { + return Err(CredentialHashError::MissingCredentials); + } + Ok(Self { credentials }) + } +} + +impl fmt::Debug for HashedCredentialVerifier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HashedCredentialVerifier") + .field("credential_count", &self.credentials.len()) + .finish() + } +} + +#[async_trait] +impl CredentialVerifier for HashedCredentialVerifier { + async fn verify( + &self, + credential: &PresentedCredential, + ) -> AuthenticationResult { + if credential.scheme() == CredentialScheme::ApiKey + && !is_compatibility_api_key(credential.expose_secret()) + { + return Err(AuthenticationError::Invalid); + } + + let mut account = None; + for stored in &self.credentials { + if stored.scheme() != credential.scheme() + || credential + .owner_hint() + .is_some_and(|hint| hint != stored.owner_id()) + || !stored.hash().verify(credential.expose_secret()) + { + continue; + } + if account.is_some() { + return Err(AuthenticationError::Invalid); + } + account = Some(AuthenticatedAccount { + owner_id: stored.owner_id().to_string(), + client_id: stored.client_id().to_string(), + }); + } + account.ok_or(AuthenticationError::Invalid) + } +} + +fn validate_secret_length(secret: &str) -> CredentialHashResult<()> { + if secret.is_empty() || secret.len() > MAX_CREDENTIAL_BYTES { + Err(CredentialHashError::InvalidSecret) + } else { + Ok(()) + } +} + +fn is_compatibility_api_key(value: &str) -> bool { + value.strip_prefix("e2b_").is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }) +} + +#[cfg(test)] +mod tests { + use axum::http::{header, HeaderMap, HeaderValue}; + + use super::*; + + fn deterministic_hash(secret: &str, marker: u8) -> CredentialHash { + CredentialHash::derive(secret, MINIMUM_ITERATIONS, &[marker; SALT_BYTES]).unwrap() + } + + fn credential(headers: HeaderMap) -> PresentedCredential { + PresentedCredential::from_headers(&headers).unwrap() + } + + #[test] + fn encoded_hash_round_trips_without_exposing_the_secret() { + let hash = deterministic_hash("e2b_a1b2c3", 7); + let encoded = hash.to_string(); + + assert!(!encoded.contains("e2b_a1b2c3")); + let parsed: CredentialHash = encoded.parse().unwrap(); + assert!(parsed.verify("e2b_a1b2c3")); + assert!(!parsed.verify("e2b_deadbeef")); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&hash).unwrap()).unwrap(), + hash + ); + } + + #[test] + fn hashes_use_salts_and_enforce_production_cost() { + let first = deterministic_hash("e2b_a1b2c3", 1); + let second = deterministic_hash("e2b_a1b2c3", 2); + + assert_ne!(first, second); + assert_eq!( + CredentialHash::derive("e2b_a1b2c3", MINIMUM_ITERATIONS - 1, &[0; SALT_BYTES]) + .unwrap_err(), + CredentialHashError::InvalidIterations + ); + assert!("pbkdf2-sha256$99999$00000000000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000" + .parse::() + .is_err()); + } + + #[tokio::test] + async fn verifier_authenticates_api_keys_and_bearer_tokens_by_hash() { + let verifier = HashedCredentialVerifier::new([ + HashedAccountCredential::new( + CredentialScheme::ApiKey, + "owner-api", + "client-api", + deterministic_hash("e2b_a1b2c3", 3), + ) + .unwrap(), + HashedAccountCredential::new( + CredentialScheme::Bearer, + "owner-bearer", + "client-bearer", + deterministic_hash("bearer-secret", 4), + ) + .unwrap(), + ]) + .unwrap(); + + let api_key = credential(HeaderMap::from_iter([( + "x-api-key".parse().unwrap(), + HeaderValue::from_static("e2b_a1b2c3"), + )])); + let api_account = verifier.verify(&api_key).await.unwrap(); + assert_eq!(api_account.owner_id, "owner-api"); + assert_eq!(api_account.client_id, "client-api"); + + let bearer = credential(HeaderMap::from_iter([( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer bearer-secret"), + )])); + let bearer_account = verifier.verify(&bearer).await.unwrap(); + assert_eq!(bearer_account.owner_id, "owner-bearer"); + } + + #[tokio::test] + async fn verifier_rejects_invalid_lexical_form_hints_and_ambiguous_keys() { + assert_eq!( + HashedAccountCredential::from_secret( + CredentialScheme::ApiKey, + "owner", + "client", + "e2b_UPPER", + ) + .unwrap_err(), + CredentialHashError::InvalidApiKey + ); + + let shared = "shared-bearer"; + let verifier = HashedCredentialVerifier::new([ + HashedAccountCredential::new( + CredentialScheme::Bearer, + "owner-a", + "client-a", + deterministic_hash(shared, 5), + ) + .unwrap(), + HashedAccountCredential::new( + CredentialScheme::Bearer, + "owner-b", + "client-b", + deterministic_hash(shared, 6), + ) + .unwrap(), + ]) + .unwrap(); + let ambiguous = credential(HeaderMap::from_iter([( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer shared-bearer"), + )])); + assert!(matches!( + verifier.verify(&ambiguous).await, + Err(AuthenticationError::Invalid) + )); + + let hinted = credential(HeaderMap::from_iter([ + ( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer shared-bearer"), + ), + ( + "x-team-id".parse().unwrap(), + HeaderValue::from_static("owner-a"), + ), + ])); + assert_eq!(verifier.verify(&hinted).await.unwrap().owner_id, "owner-a"); + } + + #[tokio::test] + async fn supabase_owner_hint_selects_one_hashed_account() { + let shared = "supabase-secret"; + let verifier = HashedCredentialVerifier::new([ + HashedAccountCredential::new( + CredentialScheme::Supabase, + "team-a", + "client-a", + deterministic_hash(shared, 7), + ) + .unwrap(), + HashedAccountCredential::new( + CredentialScheme::Supabase, + "team-b", + "client-b", + deterministic_hash(shared, 8), + ) + .unwrap(), + ]) + .unwrap(); + + let hinted = credential(HeaderMap::from_iter([ + ( + "x-supabase-token".parse().unwrap(), + HeaderValue::from_static("supabase-secret"), + ), + ( + "x-supabase-team".parse().unwrap(), + HeaderValue::from_static("team-b"), + ), + ])); + let account = verifier.verify(&hinted).await.unwrap(); + assert_eq!(account.owner_id, "team-b"); + assert_eq!(account.client_id, "client-b"); + + let ambiguous = credential(HeaderMap::from_iter([( + "x-supabase-token".parse().unwrap(), + HeaderValue::from_static("supabase-secret"), + )])); + assert!(matches!( + verifier.verify(&ambiguous).await, + Err(AuthenticationError::Invalid) + )); + } + + #[test] + fn malformed_hashes_and_empty_provider_configuration_are_rejected() { + assert_eq!( + CredentialHash::generate("").unwrap_err(), + CredentialHashError::InvalidSecret + ); + assert_eq!( + CredentialHash::generate(&"x".repeat(MAX_CREDENTIAL_BYTES + 1)).unwrap_err(), + CredentialHashError::InvalidSecret + ); + for malformed in [ + "", + "sha256$100000$00000000000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000", + "pbkdf2-sha256$100000$zz$00", + "pbkdf2-sha256$100000$00$0000000000000000000000000000000000000000000000000000000000000000", + "pbkdf2-sha256$100000$00000000000000000000000000000000$00", + "pbkdf2-sha256$100000$00000000000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000$extra", + ] { + assert!(malformed.parse::().is_err(), "{malformed}"); + } + assert_eq!( + HashedAccountCredential::new( + CredentialScheme::Bearer, + " ", + "client", + deterministic_hash("secret", 10), + ) + .unwrap_err(), + CredentialHashError::InvalidAccount + ); + assert_eq!( + HashedCredentialVerifier::new(std::iter::empty()).unwrap_err(), + CredentialHashError::MissingCredentials + ); + } + + #[test] + fn debug_output_redacts_hash_material() { + let hash = deterministic_hash("e2b_a1b2c3", 9); + let debug = format!("{hash:?}"); + assert!(debug.contains("REDACTED")); + assert!(!debug.contains(&hex::encode([9_u8; SALT_BYTES]))); + } +} diff --git a/src/compat/src/http/auth.rs b/src/compat/src/http/auth.rs new file mode 100644 index 00000000..a353396a --- /dev/null +++ b/src/compat/src/http/auth.rs @@ -0,0 +1,117 @@ +use std::fmt; + +use async_trait::async_trait; +use axum::http::{header, HeaderMap}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CredentialScheme { + ApiKey, + Bearer, + Supabase, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PresentedCredential { + scheme: CredentialScheme, + secret: String, + owner_hint: Option, +} + +impl PresentedCredential { + pub fn from_headers(headers: &HeaderMap) -> AuthenticationResult { + if let Some(value) = headers.get("x-api-key") { + return Ok(Self { + scheme: CredentialScheme::ApiKey, + secret: header_value(value)?, + owner_hint: None, + }); + } + + if let Some(value) = headers.get("x-supabase-token") { + let owner_hint = headers + .get("x-supabase-team") + .map(header_value) + .transpose()?; + return Ok(Self { + scheme: CredentialScheme::Supabase, + secret: header_value(value)?, + owner_hint, + }); + } + + if let Some(value) = headers.get(header::AUTHORIZATION) { + let authorization = header_value(value)?; + let secret = authorization + .strip_prefix("Bearer ") + .filter(|secret| !secret.is_empty()) + .ok_or(AuthenticationError::Invalid)?; + let owner_hint = headers.get("x-team-id").map(header_value).transpose()?; + return Ok(Self { + scheme: CredentialScheme::Bearer, + secret: secret.to_string(), + owner_hint, + }); + } + + Err(AuthenticationError::Missing) + } + + pub const fn scheme(&self) -> CredentialScheme { + self.scheme + } + + pub fn expose_secret(&self) -> &str { + &self.secret + } + + pub fn owner_hint(&self) -> Option<&str> { + self.owner_hint.as_deref() + } +} + +impl fmt::Debug for PresentedCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PresentedCredential") + .field("scheme", &self.scheme) + .field("secret", &"[REDACTED]") + .field("owner_hint", &self.owner_hint) + .finish() + } +} + +#[derive(Debug, Clone)] +pub struct AuthenticatedAccount { + pub owner_id: String, + pub client_id: String, +} + +#[derive(Debug, Error)] +pub enum AuthenticationError { + #[error("authentication credential is missing")] + Missing, + #[error("authentication credential is invalid")] + Invalid, + #[error("authentication provider is unavailable: {0}")] + Unavailable(String), +} + +pub type AuthenticationResult = std::result::Result; + +#[async_trait] +pub trait CredentialVerifier: Send + Sync { + async fn verify( + &self, + credential: &PresentedCredential, + ) -> AuthenticationResult; +} + +fn header_value(value: &axum::http::HeaderValue) -> AuthenticationResult { + value + .to_str() + .map(str::to_string) + .map_err(|_| AuthenticationError::Invalid) +} diff --git a/src/compat/src/http/cursor.rs b/src/compat/src/http/cursor.rs new file mode 100644 index 00000000..bc7a6dbf --- /dev/null +++ b/src/compat/src/http/cursor.rs @@ -0,0 +1,26 @@ +use thiserror::Error; + +use crate::control::SandboxCursor; + +#[derive(Debug, Error)] +pub enum CursorError { + #[error("sandbox cursor is invalid")] + Invalid, + #[error("sandbox cursor provider is unavailable: {0}")] + Unavailable(String), +} + +pub type CursorResult = std::result::Result; + +pub trait CursorDecoder: Send + Sync { + fn decode(&self, value: &str) -> CursorResult>; +} + +#[derive(Debug, Default)] +pub struct RejectingCursorDecoder; + +impl CursorDecoder for RejectingCursorDecoder { + fn decode(&self, _value: &str) -> CursorResult> { + Err(CursorError::Invalid) + } +} diff --git a/src/compat/src/http/dto.rs b/src/compat/src/http/dto.rs new file mode 100644 index 00000000..9138585b --- /dev/null +++ b/src/compat/src/http/dto.rs @@ -0,0 +1,485 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::num::NonZeroU32; + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::control::{ + ConnectionDisposition, CreateSandboxRequest, LifecyclePolicy, OnTimeoutAction, + PublicSandboxState, SandboxConnection, SandboxId, SandboxListFilter, SandboxMetric, + SandboxRecord, +}; +use crate::volume::VolumeMount; + +use super::cursor::CursorDecoder; +use super::error::ApiError; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NewSandboxBody { + #[serde(rename = "templateID")] + template_id: String, + #[serde(default = "default_timeout")] + timeout: u32, + #[serde(rename = "autoPause", default)] + auto_pause: bool, + #[serde(rename = "autoPauseMemory", default = "default_true")] + auto_pause_memory: bool, + #[serde(rename = "autoResume", default)] + auto_resume: AutoResumeBody, + #[serde(default)] + secure: bool, + #[serde(default)] + allow_internet_access: Option, + #[serde(default)] + metadata: BTreeMap, + #[serde(rename = "envVars", default)] + env_vars: BTreeMap, + #[serde(default)] + network: Option, + #[serde(default)] + mcp: Option, + #[serde(rename = "volumeMounts", default)] + volume_mounts: Vec, +} + +impl NewSandboxBody { + pub fn into_control( + self, + owner_id: String, + ) -> Result<(CreateSandboxRequest, Vec), ApiError> { + if self.network.is_some() || self.mcp.is_some() { + return Err(ApiError::bad_request( + "network and MCP overrides are not available in this preview", + )); + } + if self.auto_resume.enabled && self.auto_pause && !self.auto_pause_memory { + return Err(ApiError::bad_request( + "auto-resume requires memory-preserving auto-pause", + )); + } + crate::volume::validate_mounts(&self.volume_mounts) + .map_err(|error| ApiError::bad_request(error.to_string()))?; + Ok(( + CreateSandboxRequest { + owner_id, + template_id: self.template_id, + timeout_seconds: self.timeout, + lifecycle: LifecyclePolicy { + on_timeout: if self.auto_pause { + OnTimeoutAction::Pause + } else { + OnTimeoutAction::Kill + }, + auto_resume: self.auto_resume.enabled, + keep_memory_on_pause: self.auto_pause_memory, + }, + metadata: self.metadata, + env_vars: self.env_vars, + secure: self.secure, + allow_internet_access: self.allow_internet_access, + }, + self.volume_mounts, + )) + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct AutoResumeBody { + #[serde(default)] + enabled: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimeoutBody { + pub timeout: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PauseBody { + #[serde(default = "default_true")] + pub memory: bool, +} + +impl Default for PauseBody { + fn default() -> Self { + Self { memory: true } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResumeBody { + #[serde(default = "default_timeout")] + pub timeout: u32, + #[serde(rename = "autoPause", default)] + pub auto_pause: bool, +} + +#[derive(Debug, Default, Deserialize)] +pub struct RefreshBody { + #[serde(default)] + duration: Option, +} + +impl RefreshBody { + pub fn duration(self) -> Result { + let duration = self.duration.unwrap_or_else(default_timeout); + if duration > 3_600 { + return Err(ApiError::bad_request( + "refresh duration must be between 0 and 3600 seconds", + )); + } + Ok(duration.max(default_timeout())) + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct MetricRange { + start: Option, + end: Option, +} + +impl MetricRange { + pub fn contains(self, timestamp: i64) -> bool { + self.start.is_none_or(|start| timestamp >= start) + && self.end.is_none_or(|end| timestamp <= end) + } +} + +pub fn parse_metric_range(raw_query: Option<&str>) -> Result { + let mut range = MetricRange::default(); + for (name, value) in url::form_urlencoded::parse(raw_query.unwrap_or_default().as_bytes()) { + let parsed = value + .parse::() + .ok() + .filter(|value| *value >= 0) + .ok_or_else(|| ApiError::bad_request("metric timestamps must be non-negative"))?; + match name.as_ref() { + "start" if range.start.replace(parsed).is_none() => {} + "end" if range.end.replace(parsed).is_none() => {} + "start" | "end" => { + return Err(ApiError::bad_request( + "metric timestamp parameters must not be repeated", + )) + } + _ => return Err(ApiError::bad_request("unknown sandbox metrics parameter")), + } + } + if matches!((range.start, range.end), (Some(start), Some(end)) if start > end) { + return Err(ApiError::bad_request( + "metric start timestamp must not exceed end timestamp", + )); + } + Ok(range) +} + +pub fn parse_metric_sandbox_ids(raw_query: Option<&str>) -> Result, ApiError> { + let mut values = None; + for (name, value) in url::form_urlencoded::parse(raw_query.unwrap_or_default().as_bytes()) { + if name != "sandbox_ids" || values.replace(value.into_owned()).is_some() { + return Err(ApiError::bad_request( + "sandbox_ids must be provided exactly once", + )); + } + } + let values = values.ok_or_else(|| ApiError::bad_request("sandbox_ids is required"))?; + let mut unique = BTreeSet::new(); + let mut sandbox_ids = Vec::new(); + for value in values.split(',') { + let sandbox_id = SandboxId::new(value.to_string()) + .map_err(|_| ApiError::bad_request("sandbox_ids contains an invalid Sandbox ID"))?; + if !unique.insert(sandbox_id.to_string()) { + return Err(ApiError::bad_request("sandbox_ids must be unique")); + } + sandbox_ids.push(sandbox_id); + } + if sandbox_ids.is_empty() || sandbox_ids.len() > 100 { + return Err(ApiError::bad_request( + "sandbox_ids must contain between 1 and 100 IDs", + )); + } + Ok(sandbox_ids) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxMetricResponse { + timestamp: String, + timestamp_unix: i64, + cpu_count: u32, + cpu_used_pct: f32, + mem_used: u64, + mem_total: u64, + mem_cache: u64, + disk_used: u64, + disk_total: u64, +} + +impl From for SandboxMetricResponse { + fn from(metric: SandboxMetric) -> Self { + Self { + timestamp: format_time(metric.timestamp), + timestamp_unix: metric.timestamp.timestamp(), + cpu_count: metric.cpu_count, + cpu_used_pct: metric.cpu_used_pct, + mem_used: metric.mem_used, + mem_total: metric.mem_total, + mem_cache: metric.mem_cache, + disk_used: metric.disk_used, + disk_total: metric.disk_total, + } + } +} + +#[derive(Debug, Serialize)] +pub struct SandboxesWithMetricsResponse { + pub sandboxes: BTreeMap, +} + +pub fn parse_list_filter( + owner_id: String, + raw_query: Option<&str>, + cursors: &dyn CursorDecoder, +) -> Result { + let mut metadata = BTreeMap::new(); + let mut states = BTreeSet::new(); + let mut limit = NonZeroU32::new(100).unwrap_or(NonZeroU32::MIN); + let mut after = None; + + for (name, value) in url::form_urlencoded::parse(raw_query.unwrap_or_default().as_bytes()) { + match name.as_ref() { + "metadata" => metadata.extend(parse_metadata(&value)?), + "state" => { + for state in value.split(',') { + states.insert(match state { + "running" => PublicSandboxState::Running, + "paused" => PublicSandboxState::Paused, + _ => return Err(ApiError::bad_request("invalid sandbox state filter")), + }); + } + } + "limit" => { + let parsed = value + .parse::() + .ok() + .filter(|value| (1..=100).contains(value)) + .and_then(NonZeroU32::new) + .ok_or_else(|| ApiError::bad_request("limit must be between 1 and 100"))?; + limit = parsed; + } + "nextToken" => after = cursors.decode(&value)?.or(after), + _ => { + return Err(ApiError::bad_request( + "unknown sandbox list query parameter", + )) + } + } + } + + Ok(SandboxListFilter { + owner_id, + metadata, + states, + limit, + after, + }) +} + +fn parse_metadata(value: &str) -> Result, ApiError> { + let mut metadata = BTreeMap::new(); + for (key, value) in url::form_urlencoded::parse(value.as_bytes()) { + let decoded = url::form_urlencoded::parse(format!("value={value}").as_bytes()) + .next() + .map(|(_, value)| value.into_owned()) + .unwrap_or_default(); + if key.is_empty() { + return Err(ApiError::bad_request("metadata keys cannot be empty")); + } + metadata.insert(key.into_owned(), decoded); + } + Ok(metadata) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxResponse { + #[serde(rename = "templateID")] + template_id: String, + #[serde(rename = "sandboxID")] + sandbox_id: String, + #[serde(rename = "clientID")] + client_id: String, + #[serde(rename = "envdVersion")] + envd_version: String, + envd_access_token: String, + traffic_access_token: Option, + domain: Option, + volume_mounts: Vec, +} + +impl SandboxResponse { + pub fn from_connection( + connection: SandboxConnection, + client_id: String, + domain: Option, + ) -> (Self, ConnectionDisposition) { + let disposition = connection.disposition; + let response = Self { + template_id: connection.record.template_id().to_string(), + sandbox_id: connection.record.sandbox_id().to_string(), + client_id, + envd_version: connection.record.envd_version().to_string(), + envd_access_token: connection.envd_access_token.expose_secret().to_string(), + traffic_access_token: Some(connection.traffic_access_token.expose_secret().to_string()), + domain, + volume_mounts: volume_mount_responses(&connection.record), + }; + (response, disposition) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListedSandboxResponse { + #[serde(rename = "templateID")] + template_id: String, + #[serde(rename = "sandboxID")] + sandbox_id: String, + #[serde(rename = "clientID")] + client_id: String, + started_at: String, + end_at: String, + cpu_count: u32, + #[serde(rename = "memoryMB")] + memory_mb: u32, + #[serde(rename = "diskSizeMB")] + disk_size_mb: u32, + metadata: BTreeMap, + state: PublicSandboxState, + envd_version: String, + volume_mounts: Vec, +} + +impl ListedSandboxResponse { + pub fn from_record(record: &SandboxRecord, client_id: String) -> Option { + Some(Self { + template_id: record.template_id().to_string(), + sandbox_id: record.sandbox_id().to_string(), + client_id, + started_at: format_time(record.started_at()?), + end_at: format_time(record.expires_at()), + cpu_count: record.resources().vcpus, + memory_mb: record.resources().memory_mb, + disk_size_mb: record.resources().disk_mb, + metadata: record.metadata().clone(), + state: record.public_state()?, + envd_version: record.envd_version().to_string(), + volume_mounts: volume_mount_responses(record), + }) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxDetailResponse { + #[serde(flatten)] + listed: ListedSandboxResponse, + #[serde(skip_serializing_if = "Option::is_none")] + envd_access_token: Option, + allow_internet_access: Option, + domain: Option, + lifecycle: LifecycleResponse, +} + +impl SandboxDetailResponse { + pub fn from_record( + record: &SandboxRecord, + client_id: String, + domain: Option, + ) -> Option { + Some(Self { + listed: ListedSandboxResponse::from_record(record, client_id)?, + envd_access_token: None, + allow_internet_access: record.allow_internet_access(), + domain, + lifecycle: LifecycleResponse { + auto_resume: record.lifecycle().auto_resume, + on_timeout: record.lifecycle().on_timeout, + }, + }) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleResponse { + auto_resume: bool, + on_timeout: OnTimeoutAction, +} + +#[derive(Debug, Serialize)] +struct VolumeMountResponse { + name: String, + path: String, +} + +fn volume_mount_responses(record: &SandboxRecord) -> Vec { + record + .volume_mounts() + .iter() + .map(|mount| VolumeMountResponse { + name: mount.name.clone(), + path: mount.path.clone(), + }) + .collect() +} + +fn default_timeout() -> u32 { + 15 +} + +fn default_true() -> bool { + true +} + +fn format_time(value: DateTime) -> String { + value.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::{CursorError, CursorResult}; + + struct FixtureCursor; + + impl CursorDecoder for FixtureCursor { + fn decode(&self, value: &str) -> CursorResult> { + if value == "cursor-0" { + Ok(None) + } else { + Err(CursorError::Invalid) + } + } + } + + #[test] + fn parses_the_official_clients_nested_metadata_query() { + let filter = parse_list_filter( + "fixture-client".to_string(), + Some( + "limit=2&metadata=team%3Dalpha%252520beta&nextToken=cursor-0&state=running%2Cpaused", + ), + &FixtureCursor, + ) + .unwrap(); + + assert_eq!(filter.metadata.get("team").unwrap(), "alpha beta"); + assert_eq!(filter.limit.get(), 2); + assert_eq!(filter.states.len(), 2); + } +} diff --git a/src/compat/src/http/error.rs b/src/compat/src/http/error.rs new file mode 100644 index 00000000..1ce4b37d --- /dev/null +++ b/src/compat/src/http/error.rs @@ -0,0 +1,199 @@ +use axum::extract::rejection::JsonRejection; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +use crate::control::{ControlServiceError, RepositoryError, TemplateProviderError}; +use crate::snapshot::SnapshotServiceError; +use crate::volume::VolumeServiceError; + +use super::{AuthenticationError, CursorError}; + +#[derive(Debug)] +pub struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + pub fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + pub fn not_found() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Sandbox not found".to_string(), + } + } + + pub fn volume_not_found() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Volume not found".to_string(), + } + } + + pub fn snapshot_not_found() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Snapshot not found".to_string(), + } + } + + pub fn conflict(message: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + message: message.into(), + } + } + + pub fn unauthorized(message: impl Into) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: message.into(), + } + } + + pub fn internal() -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: "Internal server error".to_string(), + } + } +} + +#[derive(Debug, Serialize)] +struct ErrorResponse { + code: u16, + message: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let body = ErrorResponse { + code: self.status.as_u16(), + message: self.message, + }; + (self.status, Json(body)).into_response() + } +} + +impl From for ApiError { + fn from(error: JsonRejection) -> Self { + Self::bad_request(format!("Invalid JSON request: {}", error.body_text())) + } +} + +impl From for ApiError { + fn from(error: AuthenticationError) -> Self { + match error { + AuthenticationError::Missing | AuthenticationError::Invalid => { + Self::unauthorized("Invalid authentication credentials") + } + AuthenticationError::Unavailable(_) => Self::internal(), + } + } +} + +impl From for ApiError { + fn from(error: CursorError) -> Self { + match error { + CursorError::Invalid => Self::bad_request("Invalid pagination cursor"), + CursorError::Unavailable(_) => Self::internal(), + } + } +} + +impl From for ApiError { + fn from(error: ControlServiceError) -> Self { + match error { + ControlServiceError::InvalidRequest(message) => Self::bad_request(message), + ControlServiceError::NotFound(_) => Self::not_found(), + ControlServiceError::Conflict(_) => Self { + status: StatusCode::CONFLICT, + message: "Sandbox lifecycle conflict".to_string(), + }, + ControlServiceError::Template(TemplateProviderError::NotFound(_)) => Self::not_found(), + ControlServiceError::Template(TemplateProviderError::Invalid(message)) => { + Self::bad_request(message) + } + ControlServiceError::Volume(VolumeServiceError::InvalidRequest(message)) => { + Self::bad_request(message) + } + ControlServiceError::Snapshot(SnapshotServiceError::InvalidRequest(message)) => { + Self::bad_request(message) + } + ControlServiceError::Snapshot(SnapshotServiceError::NotFound) => { + Self::snapshot_not_found() + } + ControlServiceError::Snapshot(SnapshotServiceError::Duplicate) => { + Self::conflict("Snapshot already exists") + } + ControlServiceError::Snapshot(SnapshotServiceError::Conflict) => { + Self::conflict("Snapshot is in use") + } + ControlServiceError::Volume( + VolumeServiceError::NotFound + | VolumeServiceError::Duplicate + | VolumeServiceError::Conflict, + ) => Self::bad_request("Volume mount is unavailable"), + ControlServiceError::Repository(RepositoryError::Duplicate(_)) => Self { + status: StatusCode::CONFLICT, + message: "Sandbox already exists".to_string(), + }, + ControlServiceError::Execution(a3s_box_core::ExecutionManagerError::NotFound(_)) => { + Self::not_found() + } + ControlServiceError::Execution(a3s_box_core::ExecutionManagerError::Conflict { + .. + }) + | ControlServiceError::Lifecycle(_) => Self { + status: StatusCode::CONFLICT, + message: "Sandbox lifecycle conflict".to_string(), + }, + ControlServiceError::Repository(_) + | ControlServiceError::Execution(_) + | ControlServiceError::Identity(_) + | ControlServiceError::Template(_) + | ControlServiceError::Credential(_) + | ControlServiceError::Volume(_) + | ControlServiceError::Snapshot(_) => Self::internal(), + } + } +} + +impl From for ApiError { + fn from(error: SnapshotServiceError) -> Self { + match error { + SnapshotServiceError::InvalidRequest(message) => Self::bad_request(message), + SnapshotServiceError::NotFound => Self::snapshot_not_found(), + SnapshotServiceError::Duplicate => Self::conflict("Snapshot already exists"), + SnapshotServiceError::Conflict => Self::conflict("Snapshot is in use"), + SnapshotServiceError::Repository(_) + | SnapshotServiceError::Execution(_) + | SnapshotServiceError::Model(_) => Self::internal(), + } + } +} + +impl From for ApiError { + fn from(error: VolumeServiceError) -> Self { + match error { + VolumeServiceError::InvalidRequest(message) => Self::bad_request(message), + VolumeServiceError::NotFound => Self::volume_not_found(), + VolumeServiceError::Duplicate => Self::conflict("Volume already exists"), + VolumeServiceError::Conflict => Self::conflict("Volume is in use"), + VolumeServiceError::Forbidden => Self::unauthorized("Invalid volume token"), + VolumeServiceError::Repository(_) + | VolumeServiceError::Runtime(_) + | VolumeServiceError::Credential(_) + | VolumeServiceError::Model(_) + | VolumeServiceError::Content(_) => Self::internal(), + } + } +} diff --git a/src/compat/src/http/lifecycle.rs b/src/compat/src/http/lifecycle.rs new file mode 100644 index 00000000..078586b2 --- /dev/null +++ b/src/compat/src/http/lifecycle.rs @@ -0,0 +1,264 @@ +use std::collections::BTreeMap; +use std::num::NonZeroU32; + +use axum::extract::{rejection::JsonRejection, Extension, Path, RawQuery, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use futures::{stream, StreamExt}; + +use crate::control::{ConnectionDisposition, ControlServiceError, PublicSandboxState, SandboxId}; + +use super::dto::{ + parse_list_filter, parse_metric_range, parse_metric_sandbox_ids, ListedSandboxResponse, + NewSandboxBody, PauseBody, RefreshBody, ResumeBody, SandboxDetailResponse, + SandboxMetricResponse, SandboxResponse, SandboxesWithMetricsResponse, TimeoutBody, +}; +use super::error::ApiError; +use super::router::LifecycleHttpState; +use super::AuthenticatedAccount; + +const MAX_CONCURRENT_METRIC_REQUESTS: usize = 16; + +pub async fn create( + State(state): State, + Extension(account): Extension, + body: Result, JsonRejection>, +) -> Result { + let (request, volume_mounts) = body?.0.into_control(account.owner_id)?; + let connection = state + .service() + .create_with_mounts(request, volume_mounts) + .await?; + let (response, _) = SandboxResponse::from_connection( + connection, + account.client_id, + state.domain().map(str::to_string), + ); + Ok((StatusCode::CREATED, Json(response))) +} + +pub async fn connect( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + body: Result, JsonRejection>, +) -> Result { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let connection = state + .service() + .connect(&account.owner_id, &sandbox_id, body?.0.timeout) + .await?; + let (response, disposition) = SandboxResponse::from_connection( + connection, + account.client_id, + state.domain().map(str::to_string), + ); + let status = match disposition { + ConnectionDisposition::Resumed => StatusCode::CREATED, + ConnectionDisposition::AlreadyRunning | ConnectionDisposition::Created => StatusCode::OK, + }; + Ok((status, Json(response))) +} + +pub async fn pause( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + body: Result, JsonRejection>, +) -> Result { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let body = match body { + Ok(Json(body)) => body, + Err(JsonRejection::MissingJsonContentType(_)) => PauseBody::default(), + Err(error) => return Err(error.into()), + }; + state + .service() + .pause(&account.owner_id, &sandbox_id, body.memory) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn resume( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + body: Result, JsonRejection>, +) -> Result { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let body = body?.0; + let connection = state + .service() + .resume( + &account.owner_id, + &sandbox_id, + body.timeout, + body.auto_pause, + ) + .await?; + let (response, _) = SandboxResponse::from_connection( + connection, + account.client_id, + state.domain().map(str::to_string), + ); + Ok((StatusCode::CREATED, Json(response))) +} + +pub async fn list( + State(state): State, + Extension(account): Extension, + RawQuery(raw_query): RawQuery, +) -> Result>, ApiError> { + let filter = parse_list_filter( + account.owner_id, + raw_query.as_deref(), + state.cursors().as_ref(), + )?; + let page = state.service().list(&filter).await?; + let records = page + .records + .iter() + .filter_map(|record| ListedSandboxResponse::from_record(record, account.client_id.clone())) + .collect(); + Ok(Json(records)) +} + +pub async fn list_running( + State(state): State, + Extension(account): Extension, + RawQuery(raw_query): RawQuery, +) -> Result>, ApiError> { + let mut filter = parse_list_filter( + account.owner_id, + raw_query.as_deref(), + state.cursors().as_ref(), + )?; + filter.states = [PublicSandboxState::Running].into_iter().collect(); + filter.limit = NonZeroU32::MAX; + filter.after = None; + let page = state.service().list(&filter).await?; + let records = page + .records + .iter() + .rev() + .filter_map(|record| ListedSandboxResponse::from_record(record, account.client_id.clone())) + .collect(); + Ok(Json(records)) +} + +pub async fn get( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, +) -> Result, ApiError> { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let record = state.service().get(&account.owner_id, &sandbox_id).await?; + let response = SandboxDetailResponse::from_record( + &record, + account.client_id, + state.domain().map(str::to_string), + ) + .ok_or_else(ApiError::not_found)?; + Ok(Json(response)) +} + +pub async fn set_timeout( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + body: Result, JsonRejection>, +) -> Result { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + state + .service() + .set_timeout(&account.owner_id, &sandbox_id, body?.0.timeout) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn refresh( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + body: Result, JsonRejection>, +) -> Result { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let body = match body { + Ok(Json(body)) => body, + Err(JsonRejection::MissingJsonContentType(_)) => RefreshBody::default(), + Err(error) => return Err(error.into()), + }; + state + .service() + .refresh_timeout(&account.owner_id, &sandbox_id, body.duration()?) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn get_metrics( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + RawQuery(raw_query): RawQuery, +) -> Result>, ApiError> { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let range = parse_metric_range(raw_query.as_deref())?; + let metrics = state + .service() + .current_metric(&account.owner_id, &sandbox_id) + .await? + .filter(|metric| range.contains(metric.timestamp.timestamp())) + .map(SandboxMetricResponse::from) + .into_iter() + .collect(); + Ok(Json(metrics)) +} + +pub async fn get_metrics_batch( + State(state): State, + Extension(account): Extension, + RawQuery(raw_query): RawQuery, +) -> Result, ApiError> { + let sandbox_ids = parse_metric_sandbox_ids(raw_query.as_deref())?; + let service = state.service(); + let owner_id = account.owner_id; + let metrics = stream::iter(sandbox_ids.into_iter().map(|sandbox_id| { + let owner_id = owner_id.clone(); + async move { + let metric = service.current_metric(&owner_id, &sandbox_id).await; + (sandbox_id, metric) + } + })) + .buffer_unordered(MAX_CONCURRENT_METRIC_REQUESTS) + .collect::>() + .await; + let mut sandboxes = BTreeMap::new(); + for (sandbox_id, metric) in metrics { + match metric { + Ok(Some(metric)) => { + sandboxes.insert(sandbox_id.to_string(), SandboxMetricResponse::from(metric)); + } + Ok(None) | Err(ControlServiceError::NotFound(_)) => {} + Err(error) => return Err(error.into()), + } + } + Ok(Json(SandboxesWithMetricsResponse { sandboxes })) +} + +pub async fn kill( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, +) -> Result { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + if state.service().kill(&account.owner_id, &sandbox_id).await? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found()) + } +} + +fn parse_sandbox_id(value: String) -> Result { + SandboxId::new(value).map_err(|_| ApiError::bad_request("Invalid sandbox ID")) +} diff --git a/src/compat/src/http/logs.rs b/src/compat/src/http/logs.rs new file mode 100644 index 00000000..15694ed0 --- /dev/null +++ b/src/compat/src/http/logs.rs @@ -0,0 +1,326 @@ +use std::collections::BTreeMap; + +use axum::extract::{Extension, Path, RawQuery, State}; +use axum::Json; +use chrono::SecondsFormat; +use serde::Serialize; + +use crate::control::{SandboxId, SandboxLog}; + +use super::error::ApiError; +use super::router::LifecycleHttpState; +use super::AuthenticatedAccount; + +const DEFAULT_LOG_LIMIT: u32 = 1_000; +const V2_MAX_LOG_LIMIT: u32 = 1_000; + +pub async fn legacy( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + RawQuery(raw_query): RawQuery, +) -> Result, ApiError> { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let query = LogQuery::parse_legacy(raw_query.as_deref())?; + let logs = state.service().logs(&account.owner_id, &sandbox_id).await?; + let entries = select_logs(&logs, &query); + let legacy_logs = entries + .iter() + .map(|entry| LegacySandboxLogResponse { + timestamp: entry.timestamp.clone(), + line: serde_json::json!({ + "level": entry.level, + "logger": "a3s-box-runtime", + "message": entry.message, + "stream": entry.fields.get("stream"), + }) + .to_string(), + }) + .collect(); + Ok(Json(LegacySandboxLogsResponse { + logs: legacy_logs, + log_entries: entries, + })) +} + +pub async fn v2( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + RawQuery(raw_query): RawQuery, +) -> Result, ApiError> { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let query = LogQuery::parse_v2(raw_query.as_deref())?; + let logs = state.service().logs(&account.owner_id, &sandbox_id).await?; + Ok(Json(SandboxLogsV2Response { + logs: select_logs(&logs, &query), + })) +} + +fn parse_sandbox_id(value: String) -> Result { + SandboxId::new(value).map_err(|_| ApiError::bad_request("Invalid sandbox ID")) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LogsDirection { + Forward, + Backward, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "lowercase")] +enum LogLevel { + Debug, + Info, + Warn, + Error, +} + +#[derive(Debug)] +struct LogQuery { + cursor_millis: Option, + limit: u32, + direction: LogsDirection, + minimum_level: LogLevel, + search: Option, +} + +impl LogQuery { + fn parse_legacy(raw_query: Option<&str>) -> Result { + let mut cursor_millis = None; + let mut limit = None; + for (name, value) in url::form_urlencoded::parse(raw_query.unwrap_or_default().as_bytes()) { + match name.as_ref() { + "start" if cursor_millis.is_none() => { + cursor_millis = Some(parse_non_negative_i64(&value, "start")?); + } + "limit" if limit.is_none() => { + limit = Some(parse_i32_limit(&value, "limit")?); + } + "start" | "limit" => { + return Err(ApiError::bad_request( + "sandbox log query parameters must not be repeated", + )); + } + _ => return Err(ApiError::bad_request("unknown sandbox logs parameter")), + } + } + Ok(Self { + cursor_millis, + limit: limit.unwrap_or(DEFAULT_LOG_LIMIT), + direction: LogsDirection::Forward, + minimum_level: LogLevel::Debug, + search: None, + }) + } + + fn parse_v2(raw_query: Option<&str>) -> Result { + let mut cursor_millis = None; + let mut limit = None; + let mut direction = None; + let mut minimum_level = None; + let mut search = None; + for (name, value) in url::form_urlencoded::parse(raw_query.unwrap_or_default().as_bytes()) { + match name.as_ref() { + "cursor" if cursor_millis.is_none() => { + cursor_millis = Some(parse_non_negative_i64(&value, "cursor")?); + } + "limit" if limit.is_none() => { + let parsed = parse_i32_limit(&value, "limit")?; + if parsed > V2_MAX_LOG_LIMIT { + return Err(ApiError::bad_request( + "log limit must be between 0 and 1000", + )); + } + limit = Some(parsed); + } + "direction" if direction.is_none() => { + direction = Some(match value.as_ref() { + "forward" => LogsDirection::Forward, + "backward" => LogsDirection::Backward, + _ => return Err(ApiError::bad_request("invalid sandbox log direction")), + }); + } + "level" if minimum_level.is_none() => { + minimum_level = Some(match value.as_ref() { + "debug" => LogLevel::Debug, + "info" => LogLevel::Info, + "warn" => LogLevel::Warn, + "error" => LogLevel::Error, + _ => return Err(ApiError::bad_request("invalid sandbox log level")), + }); + } + "search" if search.is_none() => { + if value.chars().count() > 256 { + return Err(ApiError::bad_request( + "sandbox log search must not exceed 256 characters", + )); + } + search = Some(value.into_owned()); + } + "cursor" | "limit" | "direction" | "level" | "search" => { + return Err(ApiError::bad_request( + "sandbox log query parameters must not be repeated", + )); + } + _ => return Err(ApiError::bad_request("unknown sandbox logs parameter")), + } + } + Ok(Self { + cursor_millis, + limit: limit.unwrap_or(DEFAULT_LOG_LIMIT), + direction: direction.unwrap_or(LogsDirection::Forward), + minimum_level: minimum_level.unwrap_or(LogLevel::Debug), + search, + }) + } +} + +fn parse_non_negative_i64(value: &str, name: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value >= 0) + .ok_or_else(|| ApiError::bad_request(format!("{name} must be a non-negative integer"))) +} + +fn parse_i32_limit(value: &str, name: &str) -> Result { + value + .parse::() + .ok() + .filter(|value| *value >= 0) + .map(|value| value as u32) + .ok_or_else(|| ApiError::bad_request(format!("{name} must be a non-negative integer"))) +} + +fn select_logs(logs: &[SandboxLog], query: &LogQuery) -> Vec { + let mut ordered = logs.iter().collect::>(); + ordered.sort_by_key(|log| log.timestamp); + let include = |log: &&SandboxLog| { + let timestamp = log.timestamp.timestamp_millis(); + let in_range = match (query.direction, query.cursor_millis) { + (LogsDirection::Forward, Some(cursor)) => timestamp >= cursor, + (LogsDirection::Backward, Some(cursor)) => timestamp <= cursor, + (_, None) => true, + }; + let level = level_for_stream(&log.stream); + in_range + && level >= query.minimum_level + && query + .search + .as_ref() + .is_none_or(|search| log.message.contains(search)) + }; + let convert = |log: &SandboxLog| SandboxLogEntryResponse { + timestamp: log.timestamp.to_rfc3339_opts(SecondsFormat::AutoSi, true), + level: level_for_stream(&log.stream), + message: log.message.clone(), + fields: BTreeMap::from([ + ("logger".to_string(), "a3s-box-runtime".to_string()), + ("stream".to_string(), log.stream.clone()), + ]), + }; + + match query.direction { + LogsDirection::Forward => ordered + .into_iter() + .filter(include) + .take(query.limit as usize) + .map(convert) + .collect(), + LogsDirection::Backward => ordered + .into_iter() + .rev() + .filter(include) + .take(query.limit as usize) + .map(convert) + .collect(), + } +} + +fn level_for_stream(stream: &str) -> LogLevel { + if stream == "stderr" { + LogLevel::Error + } else { + LogLevel::Info + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacySandboxLogsResponse { + logs: Vec, + log_entries: Vec, +} + +#[derive(Debug, Serialize)] +struct LegacySandboxLogResponse { + timestamp: String, + line: String, +} + +#[derive(Debug, Clone, Serialize)] +struct SandboxLogEntryResponse { + timestamp: String, + level: LogLevel, + message: String, + fields: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct SandboxLogsV2Response { + logs: Vec, +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + + use super::*; + + fn log(second: u32, stream: &str, message: &str) -> SandboxLog { + SandboxLog { + timestamp: Utc + .with_ymd_and_hms(2026, 7, 14, 12, 0, second) + .single() + .unwrap(), + stream: stream.to_string(), + message: message.to_string(), + } + } + + #[test] + fn filters_v2_logs_by_cursor_direction_level_and_search() { + let logs = vec![ + log(2, "stdout", "ready"), + log(0, "stdout", "starting"), + log(1, "stderr", "failed once"), + ]; + let raw_query = format!( + "cursor={}&limit=1&direction=backward&level=error&search=failed", + logs[0].timestamp.timestamp_millis() + ); + let query = LogQuery::parse_v2(Some(&raw_query)).unwrap(); + let selected = select_logs(&logs, &query); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].message, "failed once"); + assert_eq!(selected[0].level, LogLevel::Error); + + let forward = select_logs(&logs, &LogQuery::parse_v2(None).unwrap()); + assert_eq!( + forward + .iter() + .map(|entry| entry.message.as_str()) + .collect::>(), + vec!["starting", "failed once", "ready"] + ); + } + + #[test] + fn validates_log_query_bounds_and_duplicates() { + assert!(LogQuery::parse_legacy(Some("limit=-1")).is_err()); + assert!(LogQuery::parse_v2(Some("limit=1001")).is_err()); + assert!(LogQuery::parse_v2(Some("direction=sideways")).is_err()); + assert!(LogQuery::parse_v2(Some("search=a&search=b")).is_err()); + } +} diff --git a/src/compat/src/http/mod.rs b/src/compat/src/http/mod.rs new file mode 100644 index 00000000..e2c651b8 --- /dev/null +++ b/src/compat/src/http/mod.rs @@ -0,0 +1,25 @@ +mod account; +mod auth; +mod cursor; +mod dto; +mod error; +mod lifecycle; +mod logs; +mod router; +mod snapshots; +mod volume_content; +mod volumes; + +pub use account::{ + CredentialHash, CredentialHashError, CredentialHashResult, HashedAccountCredential, + HashedCredentialVerifier, +}; +pub use auth::{ + AuthenticatedAccount, AuthenticationError, AuthenticationResult, CredentialScheme, + CredentialVerifier, PresentedCredential, +}; +pub use cursor::{CursorDecoder, CursorError, CursorResult, RejectingCursorDecoder}; +pub use router::{lifecycle_router, LifecycleHttpConfig, LifecycleHttpState}; + +#[cfg(test)] +mod tests; diff --git a/src/compat/src/http/router.rs b/src/compat/src/http/router.rs new file mode 100644 index 00000000..f48bac15 --- /dev/null +++ b/src/compat/src/http/router.rs @@ -0,0 +1,174 @@ +use std::sync::Arc; + +use axum::extract::{DefaultBodyLimit, State}; +use axum::http::Request; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; + +use crate::control::ControlService; +use crate::snapshot::SnapshotService; +use crate::volume::VolumeService; + +use super::auth::{CredentialVerifier, PresentedCredential}; +use super::cursor::CursorDecoder; +use super::error::ApiError; +use super::lifecycle; +use super::logs; +use super::snapshots; +use super::volume_content; +use super::volumes; + +#[derive(Debug, Clone)] +pub struct LifecycleHttpConfig { + pub domain: Option, + pub max_json_bytes: usize, +} + +impl Default for LifecycleHttpConfig { + fn default() -> Self { + Self { + domain: None, + max_json_bytes: 1024 * 1024, + } + } +} + +#[derive(Clone)] +pub struct LifecycleHttpState { + service: Arc, + verifier: Arc, + cursors: Arc, + config: LifecycleHttpConfig, + volumes: Option>, + snapshots: Option>, +} + +impl LifecycleHttpState { + pub fn new( + service: Arc, + verifier: Arc, + cursors: Arc, + config: LifecycleHttpConfig, + ) -> Self { + Self { + service, + verifier, + cursors, + config, + volumes: None, + snapshots: None, + } + } + + pub fn with_volume_service(mut self, volumes: Arc) -> Self { + self.volumes = Some(volumes); + self + } + + pub fn with_snapshot_service(mut self, snapshots: Arc) -> Self { + self.snapshots = Some(snapshots); + self + } + + pub(crate) fn service(&self) -> &ControlService { + &self.service + } + + pub(crate) fn cursors(&self) -> &Arc { + &self.cursors + } + + pub(crate) fn domain(&self) -> Option<&str> { + self.config.domain.as_deref() + } + + pub(crate) fn volume_service(&self) -> Result<&VolumeService, ApiError> { + self.volumes.as_deref().ok_or_else(ApiError::internal) + } + + pub(crate) fn snapshot_service(&self) -> Result<&SnapshotService, ApiError> { + self.snapshots.as_deref().ok_or_else(ApiError::internal) + } +} + +pub fn lifecycle_router(state: LifecycleHttpState) -> Router { + let max_json_bytes = state.config.max_json_bytes; + let control = Router::new() + .route( + "/sandboxes", + get(lifecycle::list_running).post(lifecycle::create), + ) + .route("/sandboxes/metrics", get(lifecycle::get_metrics_batch)) + .route("/v2/sandboxes", get(lifecycle::list)) + .route( + "/sandboxes/:sandbox_id", + get(lifecycle::get).delete(lifecycle::kill), + ) + .route("/sandboxes/:sandbox_id/connect", post(lifecycle::connect)) + .route("/sandboxes/:sandbox_id/logs", get(logs::legacy)) + .route("/sandboxes/:sandbox_id/pause", post(lifecycle::pause)) + .route("/sandboxes/:sandbox_id/resume", post(lifecycle::resume)) + .route( + "/sandboxes/:sandbox_id/metrics", + get(lifecycle::get_metrics), + ) + .route("/sandboxes/:sandbox_id/refreshes", post(lifecycle::refresh)) + .route("/sandboxes/:sandbox_id/snapshots", post(snapshots::create)) + .route( + "/sandboxes/:sandbox_id/timeout", + post(lifecycle::set_timeout), + ) + .route("/v2/sandboxes/:sandbox_id/logs", get(logs::v2)) + .route("/volumes", get(volumes::list).post(volumes::create)) + .route( + "/volumes/:volume_id", + get(volumes::get).delete(volumes::delete), + ) + .route("/snapshots", get(snapshots::list)) + .route( + "/templates/*template_id", + axum::routing::delete(snapshots::delete), + ) + .fallback(fallback) + .route_layer(middleware::from_fn_with_state(state.clone(), authenticate)); + let content = Router::new() + .route( + "/volumecontent/:volume_id/path", + get(volume_content::stat) + .patch(volume_content::update_metadata) + .delete(volume_content::remove), + ) + .route( + "/volumecontent/:volume_id/dir", + get(volume_content::list).post(volume_content::make_dir), + ) + .route( + "/volumecontent/:volume_id/file", + get(volume_content::read_file).put(volume_content::write_file), + ); + control + .merge(content) + .layer(DefaultBodyLimit::max(max_json_bytes)) + .with_state(state) +} + +async fn authenticate( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + let result = async { + let credential = PresentedCredential::from_headers(request.headers())?; + let account = state.verifier.verify(&credential).await?; + request.extensions_mut().insert(account); + Ok::<_, ApiError>(next.run(request).await) + } + .await; + result.unwrap_or_else(IntoResponse::into_response) +} + +async fn fallback() -> ApiError { + ApiError::not_found() +} diff --git a/src/compat/src/http/snapshots.rs b/src/compat/src/http/snapshots.rs new file mode 100644 index 00000000..89c167de --- /dev/null +++ b/src/compat/src/http/snapshots.rs @@ -0,0 +1,162 @@ +use std::num::NonZeroU32; + +use axum::extract::{rejection::JsonRejection, Extension, Path, RawQuery, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::IntoResponse; +use axum::Json; +use base64::Engine; +use serde::{Deserialize, Serialize}; + +use crate::control::SandboxId; +use crate::snapshot::{SnapshotCursor, SnapshotRecord}; + +use super::error::ApiError; +use super::router::LifecycleHttpState; +use super::AuthenticatedAccount; + +const DEFAULT_LIMIT: u32 = 100; +const MAX_LIMIT: u32 = 1_000; + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateSnapshotBody { + name: Option, +} + +#[derive(Debug, Serialize)] +pub struct SnapshotResponse { + #[serde(rename = "snapshotID")] + snapshot_id: String, + names: Vec, +} + +impl From<&SnapshotRecord> for SnapshotResponse { + fn from(record: &SnapshotRecord) -> Self { + Self { + snapshot_id: record.reference().to_string(), + names: record.names(), + } + } +} + +pub async fn create( + State(state): State, + Extension(account): Extension, + Path(sandbox_id): Path, + body: Result, JsonRejection>, +) -> Result { + let sandbox_id = parse_sandbox_id(sandbox_id)?; + let body = body?.0; + let snapshot = state + .service() + .create_snapshot(&account.owner_id, &sandbox_id, body.name.as_deref()) + .await?; + Ok((StatusCode::CREATED, Json(SnapshotResponse::from(&snapshot)))) +} + +pub async fn list( + State(state): State, + Extension(account): Extension, + RawQuery(raw_query): RawQuery, +) -> Result { + let query = parse_list_query(raw_query.as_deref())?; + let page = state + .snapshot_service()? + .list( + &account.owner_id, + query.sandbox_id.as_ref(), + query.limit, + query.after.as_ref(), + ) + .await?; + let mut headers = HeaderMap::new(); + if let Some(cursor) = page.next.as_ref() { + let encoded = encode_cursor(cursor)?; + headers.insert( + "x-next-token", + HeaderValue::from_str(&encoded).map_err(|_| ApiError::internal())?, + ); + } + let snapshots = page + .records + .iter() + .map(SnapshotResponse::from) + .collect::>(); + Ok((headers, Json(snapshots))) +} + +pub async fn delete( + State(state): State, + Extension(account): Extension, + Path(template_id): Path, +) -> Result { + let reference = template_id.trim_start_matches('/'); + if reference.is_empty() { + return Err(ApiError::snapshot_not_found()); + } + if state + .snapshot_service()? + .delete(&account.owner_id, reference) + .await? + { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::snapshot_not_found()) + } +} + +struct SnapshotListQuery { + sandbox_id: Option, + limit: NonZeroU32, + after: Option, +} + +fn parse_list_query(raw_query: Option<&str>) -> Result { + let mut sandbox_id = None; + let mut limit = None; + let mut after = None; + for (key, value) in url::form_urlencoded::parse(raw_query.unwrap_or_default().as_bytes()) { + match key.as_ref() { + "sandboxID" if sandbox_id.is_none() => { + sandbox_id = Some(parse_sandbox_id(value.into_owned())?); + } + "limit" if limit.is_none() => { + let parsed = value + .parse::() + .ok() + .filter(|value| *value <= MAX_LIMIT) + .and_then(NonZeroU32::new) + .ok_or_else(|| { + ApiError::bad_request(format!( + "Snapshot limit must be between 1 and {MAX_LIMIT}" + )) + })?; + limit = Some(parsed); + } + "nextToken" if after.is_none() => after = Some(decode_cursor(&value)?), + _ => return Err(ApiError::bad_request("Invalid snapshot list query")), + } + } + Ok(SnapshotListQuery { + sandbox_id, + limit: limit.unwrap_or(NonZeroU32::new(DEFAULT_LIMIT).ok_or_else(ApiError::internal)?), + after, + }) +} + +fn parse_sandbox_id(value: String) -> Result { + SandboxId::new(value).map_err(|_| ApiError::not_found()) +} + +fn encode_cursor(cursor: &SnapshotCursor) -> Result { + let bytes = serde_json::to_vec(cursor).map_err(|_| ApiError::internal())?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) +} + +fn decode_cursor(value: &str) -> Result { + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| ApiError::bad_request("Invalid snapshot pagination cursor"))?; + serde_json::from_slice(&bytes) + .map_err(|_| ApiError::bad_request("Invalid snapshot pagination cursor")) +} diff --git a/src/compat/src/http/tests.rs b/src/compat/src/http/tests.rs new file mode 100644 index 00000000..53ba3eed --- /dev/null +++ b/src/compat/src/http/tests.rs @@ -0,0 +1,725 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use axum::Router; +use serde_json::{json, Value}; +use tower::ServiceExt; + +use crate::control::test_support::TestHarness; +use crate::volume::tests::support::ServiceHarness as VolumeServiceHarness; +use crate::volume::VolumeService; + +use super::*; + +struct TestCredentialVerifier; + +#[async_trait] +impl CredentialVerifier for TestCredentialVerifier { + async fn verify( + &self, + credential: &PresentedCredential, + ) -> AuthenticationResult { + if credential.scheme() != CredentialScheme::ApiKey { + return Err(AuthenticationError::Invalid); + } + let owner_id = match credential.expose_secret() { + "e2b_a1b2c3" => "owner-1", + "e2b_b2c3d4" => "owner-2", + _ => return Err(AuthenticationError::Invalid), + }; + Ok(AuthenticatedAccount { + owner_id: owner_id.to_string(), + client_id: "fixture-client".to_string(), + }) + } +} + +struct TestCursorDecoder; + +impl CursorDecoder for TestCursorDecoder { + fn decode(&self, value: &str) -> CursorResult> { + if value == "cursor-0" { + Ok(None) + } else { + Err(CursorError::Invalid) + } + } +} + +fn app() -> Router { + let harness = TestHarness::new(); + lifecycle_router( + LifecycleHttpState::new( + harness.service, + Arc::new(TestCredentialVerifier), + Arc::new(TestCursorDecoder), + LifecycleHttpConfig { + domain: Some("fixture.invalid:3443".to_string()), + ..LifecycleHttpConfig::default() + }, + ) + .with_snapshot_service(harness.snapshots), + ) +} + +fn app_with_volumes(volumes: Arc) -> Router { + let harness = TestHarness::new(); + lifecycle_router( + LifecycleHttpState::new( + harness.service, + Arc::new(TestCredentialVerifier), + Arc::new(TestCursorDecoder), + LifecycleHttpConfig { + domain: Some("fixture.invalid:3443".to_string()), + ..LifecycleHttpConfig::default() + }, + ) + .with_volume_service(volumes) + .with_snapshot_service(harness.snapshots), + ) +} + +#[tokio::test] +async fn router_serves_owner_scoped_volume_control_and_bearer_content() { + let volumes = VolumeServiceHarness::new(); + let app = app_with_volumes(Arc::new(volumes.service.clone())); + + let response = send( + &app, + Method::POST, + "/volumes", + Some(json!({"name": "data"})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let created = body_json(response).await; + let volume_id = created["volumeID"].as_str().unwrap(); + let token = created["token"].as_str().unwrap(); + assert_eq!(created["name"], "data"); + + let response = send(&app, Method::GET, "/volumes", None, true).await; + assert_eq!(response.status(), StatusCode::OK); + let listed = body_json(response).await; + assert_eq!(listed.as_array().unwrap().len(), 1); + assert_eq!(listed[0]["volumeID"], volume_id); + assert!(listed[0].get("token").is_none()); + + let response = send_raw( + &app, + Method::GET, + &format!("/volumes/{volume_id}"), + Body::empty(), + &[("x-api-key", "e2b_b2c3d4")], + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let authorization = format!("Bearer {token}"); + let response = send_raw( + &app, + Method::POST, + &format!("/volumecontent/{volume_id}/dir?path=%2Fnested%2Fdeep&force=true&mode=493"), + Body::empty(), + &[("authorization", &authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(body_json(response).await["path"], "/nested/deep"); + + let response = send_raw( + &app, + Method::PUT, + &format!("/volumecontent/{volume_id}/file?path=%2Fnested%2Fdeep%2Fvalue.txt"), + Body::from("hello-volume"), + &[ + ("authorization", &authorization), + ("content-type", "application/octet-stream"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(body_json(response).await["size"], 12); + + let response = send_raw( + &app, + Method::GET, + &format!("/volumecontent/{volume_id}/file?path=%2Fnested%2Fdeep%2Fvalue.txt"), + Body::empty(), + &[("authorization", &authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(&body_bytes(response).await[..], b"hello-volume"); + + let response = send_raw( + &app, + Method::PATCH, + &format!("/volumecontent/{volume_id}/path?path=%2Fnested%2Fdeep%2Fvalue.txt"), + Body::from(r#"{"mode":384}"#), + &[ + ("authorization", &authorization), + ("content-type", "application/json"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["mode"], 384); + + let response = send_raw( + &app, + Method::GET, + &format!("/volumecontent/{volume_id}/dir?path=%2F&depth=3"), + Body::empty(), + &[("authorization", &authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let entries = body_json(response).await; + assert_eq!(entries.as_array().unwrap().len(), 3); + assert_eq!(entries[2]["path"], "/nested/deep/value.txt"); + + let response = send_raw( + &app, + Method::GET, + &format!("/volumecontent/{volume_id}/path?path=%2F"), + Body::empty(), + &[("authorization", "Bearer wrong-token")], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(body_json(response).await["code"], "forbidden"); + + let response = send_raw( + &app, + Method::GET, + &format!("/volumecontent/{volume_id}/path?path=%2F"), + Body::empty(), + &[("x-api-key", "e2b_a1b2c3")], + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let response = send( + &app, + Method::DELETE, + &format!("/volumes/{volume_id}"), + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send_raw( + &app, + Method::GET, + &format!("/volumecontent/{volume_id}/path?path=%2F"), + Body::empty(), + &[("authorization", &authorization)], + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn router_serves_owner_scoped_snapshot_restore_pagination_and_delete() { + let app = app(); + let response = send( + &app, + Method::POST, + "/sandboxes", + Some(json!({"templateID": "fixture-template", "timeout": 300})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(body_json(response).await["sandboxID"], "sandbox-1"); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/snapshots", + Some(json!({"name": "fixture-state"})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let named = body_json(response).await; + let named_id = named["snapshotID"].as_str().unwrap().to_string(); + assert!(named_id.starts_with("a3s-")); + assert!(named_id.ends_with("/fixture-state:default")); + assert_eq!(named["names"], json!([named_id.clone()])); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/snapshots", + Some(json!({})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let unnamed = body_json(response).await; + assert!(unnamed["snapshotID"].as_str().unwrap().starts_with("snap-")); + assert!(unnamed["snapshotID"] + .as_str() + .unwrap() + .ends_with(":default")); + assert_eq!(unnamed["names"], json!([])); + + let response = send_raw( + &app, + Method::POST, + "/sandboxes/sandbox-1/snapshots", + Body::from(r#"{"name":"stolen"}"#), + &[ + ("x-api-key", "e2b_b2c3d4"), + ("content-type", "application/json"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let response = send_raw( + &app, + Method::GET, + "/snapshots", + Body::empty(), + &[("x-api-key", "e2b_b2c3d4")], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!(body_json(response).await.as_array().unwrap().is_empty()); + + let response = send( + &app, + Method::GET, + "/snapshots?sandboxID=sandbox-1&limit=1", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let next = response + .headers() + .get("x-next-token") + .unwrap() + .to_str() + .unwrap() + .to_string(); + let first_page = body_json(response).await; + assert_eq!(first_page.as_array().unwrap().len(), 1); + let response = send( + &app, + Method::GET, + &format!("/snapshots?sandboxID=sandbox-1&limit=1&nextToken={next}"), + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().get("x-next-token").is_none()); + let second_page = body_json(response).await; + assert_eq!(second_page.as_array().unwrap().len(), 1); + assert_ne!(first_page[0]["snapshotID"], second_page[0]["snapshotID"]); + + let response = send(&app, Method::DELETE, "/sandboxes/sandbox-1", None, true).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = send( + &app, + Method::POST, + "/sandboxes", + Some(json!({"templateID": named_id.clone(), "timeout": 300})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(body_json(response).await["sandboxID"], "sandbox-2"); + + let encoded_id = url::form_urlencoded::byte_serialize(named_id.as_bytes()).collect::(); + let delete_uri = format!("/templates/{encoded_id}"); + let response = send(&app, Method::DELETE, &delete_uri, None, true).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let response = send(&app, Method::DELETE, "/sandboxes/sandbox-2", None, true).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = send(&app, Method::DELETE, &delete_uri, None, true).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = send(&app, Method::DELETE, &delete_uri, None, true).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn router_serves_the_pinned_official_lifecycle_shape() { + let app = app(); + let create_body = json!({ + "allow_internet_access": false, + "autoPause": true, + "autoPauseMemory": false, + "autoResume": {"enabled": false}, + "envVars": {"ALPHA": "one", "BETA": "two"}, + "metadata": {"purpose": "fixture", "team": "alpha beta"}, + "secure": true, + "templateID": "fixture-template", + "timeout": 321 + }); + let response = send(&app, Method::POST, "/sandboxes", Some(create_body), true).await; + assert_eq!(response.status(), StatusCode::CREATED); + let created = body_json(response).await; + assert_eq!(created["sandboxID"], "sandbox-1"); + assert_eq!(created["clientID"], "fixture-client"); + assert_eq!(created["envdAccessToken"], "fixture-envd-token"); + assert_eq!(created["trafficAccessToken"], "fixture-traffic-token"); + assert_eq!(created["domain"], "fixture.invalid:3443"); + + let response = send( + &app, + Method::GET, + "/sandboxes/sandbox-1/logs?start=0&limit=2", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let logs = body_json(response).await; + assert_eq!(logs["logs"].as_array().unwrap().len(), 2); + assert_eq!(logs["logEntries"].as_array().unwrap().len(), 2); + assert_eq!(logs["logEntries"][0]["message"], "starting"); + assert_eq!(logs["logEntries"][1]["level"], "error"); + let legacy_line: Value = + serde_json::from_str(logs["logs"][1]["line"].as_str().unwrap()).unwrap(); + assert_eq!(legacy_line["logger"], "a3s-box-runtime"); + assert_eq!(legacy_line["stream"], "stderr"); + + let response = send( + &app, + Method::GET, + "/v2/sandboxes/sandbox-1/logs?direction=backward&limit=1&level=error&search=failed", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let logs = body_json(response).await; + assert_eq!(logs["logs"].as_array().unwrap().len(), 1); + assert_eq!(logs["logs"][0]["message"], "failed once"); + assert_eq!(logs["logs"][0]["fields"]["stream"], "stderr"); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/connect", + Some(json!({"timeout": 222})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/pause", + Some(json!({"memory": true})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send(&app, Method::POST, "/sandboxes/sandbox-1/pause", None, true).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + + let response = send(&app, Method::GET, "/sandboxes/sandbox-1", None, true).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["state"], "paused"); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/resume", + Some(json!({"timeout": 400, "autoPause": true})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send(&app, Method::POST, "/sandboxes/sandbox-1/pause", None, true).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/connect", + Some(json!({"timeout": 222})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + &app, + Method::GET, + "/v2/sandboxes?limit=2&metadata=team%3Dalpha%252520beta&nextToken=cursor-0&state=running%2Cpaused", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let listed = body_json(response).await; + assert_eq!(listed.as_array().unwrap().len(), 1); + assert_eq!(listed[0]["sandboxID"], "sandbox-1"); + assert_eq!(listed[0]["state"], "running"); + + let response = send( + &app, + Method::GET, + "/sandboxes?metadata=team%3Dalpha%252520beta&state=paused", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await.as_array().unwrap().len(), 1); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/refreshes", + Some(json!({"duration": 60})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/refreshes", + Some(json!({})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/refreshes", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/refreshes", + Some(json!({"duration": 3_600, "futureField": true})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send(&app, Method::GET, "/sandboxes/sandbox-1", None, true).await; + assert_eq!(response.status(), StatusCode::OK); + let detail = body_json(response).await; + assert_eq!(detail["allowInternetAccess"], false); + assert_eq!(detail["lifecycle"]["onTimeout"], "pause"); + assert_eq!(detail["endAt"], "2026-07-14T13:00:00Z"); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/refreshes", + Some(json!({"duration": 3_601})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = send( + &app, + Method::POST, + "/sandboxes/sandbox-1/timeout", + Some(json!({"timeout": 123})), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send(&app, Method::DELETE, "/sandboxes/sandbox-1", None, true).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = send( + &app, + Method::DELETE, + "/sandboxes/missing-sandbox", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(body_json(response).await["code"], 404); +} + +#[tokio::test] +async fn router_serves_single_and_batch_runtime_metrics() { + let app = app(); + let response = send( + &app, + Method::POST, + "/sandboxes", + Some(json!({ + "templateID": "runtime-envd-template", + "timeout": 60 + })), + true, + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = send( + &app, + Method::GET, + "/sandboxes/sandbox-1/metrics", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let metrics = body_json(response).await; + assert_eq!(metrics.as_array().unwrap().len(), 1); + assert_eq!(metrics[0]["timestamp"], "2026-07-14T12:00:00Z"); + assert_eq!(metrics[0]["timestampUnix"], test_timestamp()); + assert_eq!(metrics[0]["cpuCount"], 2); + assert_eq!(metrics[0]["memCache"], 0); + assert_eq!(metrics[0]["diskTotal"], 1_073_741_824_u64); + + let response = send( + &app, + Method::GET, + "/sandboxes/sandbox-1/metrics?start=0&end=1", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!(body_json(response).await.as_array().unwrap().is_empty()); + + let response = send( + &app, + Method::GET, + "/sandboxes/metrics?sandbox_ids=sandbox-1,missing-sandbox", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let batch = body_json(response).await; + assert_eq!(batch["sandboxes"]["sandbox-1"]["cpuCount"], 2); + assert!(batch["sandboxes"].get("missing-sandbox").is_none()); + + let response = send( + &app, + Method::GET, + "/sandboxes/sandbox-1/metrics?start=2&end=1", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = send( + &app, + Method::GET, + "/sandboxes/missing-sandbox/metrics", + None, + true, + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +fn test_timestamp() -> i64 { + chrono::DateTime::parse_from_rfc3339("2026-07-14T12:00:00Z") + .unwrap() + .timestamp() +} + +#[tokio::test] +async fn router_requires_authentication_and_maps_invalid_json() { + let app = app(); + let response = send(&app, Method::GET, "/v2/sandboxes", None, false).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let request = Request::builder() + .method(Method::POST) + .uri("/sandboxes") + .header("x-api-key", "e2b_a1b2c3") + .header("content-type", "application/json") + .body(Body::from("{")) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn presented_credentials_redact_secret_debug_material() { + let headers = axum::http::HeaderMap::from_iter([( + axum::http::HeaderName::from_static("x-api-key"), + axum::http::HeaderValue::from_static("e2b_a1b2c3"), + )]); + let credential = PresentedCredential::from_headers(&headers).unwrap(); + let debug = format!("{credential:?}"); + assert!(!debug.contains("e2b_a1b2c3")); + assert!(debug.contains("REDACTED")); +} + +async fn send( + app: &Router, + method: Method, + uri: &str, + body: Option, + authenticated: bool, +) -> axum::response::Response { + let mut builder = Request::builder().method(method).uri(uri); + if authenticated { + builder = builder.header("x-api-key", "e2b_a1b2c3"); + } + let body = if let Some(body) = body { + builder = builder.header("content-type", "application/json"); + Body::from(serde_json::to_vec(&body).unwrap()) + } else { + Body::empty() + }; + app.clone() + .oneshot(builder.body(body).unwrap()) + .await + .unwrap() +} + +async fn body_json(response: axum::response::Response) -> Value { + let bytes = body_bytes(response).await; + serde_json::from_slice(&bytes).unwrap() +} + +async fn body_bytes(response: axum::response::Response) -> hyper::body::Bytes { + hyper::body::to_bytes(response.into_body()).await.unwrap() +} + +async fn send_raw( + app: &Router, + method: Method, + uri: &str, + body: Body, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut builder = Request::builder().method(method).uri(uri); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + app.clone() + .oneshot(builder.body(body).unwrap()) + .await + .unwrap() +} diff --git a/src/compat/src/http/volume_content.rs b/src/compat/src/http/volume_content.rs new file mode 100644 index 00000000..a64be2c5 --- /dev/null +++ b/src/compat/src/http/volume_content.rs @@ -0,0 +1,415 @@ +use std::collections::BTreeMap; + +use axum::body::{boxed, Body}; +use axum::extract::{rejection::JsonRejection, BodyStream, Path, RawQuery, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; + +use crate::control::SecretToken; +use crate::volume::{ + AuthorizedVolume, VolumeContentError, VolumeEntry, VolumeId, VolumeMetadataUpdate, + VolumeServiceError, MAX_DIRECTORY_DEPTH, +}; + +use super::router::LifecycleHttpState; + +pub async fn stat( + State(state): State, + Path(volume_id): Path, + headers: HeaderMap, + RawQuery(query): RawQuery, +) -> Result, VolumeApiError> { + let query = ContentQuery::parse(query.as_deref(), &["path"])?; + let volume = authorize(&state, &headers, volume_id).await?; + Ok(Json( + state + .volume_service() + .map_err(|_| VolumeApiError::internal())? + .filesystem() + .stat(&volume.root, query.path()?) + .await?, + )) +} + +pub async fn update_metadata( + State(state): State, + Path(volume_id): Path, + headers: HeaderMap, + RawQuery(query): RawQuery, + body: Result, JsonRejection>, +) -> Result, VolumeApiError> { + let query = ContentQuery::parse(query.as_deref(), &["path"])?; + let volume = authorize(&state, &headers, volume_id).await?; + let metadata = body.map_err(VolumeApiError::from)?.0.into(); + Ok(Json( + state + .volume_service() + .map_err(|_| VolumeApiError::internal())? + .filesystem() + .update_metadata(&volume.root, query.path()?, metadata) + .await?, + )) +} + +pub async fn remove( + State(state): State, + Path(volume_id): Path, + headers: HeaderMap, + RawQuery(query): RawQuery, +) -> Result { + let query = ContentQuery::parse(query.as_deref(), &["path"])?; + let volume = authorize(&state, &headers, volume_id).await?; + state + .volume_service() + .map_err(|_| VolumeApiError::internal())? + .filesystem() + .remove(&volume.root, query.path()?) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn list( + State(state): State, + Path(volume_id): Path, + headers: HeaderMap, + RawQuery(query): RawQuery, +) -> Result>, VolumeApiError> { + let query = ContentQuery::parse(query.as_deref(), &["path", "depth"])?; + let depth = query.optional_u32("depth")?.unwrap_or(1); + if depth > MAX_DIRECTORY_DEPTH { + return Err(VolumeApiError::bad_request(format!( + "depth cannot exceed {MAX_DIRECTORY_DEPTH}" + ))); + } + let volume = authorize(&state, &headers, volume_id).await?; + Ok(Json( + state + .volume_service() + .map_err(|_| VolumeApiError::internal())? + .filesystem() + .list(&volume.root, query.path()?, depth) + .await?, + )) +} + +pub async fn make_dir( + State(state): State, + Path(volume_id): Path, + headers: HeaderMap, + RawQuery(query): RawQuery, +) -> Result { + let query = ContentQuery::parse(query.as_deref(), &["path", "uid", "gid", "mode", "force"])?; + let path = query.path()?.to_string(); + let metadata = query.metadata()?; + let force = query.optional_bool("force")?.unwrap_or(false); + let volume = authorize(&state, &headers, volume_id).await?; + let entry = state + .volume_service() + .map_err(|_| VolumeApiError::internal())? + .filesystem() + .make_dir(&volume.root, &path, metadata, force) + .await?; + Ok((StatusCode::CREATED, Json(entry))) +} + +pub async fn read_file( + State(state): State, + Path(volume_id): Path, + headers: HeaderMap, + RawQuery(query): RawQuery, +) -> Result { + let query = ContentQuery::parse(query.as_deref(), &["path"])?; + let volume = authorize(&state, &headers, volume_id).await?; + let mut file = state + .volume_service() + .map_err(|_| VolumeApiError::internal())? + .filesystem() + .open_file(&volume.root, query.path()?) + .await?; + + let (mut sender, body) = Body::channel(); + tokio::spawn(async move { + loop { + let mut buffer = vec![0_u8; 64 * 1024]; + let read = match file.read(&mut buffer).await { + Ok(read) => read, + Err(_) => return, + }; + if read == 0 { + return; + } + buffer.truncate(read); + if sender.send_data(buffer.into()).await.is_err() { + return; + } + } + }); + let mut response = Response::new(boxed(body)); + response.headers_mut().insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/octet-stream"), + ); + Ok(response) +} + +pub async fn write_file( + State(state): State, + Path(volume_id): Path, + headers: HeaderMap, + RawQuery(query): RawQuery, + mut body: BodyStream, +) -> Result { + let query = ContentQuery::parse(query.as_deref(), &["path", "uid", "gid", "mode", "force"])?; + if headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + != Some("application/octet-stream") + { + return Err(VolumeApiError::bad_request( + "Content-Type must be application/octet-stream", + )); + } + let path = query.path()?.to_string(); + let metadata = query.metadata()?; + // Both pinned SDKs document overwrite as the default. `force=false` + // explicitly requests create-only behavior. + let force = query.optional_bool("force")?.unwrap_or(true); + let volume = authorize(&state, &headers, volume_id).await?; + let mut upload = state + .volume_service() + .map_err(|_| VolumeApiError::internal())? + .filesystem() + .begin_write(&volume.root, &path, metadata, force) + .await?; + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(|error| { + VolumeApiError::bad_request(format!("failed to read upload body: {error}")) + })?; + upload.write_all(&chunk).await?; + } + let entry = upload.finish().await?; + Ok((StatusCode::CREATED, Json(entry))) +} + +async fn authorize( + state: &LifecycleHttpState, + headers: &HeaderMap, + volume_id: String, +) -> Result { + let volume_id = VolumeId::new(volume_id).map_err(|_| VolumeApiError::not_found())?; + let authorization = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|value| !value.is_empty()) + .ok_or_else(VolumeApiError::unauthorized)?; + let token = + SecretToken::new(authorization.to_string()).map_err(|_| VolumeApiError::unauthorized())?; + state + .volume_service() + .map_err(|_| VolumeApiError::internal())? + .authorize(&volume_id, &token) + .await + .map_err(Into::into) +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MetadataBody { + uid: Option, + gid: Option, + mode: Option, +} + +impl From for VolumeMetadataUpdate { + fn from(value: MetadataBody) -> Self { + Self { + uid: value.uid, + gid: value.gid, + mode: value.mode, + } + } +} + +struct ContentQuery { + values: BTreeMap, +} + +impl ContentQuery { + fn parse(raw: Option<&str>, allowed: &[&str]) -> Result { + let mut values = BTreeMap::new(); + for (name, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { + if !allowed.contains(&name.as_ref()) { + return Err(VolumeApiError::bad_request(format!( + "unknown volume query parameter: {name}" + ))); + } + if values + .insert(name.into_owned(), value.into_owned()) + .is_some() + { + return Err(VolumeApiError::bad_request( + "volume query parameters must not be repeated", + )); + } + } + Ok(Self { values }) + } + + fn path(&self) -> Result<&str, VolumeApiError> { + self.values + .get("path") + .map(String::as_str) + .filter(|path| !path.is_empty()) + .ok_or_else(|| VolumeApiError::bad_request("path is required")) + } + + fn optional_u32(&self, name: &str) -> Result, VolumeApiError> { + self.values + .get(name) + .map(|value| { + value.parse::().map_err(|_| { + VolumeApiError::bad_request(format!("{name} must be an unsigned integer")) + }) + }) + .transpose() + } + + fn optional_bool(&self, name: &str) -> Result, VolumeApiError> { + self.values + .get(name) + .map(|value| match value.as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(VolumeApiError::bad_request(format!( + "{name} must be true or false" + ))), + }) + .transpose() + } + + fn metadata(&self) -> Result { + Ok(VolumeMetadataUpdate { + uid: self.optional_u32("uid")?, + gid: self.optional_u32("gid")?, + mode: self.optional_u32("mode")?, + }) + } +} + +#[derive(Debug)] +pub struct VolumeApiError { + status: StatusCode, + code: &'static str, + message: String, +} + +impl VolumeApiError { + fn bad_request(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "bad_request", + message: message.into(), + } + } + + fn unauthorized() -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + code: "unauthorized", + message: "Invalid volume authentication".to_string(), + } + } + + fn forbidden() -> Self { + Self { + status: StatusCode::FORBIDDEN, + code: "forbidden", + message: "Volume token does not authorize this volume".to_string(), + } + } + + fn not_found() -> Self { + Self { + status: StatusCode::NOT_FOUND, + code: "not_found", + message: "Volume or path not found".to_string(), + } + } + + fn conflict() -> Self { + Self { + status: StatusCode::CONFLICT, + code: "conflict", + message: "Volume path conflicts with an existing or active entry".to_string(), + } + } + + fn internal() -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "internal_server_error", + message: "Internal server error".to_string(), + } + } +} + +#[derive(Serialize)] +struct VolumeErrorBody { + code: &'static str, + message: String, +} + +impl IntoResponse for VolumeApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(VolumeErrorBody { + code: self.code, + message: self.message, + }), + ) + .into_response() + } +} + +impl From for VolumeApiError { + fn from(error: JsonRejection) -> Self { + Self::bad_request(format!("Invalid JSON request: {}", error.body_text())) + } +} + +impl From for VolumeApiError { + fn from(error: VolumeContentError) -> Self { + match error { + VolumeContentError::InvalidPath(message) => Self::bad_request(message), + VolumeContentError::NotFound => Self::not_found(), + VolumeContentError::Conflict => Self::conflict(), + VolumeContentError::PermissionDenied => Self::forbidden(), + VolumeContentError::Unsupported(_) | VolumeContentError::Unavailable(_) => { + Self::internal() + } + } + } +} + +impl From for VolumeApiError { + fn from(error: VolumeServiceError) -> Self { + match error { + VolumeServiceError::InvalidRequest(message) => Self::bad_request(message), + VolumeServiceError::NotFound => Self::not_found(), + VolumeServiceError::Forbidden => Self::forbidden(), + VolumeServiceError::Duplicate | VolumeServiceError::Conflict => Self::conflict(), + VolumeServiceError::Repository(_) + | VolumeServiceError::Runtime(_) + | VolumeServiceError::Credential(_) + | VolumeServiceError::Model(_) + | VolumeServiceError::Content(_) => Self::internal(), + } + } +} diff --git a/src/compat/src/http/volumes.rs b/src/compat/src/http/volumes.rs new file mode 100644 index 00000000..d8e1f1df --- /dev/null +++ b/src/compat/src/http/volumes.rs @@ -0,0 +1,110 @@ +use axum::extract::{rejection::JsonRejection, Extension, Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use serde::{Deserialize, Serialize}; + +use crate::volume::{VolumeConnection, VolumeId, VolumeRecord}; + +use super::error::ApiError; +use super::router::LifecycleHttpState; +use super::AuthenticatedAccount; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NewVolumeBody { + name: String, +} + +#[derive(Debug, Serialize)] +pub struct VolumeResponse { + #[serde(rename = "volumeID")] + volume_id: String, + name: String, +} + +impl From<&VolumeRecord> for VolumeResponse { + fn from(record: &VolumeRecord) -> Self { + Self { + volume_id: record.volume_id().to_string(), + name: record.name().to_string(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct VolumeWithTokenResponse { + #[serde(rename = "volumeID")] + volume_id: String, + name: String, + token: String, +} + +impl From for VolumeWithTokenResponse { + fn from(connection: VolumeConnection) -> Self { + Self { + volume_id: connection.record.volume_id().to_string(), + name: connection.record.name().to_string(), + token: connection.token.expose_secret().to_string(), + } + } +} + +pub async fn create( + State(state): State, + Extension(account): Extension, + body: Result, JsonRejection>, +) -> Result { + let connection = state + .volume_service()? + .create(&account.owner_id, &body?.0.name) + .await?; + Ok(( + StatusCode::CREATED, + Json(VolumeWithTokenResponse::from(connection)), + )) +} + +pub async fn list( + State(state): State, + Extension(account): Extension, +) -> Result>, ApiError> { + let volumes = state + .volume_service()? + .list(&account.owner_id) + .await? + .iter() + .map(VolumeResponse::from) + .collect(); + Ok(Json(volumes)) +} + +pub async fn get( + State(state): State, + Extension(account): Extension, + Path(volume_id): Path, +) -> Result, ApiError> { + let volume_id = parse_volume_id(volume_id)?; + let connection = state + .volume_service()? + .get(&account.owner_id, &volume_id) + .await?; + Ok(Json(connection.into())) +} + +pub async fn delete( + State(state): State, + Extension(account): Extension, + Path(volume_id): Path, +) -> Result { + let volume_id = parse_volume_id(volume_id)?; + state + .volume_service()? + .delete(&account.owner_id, &volume_id) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +fn parse_volume_id(value: String) -> Result { + VolumeId::new(value).map_err(|_| ApiError::volume_not_found()) +} diff --git a/src/compat/src/lib.rs b/src/compat/src/lib.rs new file mode 100644 index 00000000..4517e366 --- /dev/null +++ b/src/compat/src/lib.rs @@ -0,0 +1,19 @@ +//! E2B protocol compatibility service and conformance contract support. + +mod digest; +mod exports; +mod fixture; +mod model; +mod openapi; +mod proto; + +pub mod control; +pub mod envd; +pub mod gateway; +pub mod http; +pub mod production; +pub mod routing; +pub mod snapshot; +pub mod volume; + +pub use fixture::{generate_fixture, verify_fixture, FixturePaths}; diff --git a/src/compat/src/main.rs b/src/compat/src/main.rs new file mode 100644 index 00000000..c969911b --- /dev/null +++ b/src/compat/src/main.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use a3s_box_compat::{generate_fixture, verify_fixture, FixturePaths}; +use anyhow::{bail, Context, Result}; + +fn main() { + if let Err(error) = run() { + eprintln!("Error: {error:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let mut arguments = std::env::args().skip(1); + let action = arguments.next().unwrap_or_else(|| "verify".to_string()); + let mut root = None; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--root" => { + root = Some(PathBuf::from( + arguments.next().context("--root requires a path")?, + )); + } + _ => bail!("unknown argument {argument}"), + } + } + let paths = root + .map(FixturePaths::new) + .unwrap_or_else(FixturePaths::repository_default); + match action.as_str() { + "generate" => generate_fixture(&paths), + "verify" => verify_fixture(&paths), + _ => bail!("unknown action {action}; expected generate or verify"), + } +} diff --git a/src/compat/src/model.rs b/src/compat/src/model.rs new file mode 100644 index 00000000..305e56b9 --- /dev/null +++ b/src/compat/src/model.rs @@ -0,0 +1,206 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct SourceLock { + pub schema_version: u32, + pub compatibility: CompatibilityLock, + pub sources: BTreeMap, + pub artifacts: Vec, + pub files: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct CompatibilityLock { + pub id: String, + pub version: String, + pub control_plane_tags: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct UpstreamSource { + pub repository: String, + pub commit: String, + pub packages: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct LockedFile { + pub local_path: String, + pub source: String, + pub source_path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct ClientArtifact { + pub id: String, + pub source: String, + pub language: String, + pub package: String, + pub version: String, + pub url: String, + pub sha256: String, + pub integrity: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct ContractInventory { + pub schema_version: u32, + pub compatibility_id: String, + pub openapi: Vec, + pub protobuf: Vec, + pub mcp: JsonSchemaInventory, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct OpenApiInventory { + pub name: String, + pub openapi_version: String, + pub contract_version: String, + pub operations: Vec, + pub component_schemas: Vec, + pub fields: Vec, + pub authentication_headers: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct HttpOperation { + pub method: String, + pub path: String, + pub operation_id: Option, + pub tags: Vec, + pub parameters: Vec, + pub request_content_types: Vec, + pub responses: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct HttpParameter { + pub name: Option, + pub location: Option, + pub required: bool, + pub reference: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct HttpResponse { + pub status: String, + pub reference: Option, + pub content_types: Vec, + pub schema_references: Vec, + pub error: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct SchemaField { + pub pointer: String, + pub name: String, + pub required: bool, + pub field_type: Option, + pub format: Option, + pub reference: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct JsonSchemaInventory { + pub schema_id: Option, + pub title: Option, + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct ProtoFileInventory { + pub path: String, + pub package: String, + pub descriptor_digest: String, + pub services: Vec, + pub messages: Vec, + pub enums: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct ProtoService { + pub name: String, + pub methods: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct ProtoMethod { + pub name: String, + pub input_type: String, + pub output_type: String, + pub client_streaming: bool, + pub server_streaming: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct ProtoMessage { + pub name: String, + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct ProtoField { + pub name: String, + pub number: i32, + pub label: String, + pub field_type: String, + pub type_name: Option, + pub oneof: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct ProtoEnum { + pub name: String, + pub values: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct ProtoEnumValue { + pub name: String, + pub number: i32, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct PublicExportInventory { + pub schema_version: u32, + pub compatibility_id: String, + pub packages: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct PackageExports { + pub language: String, + pub package: String, + pub version: String, + pub symbols: Vec, + pub type_only_symbols: Vec, + pub reexports: Vec, + pub has_default_export: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct CompatibilityManifest { + pub schema_version: u32, + pub compatibility_id: String, + pub status: String, + pub full_compatibility: bool, + pub e2b_git_commit: String, + pub code_interpreter_git_commit: String, + pub python_e2b_version: String, + pub typescript_e2b_version: String, + pub python_code_interpreter_version: String, + pub typescript_code_interpreter_version: String, + pub control_openapi_digest: String, + pub envd_openapi_digest: String, + pub volume_content_openapi_digest: String, + pub process_descriptor_digest: String, + pub filesystem_descriptor_digest: String, + pub mcp_schema_digest: String, + pub contract_inventory_digest: String, + pub public_export_inventory_digest: String, + pub client_artifact_digests: BTreeMap, + pub a3s_compat_version: String, +} diff --git a/src/compat/src/openapi.rs b/src/compat/src/openapi.rs new file mode 100644 index 00000000..328cdd64 --- /dev/null +++ b/src/compat/src/openapi.rs @@ -0,0 +1,377 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde_json::Value; + +use crate::model::{ + HttpOperation, HttpParameter, HttpResponse, JsonSchemaInventory, OpenApiInventory, SchemaField, +}; + +const HTTP_METHODS: &[&str] = &[ + "delete", "get", "head", "options", "patch", "post", "put", "trace", +]; + +pub(crate) fn read_openapi( + path: &Path, + name: &str, + allowed_tags: Option<&BTreeSet>, +) -> Result { + let bytes = std::fs::read(path) + .with_context(|| format!("failed to read OpenAPI contract {}", path.display()))?; + let yaml: serde_yaml::Value = serde_yaml::from_slice(&bytes) + .with_context(|| format!("failed to parse OpenAPI contract {}", path.display()))?; + let document = serde_json::to_value(yaml) + .with_context(|| format!("failed to normalize OpenAPI contract {}", path.display()))?; + + let openapi_version = string_at(&document, &["openapi"]).unwrap_or_default(); + let contract_version = string_at(&document, &["info", "version"]).unwrap_or_default(); + let mut operations = collect_operations(&document, allowed_tags); + operations.sort_by(|left, right| { + (&left.path, &left.method, &left.operation_id).cmp(&( + &right.path, + &right.method, + &right.operation_id, + )) + }); + + let mut fields = Vec::new(); + collect_schema_fields(&document, "", &mut fields); + fields.sort(); + fields.dedup(); + + let mut authentication_headers = collect_authentication_headers(&document, &operations); + authentication_headers.sort(); + authentication_headers.dedup(); + + let mut component_schemas = document + .pointer("/components/schemas") + .and_then(Value::as_object) + .map(|schemas| schemas.keys().cloned().collect::>()) + .unwrap_or_default(); + component_schemas.sort(); + + Ok(OpenApiInventory { + name: name.to_string(), + openapi_version, + contract_version, + operations, + component_schemas, + fields, + authentication_headers, + }) +} + +pub(crate) fn read_json_schema(path: &Path) -> Result { + let bytes = std::fs::read(path) + .with_context(|| format!("failed to read JSON schema {}", path.display()))?; + let document: Value = serde_json::from_slice(&bytes) + .with_context(|| format!("failed to parse JSON schema {}", path.display()))?; + let mut fields = Vec::new(); + collect_schema_fields(&document, "", &mut fields); + fields.sort(); + fields.dedup(); + Ok(JsonSchemaInventory { + schema_id: value_string(document.get("$id").or_else(|| document.get("id"))), + title: value_string(document.get("title")), + fields, + }) +} + +fn collect_operations( + document: &Value, + allowed_tags: Option<&BTreeSet>, +) -> Vec { + let Some(paths) = document.get("paths").and_then(Value::as_object) else { + return Vec::new(); + }; + let mut operations = Vec::new(); + for (path, path_item) in paths { + let Some(path_object) = path_item.as_object() else { + continue; + }; + let path_parameters = path_object.get("parameters").and_then(Value::as_array); + for method in HTTP_METHODS { + let Some(operation) = path_object.get(*method).and_then(Value::as_object) else { + continue; + }; + let mut parameters = Vec::new(); + if let Some(items) = path_parameters { + parameters.extend(items.iter().map(parameter_inventory)); + } + if let Some(items) = operation.get("parameters").and_then(Value::as_array) { + parameters.extend(items.iter().map(parameter_inventory)); + } + parameters.sort_by(|left, right| { + (&left.location, &left.name, &left.reference).cmp(&( + &right.location, + &right.name, + &right.reference, + )) + }); + + let request_content_types = operation + .get("requestBody") + .and_then(|body| body.get("content")) + .and_then(Value::as_object) + .map(|content| content.keys().cloned().collect()) + .unwrap_or_default(); + let mut responses = operation + .get("responses") + .and_then(Value::as_object) + .map(|items| { + items + .iter() + .map(|(status, response)| response_inventory(status, response)) + .collect::>() + }) + .unwrap_or_default(); + responses.sort_by(|left, right| left.status.cmp(&right.status)); + + let tags = operation + .get("tags") + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(|tag| value_string(Some(tag))) + .collect::>() + }) + .unwrap_or_default(); + if allowed_tags.is_some_and(|allowed| !tags.iter().any(|tag| allowed.contains(tag))) { + continue; + } + + operations.push(HttpOperation { + method: method.to_ascii_uppercase(), + path: path.clone(), + operation_id: value_string(operation.get("operationId")), + tags, + parameters, + request_content_types, + responses, + }); + } + } + operations +} + +fn parameter_inventory(parameter: &Value) -> HttpParameter { + HttpParameter { + name: value_string(parameter.get("name")), + location: value_string(parameter.get("in")), + required: parameter + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false), + reference: value_string(parameter.get("$ref")), + } +} + +fn response_inventory(status: &str, response: &Value) -> HttpResponse { + let mut schema_references = BTreeSet::new(); + collect_references(response, &mut schema_references); + let content_types = response + .get("content") + .and_then(Value::as_object) + .map(|content| content.keys().cloned().collect()) + .unwrap_or_default(); + HttpResponse { + status: status.to_string(), + reference: value_string(response.get("$ref")), + content_types, + schema_references: schema_references.into_iter().collect(), + error: !status.starts_with('2'), + } +} + +fn collect_references(value: &Value, references: &mut BTreeSet) { + match value { + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + references.insert(reference.to_string()); + } + for child in object.values() { + collect_references(child, references); + } + } + Value::Array(items) => { + for child in items { + collect_references(child, references); + } + } + _ => {} + } +} + +fn collect_schema_fields(value: &Value, pointer: &str, fields: &mut Vec) { + match value { + Value::Object(object) => { + if let Some(properties) = object.get("properties").and_then(Value::as_object) { + let required = object + .get("required") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + for (name, property) in properties { + let property_pointer = + format!("{pointer}/properties/{}", escape_json_pointer_segment(name)); + fields.push(SchemaField { + pointer: property_pointer, + name: name.clone(), + required: required.contains(name.as_str()), + field_type: schema_type(property), + format: value_string(property.get("format")), + reference: first_reference(property), + }); + } + } + for (key, child) in object { + let child_pointer = format!("{pointer}/{}", escape_json_pointer_segment(key)); + collect_schema_fields(child, &child_pointer, fields); + } + } + Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + collect_schema_fields(child, &format!("{pointer}/{index}"), fields); + } + } + _ => {} + } +} + +fn collect_authentication_headers(document: &Value, operations: &[HttpOperation]) -> Vec { + let mut headers = BTreeSet::new(); + if let Some(schemes) = document + .pointer("/components/securitySchemes") + .and_then(Value::as_object) + { + for scheme in schemes.values() { + let scheme_type = scheme.get("type").and_then(Value::as_str); + let scheme_location = scheme.get("in").and_then(Value::as_str); + let scheme_name = scheme.get("scheme").and_then(Value::as_str); + if scheme_type == Some("apiKey") + && (scheme_location == Some("header") || scheme_name == Some("header")) + { + if let Some(name) = scheme.get("name").and_then(Value::as_str) { + headers.insert(name.to_string()); + } + } else if scheme_type == Some("http") && scheme_name == Some("bearer") { + headers.insert("Authorization".to_string()); + } + } + } + if let Some(parameters) = document + .pointer("/components/parameters") + .and_then(Value::as_object) + { + for parameter in parameters.values() { + if parameter.get("in").and_then(Value::as_str) == Some("header") { + if let Some(name) = parameter.get("name").and_then(Value::as_str) { + headers.insert(name.to_string()); + } + } + } + } + for operation in operations { + for parameter in &operation.parameters { + if parameter.location.as_deref() == Some("header") { + if let Some(name) = parameter.name.as_ref() { + headers.insert(name.clone()); + } + } + } + } + headers.into_iter().collect() +} + +fn first_reference(value: &Value) -> Option { + match value { + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + return Some(reference.to_string()); + } + object.values().find_map(first_reference) + } + Value::Array(items) => items.iter().find_map(first_reference), + _ => None, + } +} + +fn schema_type(value: &Value) -> Option { + match value.get("type") { + Some(Value::String(value)) => Some(value.clone()), + Some(Value::Array(values)) => Some( + values + .iter() + .filter_map(Value::as_str) + .collect::>() + .join("|"), + ), + _ => None, + } +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + value_string(Some(current)) +} + +fn value_string(value: Option<&Value>) -> Option { + match value { + Some(Value::String(value)) => Some(value.clone()), + Some(Value::Number(value)) => Some(value.to_string()), + _ => None, + } +} + +fn escape_json_pointer_segment(segment: &str) -> String { + segment.replace('~', "~0").replace('/', "~1") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collects_inline_fields_and_error_responses() { + let document: Value = serde_json::json!({ + "openapi": "3.0.0", + "info": {"version": "1"}, + "paths": { + "/sandboxes": { + "post": { + "operationId": "createSandbox", + "requestBody": {"content": {"application/json": {"schema": { + "type": "object", + "required": ["template"], + "properties": {"template": {"type": "string"}} + }}}}, + "responses": { + "201": {"description": "created"}, + "400": {"$ref": "#/components/responses/BadRequest"} + } + } + } + } + }); + let operations = collect_operations(&document, None); + assert_eq!(operations.len(), 1); + assert_eq!(operations[0].request_content_types, ["application/json"]); + assert!(!operations[0].responses[0].error); + assert!(operations[0].responses[1].error); + + let mut fields = Vec::new(); + collect_schema_fields(&document, "", &mut fields); + assert!(fields + .iter() + .any(|field| field.name == "template" && field.required)); + } +} diff --git a/src/compat/src/production/config.rs b/src/compat/src/production/config.rs new file mode 100644 index 00000000..f88b098a --- /dev/null +++ b/src/compat/src/production/config.rs @@ -0,0 +1,985 @@ +use std::collections::BTreeSet; +use std::fmt; +use std::net::SocketAddr; +use std::num::{NonZeroU16, NonZeroU32, NonZeroUsize}; +use std::path::{Component, Path, PathBuf}; +use std::str::FromStr; +use std::time::Duration; + +use a3s_acl::{Block, Document, Value}; +use a3s_box_core::{BoxConfig, ExecutionIsolation, NetworkMode, ResourceConfig}; +use axum::http::uri::Authority; +use thiserror::Error; +use url::Url; + +use crate::control::{EnvdMode, ResolvedTemplate, TokenKeyMaterial, TokenScope}; +use crate::gateway::DataPlaneGatewayConfig; +use crate::http::{CredentialHash, CredentialScheme, HashedAccountCredential}; +use crate::routing::{SandboxDomain, SandboxRoutePolicy, ENVD_PORT}; + +use super::StaticTemplateProvider; + +const MAX_CONFIG_BYTES: u64 = 1024 * 1024; +const DEFAULT_MAX_JSON_BYTES: usize = 1024 * 1024; +const MIN_MAX_JSON_BYTES: usize = 1024; +const MAX_MAX_JSON_BYTES: usize = 16 * 1024 * 1024; +const MAX_COMMAND_PARTS: usize = 256; +const MAX_COMMAND_BYTES: usize = 64 * 1024; +const TOKEN_KEY_BYTES: usize = 32; + +/// Validated service maintenance cadence. +#[derive(Debug, Clone, Copy)] +pub struct SupervisorConfig { + interval: Duration, + batch_size: NonZeroU32, + reconciliation_page_size: NonZeroU32, +} + +impl SupervisorConfig { + pub const fn interval(self) -> Duration { + self.interval + } + + pub const fn batch_size(self) -> NonZeroU32 { + self.batch_size + } + + pub const fn reconciliation_page_size(self) -> NonZeroU32 { + self.reconciliation_page_size + } +} + +/// Fully resolved ACL configuration. Secret key material is redacted from Debug output. +pub struct E2bCompatConfig { + pub(crate) api_listen: SocketAddr, + pub(crate) api_public_url: Url, + pub(crate) sandbox_domain: SandboxDomain, + pub(crate) sandbox_public_domain: String, + pub(crate) database_path: PathBuf, + pub(crate) runtime_home: PathBuf, + pub(crate) runtime_state_path: PathBuf, + pub(crate) max_json_bytes: usize, + pub(crate) gateway: DataPlaneGatewayConfig, + pub(crate) supervisor: SupervisorConfig, + pub(crate) credentials: Vec, + pub(crate) active_token_version: u32, + pub(crate) token_keys: Vec, + pub(crate) templates: StaticTemplateProvider, +} + +impl E2bCompatConfig { + pub async fn load(path: impl AsRef) -> E2bConfigResult { + let path = path.as_ref(); + if path.extension().and_then(|extension| extension.to_str()) != Some("acl") { + return Err(E2bConfigError::InvalidExtension(path.to_path_buf())); + } + let metadata = tokio::fs::metadata(path) + .await + .map_err(|source| E2bConfigError::Read { + path: path.to_path_buf(), + source, + })?; + if metadata.len() > MAX_CONFIG_BYTES { + return Err(invalid(format!( + "ACL configuration exceeds the {MAX_CONFIG_BYTES}-byte limit" + ))); + } + let input = + tokio::fs::read_to_string(path) + .await + .map_err(|source| E2bConfigError::Read { + path: path.to_path_buf(), + source, + })?; + if input.len() as u64 > MAX_CONFIG_BYTES { + return Err(invalid(format!( + "ACL configuration exceeds the {MAX_CONFIG_BYTES}-byte limit" + ))); + } + Self::parse_with_environment(&input, |name| std::env::var(name).ok()) + } + + pub fn parse(input: &str) -> E2bConfigResult { + Self::parse_with_environment(input, |name| std::env::var(name).ok()) + } + + pub fn api_listen(&self) -> SocketAddr { + self.api_listen + } + + pub fn api_public_url(&self) -> &Url { + &self.api_public_url + } + + pub fn sandbox_domain(&self) -> &str { + self.sandbox_domain.as_str() + } + + pub fn sandbox_public_domain(&self) -> &str { + &self.sandbox_public_domain + } + + pub fn supervisor(&self) -> SupervisorConfig { + self.supervisor + } + + pub fn gateway(&self) -> &DataPlaneGatewayConfig { + &self.gateway + } + + pub(super) fn parse_with_environment( + input: &str, + mut environment: F, + ) -> E2bConfigResult + where + F: FnMut(&str) -> Option, + { + let document = a3s_acl::parse(input)?; + parse_document(document, &mut environment) + } +} + +impl fmt::Debug for E2bCompatConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("E2bCompatConfig") + .field("api_listen", &self.api_listen) + .field("api_public_url", &self.api_public_url) + .field("sandbox_domain", &self.sandbox_domain) + .field("sandbox_public_domain", &self.sandbox_public_domain) + .field("database_path", &self.database_path) + .field("runtime_home", &self.runtime_home) + .field("runtime_state_path", &self.runtime_state_path) + .field("max_json_bytes", &self.max_json_bytes) + .field("gateway", &self.gateway) + .field("supervisor", &self.supervisor) + .field("credential_count", &self.credentials.len()) + .field("active_token_version", &self.active_token_version) + .field("token_key_count", &self.token_keys.len()) + .field("template_count", &self.templates.len()) + .finish() + } +} + +#[derive(Debug, Error)] +pub enum E2bConfigError { + #[error("E2B compatibility configuration must use the .acl extension: {0}")] + InvalidExtension(PathBuf), + #[error("failed to read E2B compatibility ACL configuration {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to parse E2B compatibility ACL configuration: {0}")] + Parse(#[from] a3s_acl::ParseError), + #[error("invalid E2B compatibility ACL configuration: {0}")] + Invalid(String), +} + +pub type E2bConfigResult = std::result::Result; + +fn parse_document(document: Document, environment: &mut F) -> E2bConfigResult +where + F: FnMut(&str) -> Option, +{ + if document.blocks.len() != 1 || document.blocks[0].name != "e2b_compat" { + return Err(invalid( + "configuration must contain exactly one e2b_compat block", + )); + } + let root = &document.blocks[0]; + require_no_labels(root, "e2b_compat")?; + ensure_shape( + root, + &[ + "api_listen", + "api_public_url", + "sandbox_domain", + "sandbox_public_domain", + "database_path", + "runtime_home", + "runtime_state_path", + "max_json_bytes", + ], + &[ + "supervisor", + "gateway", + "account", + "token_key", + "template_policy", + ], + "e2b_compat", + )?; + + let api_listen = required_string(root, "api_listen", "e2b_compat")? + .parse::() + .map_err(|_| invalid("e2b_compat.api_listen must be an IP socket address"))?; + if api_listen.port() == 0 { + return Err(invalid("e2b_compat.api_listen port must be non-zero")); + } + let api_public_url = parse_public_url(required_string(root, "api_public_url", "e2b_compat")?)?; + let sandbox_domain = SandboxDomain::new(required_string(root, "sandbox_domain", "e2b_compat")?) + .map_err(|error| invalid(format!("e2b_compat.sandbox_domain: {error}")))?; + let sandbox_public_domain = parse_sandbox_public_domain( + optional_string(root, "sandbox_public_domain", "e2b_compat")?, + &sandbox_domain, + )?; + let database_path = required_absolute_path(root, "database_path", "e2b_compat")?; + let runtime_home = required_absolute_path(root, "runtime_home", "e2b_compat")?; + let runtime_state_path = required_absolute_path(root, "runtime_state_path", "e2b_compat")?; + if database_path == runtime_state_path { + return Err(invalid( + "database_path and runtime_state_path must identify different files", + )); + } + let max_json_bytes = + optional_usize(root, "max_json_bytes", "e2b_compat")?.unwrap_or(DEFAULT_MAX_JSON_BYTES); + if !(MIN_MAX_JSON_BYTES..=MAX_MAX_JSON_BYTES).contains(&max_json_bytes) { + return Err(invalid(format!( + "e2b_compat.max_json_bytes must be between {MIN_MAX_JSON_BYTES} and {MAX_MAX_JSON_BYTES}" + ))); + } + + let supervisor = parse_supervisor(single_child(root, "supervisor", "e2b_compat")?)?; + let gateway = parse_gateway(single_child(root, "gateway", "e2b_compat")?)?; + if gateway.listen == api_listen { + return Err(invalid( + "e2b_compat.gateway.listen must differ from e2b_compat.api_listen", + )); + } + let credentials = parse_credentials(children(root, "account"))?; + let (active_token_version, token_keys) = + parse_token_keys(children(root, "token_key"), environment)?; + let templates = + StaticTemplateProvider::new(parse_templates(children(root, "template_policy"))?) + .map_err(|error| invalid(error.to_string()))?; + + Ok(E2bCompatConfig { + api_listen, + api_public_url, + sandbox_domain, + sandbox_public_domain, + database_path, + runtime_home, + runtime_state_path, + max_json_bytes, + gateway, + supervisor, + credentials, + active_token_version, + token_keys, + templates, + }) +} + +fn parse_gateway(block: &Block) -> E2bConfigResult { + const MAX_CONNECTIONS: usize = 100_000; + const MAX_TIMEOUT_MILLISECONDS: u64 = 60_000; + const MAX_DRAIN_SECONDS: u64 = 300; + + let context = "e2b_compat.gateway"; + require_no_labels(block, context)?; + ensure_shape( + block, + &[ + "listen", + "tls_certificate_path", + "tls_private_key_path", + "max_connections", + "handshake_timeout_ms", + "connect_timeout_ms", + "drain_timeout_seconds", + ], + &[], + context, + )?; + let listen = required_string(block, "listen", context)? + .parse::() + .map_err(|_| invalid("e2b_compat.gateway.listen must be an IP socket address"))?; + if listen.port() == 0 { + return Err(invalid("e2b_compat.gateway.listen port must be non-zero")); + } + let certificate_path = required_absolute_path(block, "tls_certificate_path", context)?; + let private_key_path = required_absolute_path(block, "tls_private_key_path", context)?; + if certificate_path == private_key_path { + return Err(invalid( + "e2b_compat.gateway TLS certificate and private key paths must differ", + )); + } + let max_connections = NonZeroUsize::new(required_usize(block, "max_connections", context)?) + .filter(|value| value.get() <= MAX_CONNECTIONS) + .ok_or_else(|| { + invalid(format!( + "{context}.max_connections must be between 1 and {MAX_CONNECTIONS}" + )) + })?; + let handshake_timeout_ms = required_u64(block, "handshake_timeout_ms", context)?; + let connect_timeout_ms = required_u64(block, "connect_timeout_ms", context)?; + if !(100..=MAX_TIMEOUT_MILLISECONDS).contains(&handshake_timeout_ms) { + return Err(invalid(format!( + "{context}.handshake_timeout_ms must be between 100 and {MAX_TIMEOUT_MILLISECONDS}" + ))); + } + if !(10..=MAX_TIMEOUT_MILLISECONDS).contains(&connect_timeout_ms) { + return Err(invalid(format!( + "{context}.connect_timeout_ms must be between 10 and {MAX_TIMEOUT_MILLISECONDS}" + ))); + } + let drain_timeout_seconds = required_u64(block, "drain_timeout_seconds", context)?; + if !(1..=MAX_DRAIN_SECONDS).contains(&drain_timeout_seconds) { + return Err(invalid(format!( + "{context}.drain_timeout_seconds must be between 1 and {MAX_DRAIN_SECONDS}" + ))); + } + + Ok(DataPlaneGatewayConfig { + listen, + certificate_path, + private_key_path, + max_connections, + handshake_timeout: Duration::from_millis(handshake_timeout_ms), + connect_timeout: Duration::from_millis(connect_timeout_ms), + drain_timeout: Duration::from_secs(drain_timeout_seconds), + }) +} + +fn parse_public_url(value: String) -> E2bConfigResult { + let url = Url::parse(&value) + .map_err(|_| invalid("e2b_compat.api_public_url must be an absolute HTTP(S) URL"))?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(invalid( + "e2b_compat.api_public_url must be an HTTP(S) origin without credentials, query, or fragment", + )); + } + Ok(url) +} + +fn parse_sandbox_public_domain( + value: Option, + sandbox_domain: &SandboxDomain, +) -> E2bConfigResult { + let value = value.unwrap_or_else(|| sandbox_domain.as_str().to_string()); + let authority = Authority::from_str(&value).map_err(|_| { + invalid( + "e2b_compat.sandbox_public_domain must be the sandbox domain with an optional TCP port", + ) + })?; + let port_is_valid = authority + .port() + .map(|port| port.as_str().parse::().is_ok()) + .unwrap_or(true); + if authority.host() != sandbox_domain.as_str() || !port_is_valid { + return Err(invalid( + "e2b_compat.sandbox_public_domain must match sandbox_domain and may include one non-zero TCP port", + )); + } + Ok(value) +} + +fn parse_supervisor(block: &Block) -> E2bConfigResult { + require_no_labels(block, "e2b_compat.supervisor")?; + ensure_shape( + block, + &["interval_seconds", "batch_size", "reconciliation_page_size"], + &[], + "e2b_compat.supervisor", + )?; + let interval_seconds = required_u64(block, "interval_seconds", "e2b_compat.supervisor")?; + if !(1..=3600).contains(&interval_seconds) { + return Err(invalid( + "e2b_compat.supervisor.interval_seconds must be between 1 and 3600", + )); + } + let batch_size = required_nonzero_u32(block, "batch_size", "e2b_compat.supervisor")?; + let reconciliation_page_size = + required_nonzero_u32(block, "reconciliation_page_size", "e2b_compat.supervisor")?; + if batch_size.get() > 10_000 || reconciliation_page_size.get() > 10_000 { + return Err(invalid( + "supervisor batch sizes cannot exceed 10000 records", + )); + } + Ok(SupervisorConfig { + interval: Duration::from_secs(interval_seconds), + batch_size, + reconciliation_page_size, + }) +} + +fn parse_credentials(blocks: Vec<&Block>) -> E2bConfigResult> { + if blocks.is_empty() { + return Err(invalid("at least one account block is required")); + } + let mut labels = BTreeSet::new(); + let mut identities = BTreeSet::new(); + let mut credentials = Vec::with_capacity(blocks.len()); + for block in blocks { + let label = single_label(block, "e2b_compat.account")?; + if !labels.insert(label.to_string()) { + return Err(invalid(format!( + "account label {label:?} is configured more than once" + ))); + } + let context = format!("e2b_compat.account[{label}]"); + ensure_shape( + block, + &["scheme", "owner_id", "client_id", "hash"], + &[], + &context, + )?; + let scheme_name = required_string(block, "scheme", &context)?; + let scheme = match scheme_name.as_str() { + "api_key" => CredentialScheme::ApiKey, + "bearer" => CredentialScheme::Bearer, + "supabase" => CredentialScheme::Supabase, + _ => { + return Err(invalid(format!( + "{context}.scheme must be api_key, bearer, or supabase" + ))) + } + }; + let owner_id = required_nonempty_string(block, "owner_id", &context, 128)?; + let client_id = required_nonempty_string(block, "client_id", &context, 128)?; + if !identities.insert((scheme_name, client_id.clone())) { + return Err(invalid(format!( + "credential scheme and client ID are duplicated in {context}" + ))); + } + let hash = required_string(block, "hash", &context)? + .parse::() + .map_err(|error| invalid(format!("{context}.hash: {error}")))?; + credentials.push( + HashedAccountCredential::new(scheme, owner_id, client_id, hash) + .map_err(|error| invalid(format!("{context}: {error}")))?, + ); + } + Ok(credentials) +} + +fn parse_token_keys( + blocks: Vec<&Block>, + environment: &mut F, +) -> E2bConfigResult<(u32, Vec)> +where + F: FnMut(&str) -> Option, +{ + if blocks.is_empty() { + return Err(invalid("at least one token_key block is required")); + } + let mut labels = BTreeSet::new(); + let mut versions = BTreeSet::new(); + let mut active_version = None; + let mut materials = Vec::with_capacity(blocks.len()); + for block in blocks { + let label = single_label(block, "e2b_compat.token_key")?; + if !labels.insert(label.to_string()) { + return Err(invalid(format!( + "token key label {label:?} is configured more than once" + ))); + } + let context = format!("e2b_compat.token_key[{label}]"); + ensure_shape( + block, + &["version", "active", "encryption_key", "digest_key"], + &[], + &context, + )?; + let version = required_u32(block, "version", &context)?; + if version == 0 || !versions.insert(version) { + return Err(invalid(format!( + "{context}.version must be unique and greater than zero" + ))); + } + if required_bool(block, "active", &context)? && active_version.replace(version).is_some() { + return Err(invalid("exactly one token key can be active")); + } + let encryption = required_environment_key(block, "encryption_key", &context, environment)?; + let digest = required_environment_key(block, "digest_key", &context, environment)?; + if encryption == digest { + return Err(invalid(format!( + "{context} must use independent encryption and digest keys" + ))); + } + materials.push( + TokenKeyMaterial::new(version, &encryption, &digest) + .map_err(|error| invalid(format!("{context}: {error}")))?, + ); + } + let active_version = active_version.ok_or_else(|| invalid("one token key must be active"))?; + Ok((active_version, materials)) +} + +fn required_environment_key( + block: &Block, + field: &str, + context: &str, + environment: &mut F, +) -> E2bConfigResult<[u8; TOKEN_KEY_BYTES]> +where + F: FnMut(&str) -> Option, +{ + let value = required_value(block, field, context)?; + let variable = match value { + Value::Call(name, arguments) + if name == "env" + && arguments.len() == 1 + && matches!(&arguments[0], Value::String(_)) => + { + arguments[0].as_str().unwrap_or_default() + } + _ => { + return Err(invalid(format!( + "{context}.{field} must use env(\"VARIABLE\")" + ))) + } + }; + if !valid_environment_name(variable) { + return Err(invalid(format!( + "{context}.{field} references an invalid environment variable name" + ))); + } + let encoded = environment(variable).ok_or_else(|| { + invalid(format!( + "environment variable {variable} required by {context}.{field} is unavailable" + )) + })?; + let decoded = hex::decode(encoded).map_err(|_| { + invalid(format!( + "environment variable {variable} must contain a 32-byte hexadecimal key" + )) + })?; + decoded.try_into().map_err(|_| { + invalid(format!( + "environment variable {variable} must contain a 32-byte hexadecimal key" + )) + }) +} + +fn valid_environment_name(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && (bytes[0].is_ascii_uppercase() || bytes[0] == b'_') + && bytes + .iter() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || *byte == b'_') +} + +fn parse_templates(blocks: Vec<&Block>) -> E2bConfigResult> { + if blocks.is_empty() { + return Err(invalid("at least one template_policy block is required")); + } + let mut labels = BTreeSet::new(); + let mut templates = Vec::with_capacity(blocks.len()); + for block in blocks { + let template_id = single_label(block, "e2b_compat.template_policy")?.to_string(); + if !labels.insert(template_id.clone()) { + return Err(invalid(format!( + "template policy {template_id:?} is configured more than once" + ))); + } + let context = format!("e2b_compat.template_policy[{template_id}]"); + ensure_shape( + block, + &[ + "image", + "envd_version", + "envd_mode", + "isolation", + "network", + "command", + "entrypoint", + "user", + "workdir", + "read_only", + "stdin_open", + ], + &["resources", "route"], + &context, + )?; + + let image = required_nonempty_string(block, "image", &context, 512)?; + if image.chars().any(char::is_whitespace) { + return Err(invalid(format!( + "{context}.image cannot contain whitespace" + ))); + } + let envd_version = required_nonempty_string(block, "envd_version", &context, 128)?; + let envd_mode = match optional_string(block, "envd_mode", &context)?.as_deref() { + None | Some("broker") => EnvdMode::Broker, + Some("runtime") => EnvdMode::Runtime, + Some(_) => { + return Err(invalid(format!( + "{context}.envd_mode must be broker or runtime" + ))) + } + }; + let isolation = match optional_string(block, "isolation", &context)?.as_deref() { + None => ExecutionIsolation::Microvm, + Some("sandbox") => ExecutionIsolation::Sandbox, + Some(_) => { + return Err(invalid(format!( + "{context}.isolation accepts only the explicit value sandbox; omit it for MicroVM" + ))) + } + }; + let network = match optional_string(block, "network", &context)?.as_deref() { + None | Some("tsi") => NetworkMode::Tsi, + Some("none") => NetworkMode::None, + Some(_) => return Err(invalid(format!("{context}.network must be tsi or none"))), + }; + let resources_block = single_child(block, "resources", &context)?; + let resources = parse_resources(resources_block, &context)?; + let command = optional_string_list(block, "command", &context)?.unwrap_or_default(); + let entrypoint_override = optional_string_list(block, "entrypoint", &context)?; + if entrypoint_override.as_ref().is_some_and(Vec::is_empty) { + return Err(invalid(format!("{context}.entrypoint cannot be empty"))); + } + let user = optional_nonempty_string(block, "user", &context, 128)?; + let workdir = optional_nonempty_string(block, "workdir", &context, 4096)?; + let read_only = optional_bool(block, "read_only", &context)?.unwrap_or(false); + let stdin_open = optional_bool(block, "stdin_open", &context)?.unwrap_or(false); + let routing = parse_routes(children(block, "route"), &context)?; + + templates.push(( + template_id, + ResolvedTemplate { + config: BoxConfig { + isolation, + image, + resources, + cmd: command, + entrypoint_override, + user, + workdir, + network, + read_only, + stdin_open, + ..BoxConfig::default() + }, + envd_version, + envd_mode, + routing, + rootfs_snapshot_id: None, + }, + )); + } + Ok(templates) +} + +fn parse_resources(block: &Block, parent: &str) -> E2bConfigResult { + let context = format!("{parent}.resources"); + require_no_labels(block, &context)?; + ensure_shape(block, &["vcpus", "memory_mb", "disk_mb"], &[], &context)?; + let vcpus = required_u32(block, "vcpus", &context)?; + let memory_mb = required_u32(block, "memory_mb", &context)?; + let disk_mb = required_u32(block, "disk_mb", &context)?; + if vcpus == 0 || vcpus > 256 { + return Err(invalid(format!( + "{context}.vcpus must be between 1 and 256" + ))); + } + if memory_mb < 16 { + return Err(invalid(format!("{context}.memory_mb must be at least 16"))); + } + if disk_mb == 0 { + return Err(invalid(format!( + "{context}.disk_mb must be greater than zero" + ))); + } + Ok(ResourceConfig { + vcpus, + memory_mb, + disk_mb, + timeout: ResourceConfig::default().timeout, + }) +} + +fn parse_routes(blocks: Vec<&Block>, parent: &str) -> E2bConfigResult { + let mut ports = Vec::with_capacity(blocks.len() + 1); + for (index, block) in blocks.into_iter().enumerate() { + let context = format!("{parent}.route[{index}]"); + require_no_labels(block, &context)?; + ensure_shape(block, &["port", "token_scope"], &[], &context)?; + let port = required_u16(block, "port", &context)?; + if port == 0 { + return Err(invalid(format!("{context}.port must be non-zero"))); + } + let scope = match required_string(block, "token_scope", &context)?.as_str() { + "envd" => TokenScope::Envd, + "traffic" => TokenScope::Traffic, + _ => { + return Err(invalid(format!( + "{context}.token_scope must be envd or traffic" + ))) + } + }; + ports.push((port, scope)); + } + if !ports.iter().any(|(port, _)| *port == ENVD_PORT) { + ports.push((ENVD_PORT, TokenScope::Envd)); + } + SandboxRoutePolicy::new(ports) + .map_err(|error| invalid(format!("{parent} has an invalid route policy: {error}"))) +} + +fn ensure_shape( + block: &Block, + attributes: &[&str], + child_blocks: &[&str], + context: &str, +) -> E2bConfigResult<()> { + for attribute in block.attributes.keys() { + if !attributes.contains(&attribute.as_str()) { + return Err(invalid(format!( + "{context} contains unknown attribute {attribute}" + ))); + } + } + for child in &block.blocks { + if !child_blocks.contains(&child.name.as_str()) { + return Err(invalid(format!( + "{context} contains unknown block {}", + child.name + ))); + } + } + Ok(()) +} + +fn children<'a>(block: &'a Block, name: &str) -> Vec<&'a Block> { + block + .blocks + .iter() + .filter(|child| child.name == name) + .collect() +} + +fn single_child<'a>(block: &'a Block, name: &str, context: &str) -> E2bConfigResult<&'a Block> { + let matches = children(block, name); + match matches.as_slice() { + [child] => Ok(child), + [] => Err(invalid(format!("{context}.{name} block is required"))), + _ => Err(invalid(format!( + "{context} can contain only one {name} block" + ))), + } +} + +fn require_no_labels(block: &Block, context: &str) -> E2bConfigResult<()> { + if block.labels.is_empty() { + Ok(()) + } else { + Err(invalid(format!("{context} does not accept block labels"))) + } +} + +fn single_label<'a>(block: &'a Block, context: &str) -> E2bConfigResult<&'a str> { + match block.labels.as_slice() { + [label] if !label.trim().is_empty() && label.len() <= 128 => Ok(label), + _ => Err(invalid(format!( + "{context} requires exactly one non-empty string label" + ))), + } +} + +fn required_value<'a>(block: &'a Block, field: &str, context: &str) -> E2bConfigResult<&'a Value> { + block + .attributes + .get(field) + .ok_or_else(|| invalid(format!("{context}.{field} is required"))) +} + +fn required_string(block: &Block, field: &str, context: &str) -> E2bConfigResult { + match required_value(block, field, context)? { + Value::String(value) => Ok(value.clone()), + _ => Err(invalid(format!("{context}.{field} must be a string"))), + } +} + +fn optional_string(block: &Block, field: &str, context: &str) -> E2bConfigResult> { + block + .attributes + .get(field) + .map(|value| match value { + Value::String(value) => Ok(value.clone()), + _ => Err(invalid(format!("{context}.{field} must be a string"))), + }) + .transpose() +} + +fn required_nonempty_string( + block: &Block, + field: &str, + context: &str, + max_bytes: usize, +) -> E2bConfigResult { + let value = required_string(block, field, context)?; + validate_nonempty_string(value, field, context, max_bytes) +} + +fn optional_nonempty_string( + block: &Block, + field: &str, + context: &str, + max_bytes: usize, +) -> E2bConfigResult> { + optional_string(block, field, context)? + .map(|value| validate_nonempty_string(value, field, context, max_bytes)) + .transpose() +} + +fn validate_nonempty_string( + value: String, + field: &str, + context: &str, + max_bytes: usize, +) -> E2bConfigResult { + if value.trim().is_empty() || value.len() > max_bytes || value.contains('\0') { + Err(invalid(format!( + "{context}.{field} must be non-empty and no more than {max_bytes} bytes" + ))) + } else { + Ok(value) + } +} + +fn required_bool(block: &Block, field: &str, context: &str) -> E2bConfigResult { + match required_value(block, field, context)? { + Value::Bool(value) => Ok(*value), + _ => Err(invalid(format!("{context}.{field} must be a boolean"))), + } +} + +fn optional_bool(block: &Block, field: &str, context: &str) -> E2bConfigResult> { + block + .attributes + .get(field) + .map(|value| match value { + Value::Bool(value) => Ok(*value), + _ => Err(invalid(format!("{context}.{field} must be a boolean"))), + }) + .transpose() +} + +fn required_u64(block: &Block, field: &str, context: &str) -> E2bConfigResult { + number_as_u64(required_value(block, field, context)?, field, context) +} + +fn required_u32(block: &Block, field: &str, context: &str) -> E2bConfigResult { + let value = required_u64(block, field, context)?; + u32::try_from(value).map_err(|_| invalid(format!("{context}.{field} exceeds the u32 range"))) +} + +fn required_u16(block: &Block, field: &str, context: &str) -> E2bConfigResult { + let value = required_u64(block, field, context)?; + u16::try_from(value) + .map_err(|_| invalid(format!("{context}.{field} exceeds the TCP port range"))) +} + +fn required_nonzero_u32(block: &Block, field: &str, context: &str) -> E2bConfigResult { + NonZeroU32::new(required_u32(block, field, context)?) + .ok_or_else(|| invalid(format!("{context}.{field} must be greater than zero"))) +} + +fn optional_usize(block: &Block, field: &str, context: &str) -> E2bConfigResult> { + block + .attributes + .get(field) + .map(|value| { + let value = number_as_u64(value, field, context)?; + usize::try_from(value) + .map_err(|_| invalid(format!("{context}.{field} exceeds the platform range"))) + }) + .transpose() +} + +fn required_usize(block: &Block, field: &str, context: &str) -> E2bConfigResult { + let value = required_u64(block, field, context)?; + usize::try_from(value) + .map_err(|_| invalid(format!("{context}.{field} exceeds the platform range"))) +} + +fn number_as_u64(value: &Value, field: &str, context: &str) -> E2bConfigResult { + match value { + Value::Number(value) + if value.is_finite() + && *value >= 0.0 + && value.fract() == 0.0 + && *value <= u64::MAX as f64 => + { + Ok(*value as u64) + } + _ => Err(invalid(format!( + "{context}.{field} must be a non-negative integer" + ))), + } +} + +fn optional_string_list( + block: &Block, + field: &str, + context: &str, +) -> E2bConfigResult>> { + let Some(value) = block.attributes.get(field) else { + return Ok(None); + }; + let Value::List(values) = value else { + return Err(invalid(format!( + "{context}.{field} must be a list of strings" + ))); + }; + if values.len() > MAX_COMMAND_PARTS { + return Err(invalid(format!( + "{context}.{field} cannot contain more than {MAX_COMMAND_PARTS} entries" + ))); + } + let mut total_bytes = 0usize; + let mut strings = Vec::with_capacity(values.len()); + for value in values { + let Value::String(value) = value else { + return Err(invalid(format!( + "{context}.{field} must contain only strings" + ))); + }; + if value.contains('\0') { + return Err(invalid(format!( + "{context}.{field} cannot contain NUL bytes" + ))); + } + total_bytes = total_bytes + .checked_add(value.len()) + .ok_or_else(|| invalid(format!("{context}.{field} is too large")))?; + strings.push(value.clone()); + } + if total_bytes > MAX_COMMAND_BYTES { + return Err(invalid(format!( + "{context}.{field} cannot exceed {MAX_COMMAND_BYTES} bytes" + ))); + } + Ok(Some(strings)) +} + +fn required_absolute_path(block: &Block, field: &str, context: &str) -> E2bConfigResult { + let path = PathBuf::from(required_nonempty_string(block, field, context, 4096)?); + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::CurDir)) + { + return Err(invalid(format!( + "{context}.{field} must be an absolute normalized path" + ))); + } + Ok(path) +} + +fn invalid(message: impl Into) -> E2bConfigError { + E2bConfigError::Invalid(message.into()) +} diff --git a/src/compat/src/production/identity.rs b/src/compat/src/production/identity.rs new file mode 100644 index 00000000..79697bf0 --- /dev/null +++ b/src/compat/src/production/identity.rs @@ -0,0 +1,30 @@ +use a3s_box_core::OperationId; +use uuid::Uuid; + +use crate::control::{ + IdentityProviderError, IdentityProviderResult, SandboxId, SandboxIdentity, + SandboxIdentityProvider, +}; + +/// Generates externally safe sandbox IDs and independent lifecycle operation IDs. +#[derive(Debug, Default)] +pub struct UuidSandboxIdentityProvider; + +impl SandboxIdentityProvider for UuidSandboxIdentityProvider { + fn next_identity(&self) -> IdentityProviderResult { + let sandbox_uuid = Uuid::new_v4(); + let operation_uuid = Uuid::new_v4(); + let sandbox_id = SandboxId::new(format!("sandbox-{sandbox_uuid}")) + .map_err(|error| unavailable(error.to_string()))?; + let operation_id = OperationId::new(format!("e2b-create-{operation_uuid}")) + .map_err(|error| unavailable(error.to_string()))?; + Ok(SandboxIdentity { + sandbox_id, + operation_id, + }) + } +} + +fn unavailable(message: String) -> IdentityProviderError { + IdentityProviderError::Unavailable(message) +} diff --git a/src/compat/src/production/mod.rs b/src/compat/src/production/mod.rs new file mode 100644 index 00000000..2cc04c65 --- /dev/null +++ b/src/compat/src/production/mod.rs @@ -0,0 +1,14 @@ +//! ACL-configured production composition for the E2B compatibility service. + +mod config; +mod identity; +mod service; +mod template; + +pub use config::{E2bCompatConfig, E2bConfigError, E2bConfigResult, SupervisorConfig}; +pub use identity::UuidSandboxIdentityProvider; +pub use service::{E2bCompatService, E2bServiceError, E2bServiceResult}; +pub use template::StaticTemplateProvider; + +#[cfg(test)] +mod tests; diff --git a/src/compat/src/production/service.rs b/src/compat/src/production/service.rs new file mode 100644 index 00000000..20751ce4 --- /dev/null +++ b/src/compat/src/production/service.rs @@ -0,0 +1,534 @@ +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use a3s_box_core::{ExecutionManager, ExecutionPortConnector, ExecutionSessionManager}; +use a3s_box_runtime::LocalExecutionManager; +use axum::Router; +use thiserror::Error; +use tokio::sync::watch; +use tokio::time::{self, MissedTickBehavior}; +use tracing::{error, info, warn}; +use url::Url; + +use crate::control::{ + ControlService, ControlServiceDependencies, LifecycleMaintenanceReport, LifecycleSupervisor, + LifecycleSupervisorDependencies, LifecycleSupervisorError, RepositoryError, + RotatingTokenProvider, SandboxRepository, SqliteSandboxRepository, SystemClock, + TokenIssuerError, +}; +use crate::gateway::{DataPlaneGateway, DataPlaneGatewayError}; +use crate::http::{ + lifecycle_router, CredentialHashError, HashedCredentialVerifier, LifecycleHttpConfig, + LifecycleHttpState, RejectingCursorDecoder, +}; +use crate::routing::{RouteLeaseService, SandboxRouteParser}; +use crate::snapshot::{ + SnapshotReconciliationReport, SnapshotService, SnapshotServiceDependencies, + SnapshotTemplateProvider, SqliteSnapshotRepository, +}; +use crate::volume::{ + current_volume_id_mapper, A3sRuntimeVolumeStore, SqliteVolumeRepository, VolumeFilesystem, + VolumeReconciliationReport, VolumeService, VolumeServiceDependencies, +}; + +use super::{E2bCompatConfig, SupervisorConfig, UuidSandboxIdentityProvider}; + +/// Production service with one canonical lifecycle store and runtime manager. +pub struct E2bCompatService { + listen: SocketAddr, + gateway_listen: SocketAddr, + public_url: Url, + sandbox_domain: String, + sandbox_public_domain: String, + router: Router, + gateway: DataPlaneGateway, + supervisor: LifecycleSupervisor, + supervisor_config: SupervisorConfig, + route_parser: SandboxRouteParser, + route_leases: RouteLeaseService, + volumes: Arc, + snapshots: Arc, +} + +impl E2bCompatService { + pub async fn build(config: E2bCompatConfig) -> E2bServiceResult { + prepare_directory(&config.runtime_home).await?; + prepare_parent(&config.database_path).await?; + prepare_parent(&config.runtime_state_path).await?; + + let repository = Arc::new(SqliteSandboxRepository::open(&config.database_path).await?); + let local_executions = Arc::new(LocalExecutionManager::with_vm_backend( + &config.runtime_state_path, + &config.runtime_home, + )); + let executions: Arc = local_executions.clone(); + let sessions: Arc = local_executions.clone(); + let port_connector: Arc = local_executions; + let clock = Arc::new(SystemClock); + let tokens = Arc::new(RotatingTokenProvider::new( + config.active_token_version, + config.token_keys, + )?); + let verifier = Arc::new(HashedCredentialVerifier::new(config.credentials)?); + let configured_templates: Arc = + Arc::new(config.templates); + + let volume_repository = Arc::new(SqliteVolumeRepository::new(repository.connection())); + let volume_filesystem = Arc::new(VolumeFilesystem::new(current_volume_id_mapper()?)); + let volumes = Arc::new(VolumeService::new(VolumeServiceDependencies { + repository: volume_repository, + runtime: Arc::new(A3sRuntimeVolumeStore::new(&config.runtime_home)), + clock: clock.clone(), + token_issuer: tokens.clone(), + token_resolver: tokens.clone(), + token_verifier: tokens.clone(), + filesystem: volume_filesystem, + })); + let snapshot_repository = Arc::new(SqliteSnapshotRepository::new(repository.connection())); + let snapshots = Arc::new(SnapshotService::new(SnapshotServiceDependencies { + repository: snapshot_repository.clone(), + executions: executions.clone(), + clock: clock.clone(), + })); + let templates = Arc::new(SnapshotTemplateProvider::new( + configured_templates, + snapshot_repository, + )); + + let control = Arc::new( + ControlService::new(ControlServiceDependencies { + repository: repository.clone(), + executions: executions.clone(), + ports: port_connector.clone(), + clock: clock.clone(), + identities: Arc::new(UuidSandboxIdentityProvider), + templates, + token_issuer: tokens.clone(), + token_resolver: tokens.clone(), + }) + .with_volume_mount_resolver(volumes.clone()) + .with_snapshot_service(snapshots.clone()), + ); + let supervisor = LifecycleSupervisor::new(LifecycleSupervisorDependencies { + repository: repository.clone(), + executions: executions.clone(), + clock: clock.clone(), + }); + let route_parser = SandboxRouteParser::new(config.sandbox_domain.clone()); + let route_leases = + RouteLeaseService::new(repository as Arc, tokens, clock); + let sandbox_domain = config.sandbox_domain.as_str().to_string(); + let sandbox_public_domain = config.sandbox_public_domain.clone(); + let router = lifecycle_router( + LifecycleHttpState::new( + control, + verifier, + Arc::new(RejectingCursorDecoder), + LifecycleHttpConfig { + domain: Some(sandbox_public_domain.clone()), + max_json_bytes: config.max_json_bytes, + }, + ) + .with_volume_service(volumes.clone()) + .with_snapshot_service(snapshots.clone()), + ); + let gateway = DataPlaneGateway::build( + config.gateway.clone(), + route_parser.clone(), + route_leases.clone(), + executions, + sessions, + port_connector, + ) + .await?; + + Ok(Self { + listen: config.api_listen, + gateway_listen: gateway.listen(), + public_url: config.api_public_url, + sandbox_domain, + sandbox_public_domain, + router, + gateway, + supervisor, + supervisor_config: config.supervisor, + route_parser, + route_leases, + volumes, + snapshots, + }) + } + + pub fn listen(&self) -> SocketAddr { + self.listen + } + + pub fn public_url(&self) -> &Url { + &self.public_url + } + + pub fn gateway_listen(&self) -> SocketAddr { + self.gateway_listen + } + + pub fn sandbox_domain(&self) -> &str { + &self.sandbox_domain + } + + pub fn sandbox_public_domain(&self) -> &str { + &self.sandbox_public_domain + } + + pub fn router(&self) -> Router { + self.router.clone() + } + + pub fn route_parser(&self) -> &SandboxRouteParser { + &self.route_parser + } + + pub fn route_leases(&self) -> &RouteLeaseService { + &self.route_leases + } + + pub async fn reconcile_startup(&self) -> E2bServiceResult { + let volume_report = self.volumes.reconcile_startup().await?; + log_volume_report("startup reconciliation", &volume_report); + let snapshot_report = self.snapshots.reconcile_startup().await?; + log_snapshot_report("startup reconciliation", &snapshot_report); + let lifecycle_report = self + .supervisor + .reconcile_startup(self.supervisor_config.reconciliation_page_size()) + .await?; + Ok(lifecycle_report) + } + + pub async fn serve(self) -> E2bServiceResult<()> { + let listener = tokio::net::TcpListener::bind(self.listen) + .await + .map_err(|source| E2bServiceError::Bind { + address: self.listen, + source, + })?; + let local_address = listener + .local_addr() + .map_err(|source| E2bServiceError::Bind { + address: self.listen, + source, + })?; + let gateway_listener = tokio::net::TcpListener::bind(self.gateway_listen) + .await + .map_err(|source| E2bServiceError::Bind { + address: self.gateway_listen, + source, + })?; + let listener = listener + .into_std() + .map_err(|source| E2bServiceError::Bind { + address: self.listen, + source, + })?; + let (shutdown_sender, shutdown_receiver) = watch::channel(false); + let supervisor = self.supervisor.clone(); + let volumes = self.volumes.clone(); + let snapshots = self.snapshots.clone(); + let supervisor_config = self.supervisor_config; + let mut maintenance = tokio::spawn(run_maintenance( + supervisor, + volumes, + snapshots, + supervisor_config, + shutdown_receiver.clone(), + )); + let mut gateway = Box::pin( + self.gateway + .serve(gateway_listener, shutdown_receiver.clone()), + ); + let mut server = Box::pin( + axum::Server::from_tcp(listener) + .map_err(E2bServiceError::Listener)? + .serve(self.router.into_make_service()) + .with_graceful_shutdown(wait_for_shutdown(shutdown_receiver)), + ); + + info!( + listen = %local_address, + public_url = %self.public_url, + sandbox_domain = %self.sandbox_domain, + sandbox_public_domain = %self.sandbox_public_domain, + gateway_listen = %self.gateway_listen, + "E2B compatibility service started" + ); + + let termination = tokio::select! { + signal = shutdown_signal() => { + Termination::Signal(signal) + } + server_result = &mut server => { + Termination::Control(server_result) + } + gateway_result = &mut gateway => { + Termination::Gateway(gateway_result) + } + maintenance_result = &mut maintenance => { + Termination::Maintenance(maintenance_result) + } + }; + request_shutdown(&shutdown_sender); + match termination { + Termination::Signal(signal) => { + if signal.is_ok() { + info!("shutdown signal received"); + } + let control = server.await; + let gateway_result = gateway.await; + let maintenance_result = maintenance.await; + signal?; + control.map_err(E2bServiceError::Server)?; + gateway_result?; + join_maintenance(maintenance_result)?; + } + Termination::Control(control) => { + let gateway_result = gateway.await; + let maintenance_result = maintenance.await; + control.map_err(E2bServiceError::Server)?; + gateway_result?; + join_maintenance(maintenance_result)?; + } + Termination::Gateway(gateway_result) => { + let control = server.await; + let maintenance_result = maintenance.await; + gateway_result?; + control.map_err(E2bServiceError::Server)?; + join_maintenance(maintenance_result)?; + } + Termination::Maintenance(maintenance_result) => { + let control = server.await; + let gateway_result = gateway.await; + join_maintenance(maintenance_result)?; + control.map_err(E2bServiceError::Server)?; + gateway_result?; + } + } + info!("E2B compatibility service stopped"); + Ok(()) + } +} + +enum Termination { + Signal(E2bServiceResult<()>), + Control(Result<(), hyper::Error>), + Gateway(Result<(), DataPlaneGatewayError>), + Maintenance(Result, tokio::task::JoinError>), +} + +async fn run_maintenance( + supervisor: LifecycleSupervisor, + volumes: Arc, + snapshots: Arc, + config: SupervisorConfig, + mut shutdown: watch::Receiver, +) -> E2bServiceResult<()> { + let volume_report = volumes.reconcile_startup().await?; + log_volume_report("startup reconciliation", &volume_report); + let snapshot_report = snapshots.reconcile_startup().await?; + log_snapshot_report("startup reconciliation", &snapshot_report); + let report = supervisor + .reconcile_startup(config.reconciliation_page_size()) + .await?; + log_report("startup reconciliation", &report); + + let start = time::Instant::now() + config.interval(); + let mut interval = time::interval_at(start, config.interval()); + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + loop { + tokio::select! { + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { + return Ok(()); + } + } + _ = interval.tick() => { + let snapshot_report = snapshots.reconcile_startup().await?; + log_snapshot_report("snapshot maintenance", &snapshot_report); + let report = supervisor.reap_expired(config.batch_size()).await?; + log_report("expiry maintenance", &report); + } + } + } +} + +fn log_snapshot_report(operation: &str, report: &SnapshotReconciliationReport) { + if report.failures.is_empty() { + info!( + operation, + examined = report.examined, + completed = report.completed, + deferred = report.deferred, + "snapshot maintenance completed" + ); + return; + } + warn!( + operation, + examined = report.examined, + completed = report.completed, + deferred = report.deferred, + failures = report.failures.len(), + "snapshot maintenance completed with isolated record failures" + ); + for message in &report.failures { + error!(operation, message, "snapshot record maintenance failed"); + } +} + +fn log_volume_report(operation: &str, report: &VolumeReconciliationReport) { + if report.failures.is_empty() { + info!( + operation, + examined = report.examined, + completed = report.completed, + deferred = report.deferred, + "volume maintenance completed" + ); + return; + } + warn!( + operation, + examined = report.examined, + completed = report.completed, + deferred = report.deferred, + failures = report.failures.len(), + "volume maintenance completed with isolated record failures" + ); + for message in &report.failures { + error!(operation, message, "volume record maintenance failed"); + } +} + +fn log_report(operation: &str, report: &LifecycleMaintenanceReport) { + if report.failures.is_empty() { + info!( + operation, + examined = report.examined, + completed = report.completed, + deferred = report.deferred, + "lifecycle maintenance completed" + ); + return; + } + warn!( + operation, + examined = report.examined, + completed = report.completed, + deferred = report.deferred, + failures = report.failures.len(), + "lifecycle maintenance completed with isolated record failures" + ); + for failure in &report.failures { + error!( + operation, + sandbox_id = %failure.sandbox_id, + message = %failure.message, + "lifecycle record maintenance failed" + ); + } +} + +async fn prepare_parent(path: &Path) -> E2bServiceResult<()> { + let parent = path.parent().ok_or_else(|| E2bServiceError::InvalidPath { + path: path.to_path_buf(), + })?; + prepare_directory(parent).await +} + +async fn prepare_directory(path: &Path) -> E2bServiceResult<()> { + tokio::fs::create_dir_all(path) + .await + .map_err(|source| E2bServiceError::CreateDirectory { + path: path.to_path_buf(), + source, + }) +} + +async fn wait_for_shutdown(mut shutdown: watch::Receiver) { + while !*shutdown.borrow() { + if shutdown.changed().await.is_err() { + return; + } + } +} + +fn request_shutdown(sender: &watch::Sender) { + let _ = sender.send(true); +} + +fn join_maintenance( + result: Result, tokio::task::JoinError>, +) -> E2bServiceResult<()> { + result.map_err(E2bServiceError::MaintenanceTask)? +} + +#[cfg(unix)] +async fn shutdown_signal() -> E2bServiceResult<()> { + use tokio::signal::unix::{signal, SignalKind}; + + let mut terminate = signal(SignalKind::terminate()).map_err(E2bServiceError::Signal)?; + tokio::select! { + result = tokio::signal::ctrl_c() => result.map_err(E2bServiceError::Signal)?, + _ = terminate.recv() => {}, + } + Ok(()) +} + +#[cfg(not(unix))] +async fn shutdown_signal() -> E2bServiceResult<()> { + tokio::signal::ctrl_c() + .await + .map_err(E2bServiceError::Signal) +} + +#[derive(Debug, Error)] +pub enum E2bServiceError { + #[error("invalid service state path: {path}")] + InvalidPath { path: PathBuf }, + #[error("failed to create service directory {path}: {source}")] + CreateDirectory { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to bind E2B compatibility listener {address}: {source}")] + Bind { + address: SocketAddr, + #[source] + source: std::io::Error, + }, + #[error("failed to create E2B compatibility listener: {0}")] + Listener(#[source] hyper::Error), + #[error("E2B compatibility HTTP server failed: {0}")] + Server(#[source] hyper::Error), + #[error("failed to install or receive the shutdown signal: {0}")] + Signal(#[source] std::io::Error), + #[error("lifecycle maintenance task failed: {0}")] + MaintenanceTask(#[source] tokio::task::JoinError), + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Credential(#[from] CredentialHashError), + #[error(transparent)] + Token(#[from] TokenIssuerError), + #[error(transparent)] + Supervisor(#[from] LifecycleSupervisorError), + #[error(transparent)] + Gateway(#[from] DataPlaneGatewayError), + #[error(transparent)] + Volume(#[from] crate::volume::VolumeServiceError), + #[error(transparent)] + VolumeContent(#[from] crate::volume::VolumeContentError), + #[error(transparent)] + Snapshot(#[from] crate::snapshot::SnapshotServiceError), +} + +pub type E2bServiceResult = std::result::Result; diff --git a/src/compat/src/production/template.rs b/src/compat/src/production/template.rs new file mode 100644 index 00000000..8da78846 --- /dev/null +++ b/src/compat/src/production/template.rs @@ -0,0 +1,94 @@ +use std::collections::BTreeMap; + +use a3s_box_core::resolve_execution; +use async_trait::async_trait; + +use crate::control::{ + ResolvedTemplate, TemplateProvider, TemplateProviderError, TemplateProviderResult, +}; + +/// Immutable startup-validated template catalog. +#[derive(Debug, Clone)] +pub struct StaticTemplateProvider { + templates: BTreeMap, +} + +impl StaticTemplateProvider { + pub fn new( + templates: impl IntoIterator, + ) -> TemplateProviderResult { + let mut catalog = BTreeMap::new(); + for (template_id, template) in templates { + validate_template(&template_id, &template)?; + if catalog.insert(template_id.clone(), template).is_some() { + return Err(TemplateProviderError::Invalid(format!( + "template ID {template_id} is configured more than once" + ))); + } + } + if catalog.is_empty() { + return Err(TemplateProviderError::Invalid( + "at least one template policy is required".to_string(), + )); + } + Ok(Self { templates: catalog }) + } + + pub fn contains(&self, template_id: &str) -> bool { + self.templates.contains_key(template_id) + } + + pub fn len(&self) -> usize { + self.templates.len() + } + + pub fn is_empty(&self) -> bool { + self.templates.is_empty() + } +} + +#[async_trait] +impl TemplateProvider for StaticTemplateProvider { + async fn resolve( + &self, + _owner_id: &str, + template_id: &str, + ) -> TemplateProviderResult { + self.templates + .get(template_id) + .cloned() + .ok_or_else(|| TemplateProviderError::NotFound(template_id.to_string())) + } +} + +fn validate_template(template_id: &str, template: &ResolvedTemplate) -> TemplateProviderResult<()> { + if template_id.is_empty() + || template_id.len() > 128 + || template_id.chars().any(char::is_whitespace) + { + return Err(TemplateProviderError::Invalid(format!( + "template ID {template_id:?} is invalid" + ))); + } + if template.config.image.trim().is_empty() { + return Err(TemplateProviderError::Invalid(format!( + "template {template_id} has no OCI image" + ))); + } + if template.envd_version.trim().is_empty() { + return Err(TemplateProviderError::Invalid(format!( + "template {template_id} has no envd version" + ))); + } + template.routing.validate().map_err(|error| { + TemplateProviderError::Invalid(format!( + "template {template_id} has an invalid route policy: {error}" + )) + })?; + resolve_execution(&template.config).map_err(|error| { + TemplateProviderError::Invalid(format!( + "template {template_id} has an invalid execution policy: {error}" + )) + })?; + Ok(()) +} diff --git a/src/compat/src/production/tests.rs b/src/compat/src/production/tests.rs new file mode 100644 index 00000000..f20393cf --- /dev/null +++ b/src/compat/src/production/tests.rs @@ -0,0 +1,357 @@ +use std::path::Path; + +use a3s_box_core::ExecutionIsolation; +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use tower::ServiceExt; + +use crate::control::{EnvdMode, SandboxIdentityProvider, TemplateProvider, TokenScope}; +use crate::http::CredentialHash; +use crate::routing::{CODE_INTERPRETER_PORT, ENVD_PORT}; + +use super::config::E2bCompatConfig; +use super::{E2bCompatService, E2bConfigError, UuidSandboxIdentityProvider}; + +fn parse_config(root: &Path) -> E2bCompatConfig { + write_test_tls(root); + E2bCompatConfig::parse_with_environment(&acl_config(root), test_environment).unwrap() +} + +fn acl_config(root: &Path) -> String { + let database = acl_path(&root.join("lifecycle.sqlite3")); + let runtime_home = acl_path(&root.join("runtime")); + let runtime_state = acl_path(&root.join("managed-executions.json")); + let certificate = acl_path(&root.join("gateway-cert.pem")); + let private_key = acl_path(&root.join("gateway-key.pem")); + let hash = CredentialHash::derive("e2b_a1b2c3", 100_000, &[3; 16]).unwrap(); + format!( + r#" +e2b_compat {{ + api_listen = "127.0.0.1:3001" + api_public_url = "https://api.box.example.com" + sandbox_domain = "box.example.com" + sandbox_public_domain = "box.example.com:3443" + database_path = "{database}" + runtime_home = "{runtime_home}" + runtime_state_path = "{runtime_state}" + max_json_bytes = 2097152 + + gateway {{ + listen = "127.0.0.1:3002" + tls_certificate_path = "{certificate}" + tls_private_key_path = "{private_key}" + max_connections = 1024 + handshake_timeout_ms = 5000 + connect_timeout_ms = 2000 + drain_timeout_seconds = 10 + }} + + supervisor {{ + interval_seconds = 5 + batch_size = 100 + reconciliation_page_size = 200 + }} + + account "primary" {{ + scheme = "api_key" + owner_id = "owner-production" + client_id = "client-production" + hash = "{hash}" + }} + + token_key "2026-07" {{ + version = 7 + active = true + encryption_key = env("TOKEN_ENCRYPTION") + digest_key = env("TOKEN_DIGEST") + }} + + template_policy "fixture-template" {{ + image = "alpine:3.20" + envd_version = "0.1.3" + envd_mode = "runtime" + isolation = "sandbox" + network = "none" + command = ["/bin/sh", "-c", "while :; do sleep 60; done"] + read_only = false + stdin_open = false + + resources {{ + vcpus = 2 + memory_mb = 512 + disk_mb = 1024 + }} + + route {{ + port = 49983 + token_scope = "envd" + }} + + route {{ + port = 49999 + token_scope = "traffic" + }} + }} +}} +"# + ) +} + +fn write_test_tls(root: &Path) { + let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(vec![ + "*.box.example.com".to_string(), + "sandbox.box.example.com".to_string(), + ]) + .unwrap(); + std::fs::write(root.join("gateway-cert.pem"), cert.pem()).unwrap(); + std::fs::write(root.join("gateway-key.pem"), key_pair.serialize_pem()).unwrap(); +} + +fn acl_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn test_environment(name: &str) -> Option { + match name { + "TOKEN_ENCRYPTION" => Some(hex::encode([7_u8; 32])), + "TOKEN_DIGEST" => Some(hex::encode([8_u8; 32])), + _ => None, + } +} + +#[tokio::test] +async fn parses_acl_into_strict_runtime_credentials_and_template_policy() { + let root = tempfile::tempdir().unwrap(); + let config = parse_config(root.path()); + + assert_eq!(config.api_listen().to_string(), "127.0.0.1:3001"); + assert_eq!( + config.api_public_url().as_str(), + "https://api.box.example.com/" + ); + assert_eq!(config.sandbox_domain(), "box.example.com"); + assert_eq!(config.sandbox_public_domain(), "box.example.com:3443"); + assert_eq!(config.gateway().listen().to_string(), "127.0.0.1:3002"); + assert_eq!(config.gateway().max_connections().get(), 1024); + assert_eq!(config.supervisor().batch_size().get(), 100); + assert_eq!(config.templates.len(), 1); + let template = config + .templates + .resolve("fixture-owner", "fixture-template") + .await + .unwrap(); + assert_eq!(template.config.isolation, ExecutionIsolation::Sandbox); + assert_eq!(template.config.image, "alpine:3.20"); + assert_eq!(template.config.resources.memory_mb, 512); + assert_eq!(template.envd_mode, EnvdMode::Runtime); + assert_eq!( + template.routing.token_scope(ENVD_PORT), + Some(TokenScope::Envd) + ); + assert_eq!( + template.routing.token_scope(CODE_INTERPRETER_PORT), + Some(TokenScope::Traffic) + ); + + let debug = format!("{config:?}"); + assert!(!debug.contains(&hex::encode([7_u8; 32]))); + assert!(!debug.contains(&hex::encode([8_u8; 32]))); + assert!(!debug.contains("e2b_a1b2c3")); +} + +#[test] +fn rejects_plaintext_token_keys_missing_environment_and_unknown_fields() { + let root = tempfile::tempdir().unwrap(); + let input = acl_config(root.path()); + + let plaintext = input.replace( + "env(\"TOKEN_ENCRYPTION\")", + &format!("\"{}\"", hex::encode([7_u8; 32])), + ); + assert!( + E2bCompatConfig::parse_with_environment(&plaintext, test_environment) + .unwrap_err() + .to_string() + .contains("must use env") + ); + + let missing = E2bCompatConfig::parse_with_environment(&input, |_| None).unwrap_err(); + assert!(missing.to_string().contains("TOKEN_ENCRYPTION")); + assert!(!missing.to_string().contains(&hex::encode([7_u8; 32]))); + + let unknown = input.replace( + "max_json_bytes = 2097152", + "max_json_bytes = 2097152\n accidental_backend = \"unsafe\"", + ); + assert!( + E2bCompatConfig::parse_with_environment(&unknown, test_environment) + .unwrap_err() + .to_string() + .contains("unknown attribute accidental_backend") + ); + + let mismatched_domain = input.replace( + "sandbox_public_domain = \"box.example.com:3443\"", + "sandbox_public_domain = \"other.example.com:3443\"", + ); + assert!( + E2bCompatConfig::parse_with_environment(&mismatched_domain, test_environment) + .unwrap_err() + .to_string() + .contains("must match sandbox_domain") + ); + + let zero_port = input.replace( + "sandbox_public_domain = \"box.example.com:3443\"", + "sandbox_public_domain = \"box.example.com:0\"", + ); + assert!( + E2bCompatConfig::parse_with_environment(&zero_port, test_environment) + .unwrap_err() + .to_string() + .contains("non-zero TCP port") + ); + + let default_domain = input.replace(" sandbox_public_domain = \"box.example.com:3443\"\n", ""); + let defaulted = + E2bCompatConfig::parse_with_environment(&default_domain, test_environment).unwrap(); + assert_eq!(defaulted.sandbox_public_domain(), "box.example.com"); +} + +#[tokio::test] +async fn defaults_templates_to_broker_envd_and_rejects_unknown_modes() { + let root = tempfile::tempdir().unwrap(); + let input = acl_config(root.path()); + let broker = input.replace(" envd_mode = \"runtime\"\n", ""); + let config = E2bCompatConfig::parse_with_environment(&broker, test_environment).unwrap(); + let template = config + .templates + .resolve("fixture-owner", "fixture-template") + .await + .unwrap(); + assert_eq!(template.envd_mode, EnvdMode::Broker); + + let invalid = input.replace("envd_mode = \"runtime\"", "envd_mode = \"sidecar\""); + assert!( + E2bCompatConfig::parse_with_environment(&invalid, test_environment) + .unwrap_err() + .to_string() + .contains("envd_mode must be broker or runtime") + ); +} + +#[test] +fn rejects_missing_or_unsafe_gateway_configuration() { + let root = tempfile::tempdir().unwrap(); + let input = acl_config(root.path()); + + let missing = input.replace( + &input[input.find(" gateway {").unwrap()..input.find(" supervisor {").unwrap()], + "", + ); + assert!( + E2bCompatConfig::parse_with_environment(&missing, test_environment) + .unwrap_err() + .to_string() + .contains("gateway block is required") + ); + + let same_listener = input.replace("listen = \"127.0.0.1:3002\"", "listen = \"127.0.0.1:3001\""); + assert!( + E2bCompatConfig::parse_with_environment(&same_listener, test_environment) + .unwrap_err() + .to_string() + .contains("must differ") + ); + + let relative_key = input.replace( + &format!( + "tls_private_key_path = \"{}\"", + acl_path(&root.path().join("gateway-key.pem")) + ), + "tls_private_key_path = \"gateway-key.pem\"", + ); + assert!( + E2bCompatConfig::parse_with_environment(&relative_key, test_environment) + .unwrap_err() + .to_string() + .contains("absolute normalized path") + ); + + let unbounded = input.replace("max_connections = 1024", "max_connections = 0"); + assert!( + E2bCompatConfig::parse_with_environment(&unbounded, test_environment) + .unwrap_err() + .to_string() + .contains("max_connections must be between") + ); +} + +#[tokio::test] +async fn loader_requires_the_acl_extension_before_reading() { + let root = tempfile::tempdir().unwrap(); + let error = E2bCompatConfig::load(root.path().join("service.conf")) + .await + .unwrap_err(); + assert!(matches!(error, E2bConfigError::InvalidExtension(_))); +} + +#[test] +fn uuid_identity_provider_generates_valid_independent_ids() { + let provider = UuidSandboxIdentityProvider; + let first = provider.next_identity().unwrap(); + let second = provider.next_identity().unwrap(); + + assert!(first.sandbox_id.as_str().starts_with("sandbox-")); + assert!(first.operation_id.as_str().starts_with("e2b-create-")); + assert_ne!(first.sandbox_id, second.sandbox_id); + assert_ne!(first.operation_id, second.operation_id); +} + +#[tokio::test] +async fn production_composition_wires_auth_sqlite_routing_and_supervision_without_launching_runtime( +) { + let root = tempfile::tempdir().unwrap(); + let service = E2bCompatService::build(parse_config(root.path())) + .await + .unwrap(); + assert_eq!(service.listen().to_string(), "127.0.0.1:3001"); + assert_eq!(service.gateway_listen().to_string(), "127.0.0.1:3002"); + assert_eq!(service.sandbox_domain(), "box.example.com"); + assert_eq!(service.sandbox_public_domain(), "box.example.com:3443"); + assert!(service + .route_parser() + .parse_host( + "49983-sandbox-example.box.example.com", + &axum::http::HeaderMap::new(), + ) + .is_ok()); + let report = service.reconcile_startup().await.unwrap(); + assert_eq!(report.examined, 0); + + let unauthenticated = service + .router() + .oneshot( + Request::builder() + .uri("/v2/sandboxes") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let authenticated = service + .router() + .oneshot( + Request::builder() + .uri("/v2/sandboxes") + .header("x-api-key", "e2b_a1b2c3") + .header(header::ACCEPT, "application/json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authenticated.status(), StatusCode::OK); +} diff --git a/src/compat/src/proto.rs b/src/compat/src/proto.rs new file mode 100644 index 00000000..8924e1aa --- /dev/null +++ b/src/compat/src/proto.rs @@ -0,0 +1,255 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use prost::Message; +use prost_types::{ + field_descriptor_proto::{Label, Type}, + DescriptorProto, EnumDescriptorProto, FileDescriptorProto, FileDescriptorSet, +}; + +use crate::digest::sha256; +use crate::model::{ + ProtoEnum, ProtoEnumValue, ProtoField, ProtoFileInventory, ProtoMessage, ProtoMethod, + ProtoService, +}; +use serde::Serialize; + +pub(crate) fn read_protobuf_contracts( + proto_root: &Path, + relative_paths: &[&str], +) -> Result> { + let descriptor = tempfile::NamedTempFile::new() + .context("failed to create temporary Protobuf descriptor file")?; + let descriptor_path = descriptor.path().to_path_buf(); + let mut command = Command::new("protoc"); + command + .current_dir(proto_root) + .arg("--include_imports") + .arg("--proto_path=.") + .arg(format!( + "--descriptor_set_out={}", + descriptor_path.display() + )); + for include in [ + "/usr/include", + "/usr/local/include", + "/opt/homebrew/include", + "/usr/local/opt/protobuf/include", + ] { + if Path::new(include).is_dir() { + command.arg(format!("--proto_path={include}")); + } + } + for path in relative_paths { + command.arg(path); + } + let output = command.output().with_context(|| { + format!( + "failed to execute protoc for contracts below {}", + proto_root.display() + ) + })?; + if !output.status.success() { + bail!( + "protoc failed for contracts below {}: {}", + proto_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let bytes = std::fs::read(&descriptor_path) + .context("failed to read generated Protobuf descriptor set")?; + let descriptors = FileDescriptorSet::decode(bytes.as_slice()) + .context("failed to decode generated Protobuf descriptor set")?; + let by_name = descriptors + .file + .into_iter() + .filter_map(|file| file.name.clone().map(|name| (name, file))) + .collect::>(); + + let mut inventory = Vec::new(); + for relative_path in relative_paths { + let file = by_name.get(*relative_path).ok_or_else(|| { + anyhow::anyhow!( + "protoc descriptor set did not contain requested contract {relative_path}" + ) + })?; + inventory.push(file_inventory(relative_path, file)?); + } + inventory.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(inventory) +} + +fn file_inventory(path: &str, file: &FileDescriptorProto) -> Result { + let mut services = file + .service + .iter() + .map(|service| { + let mut methods = service + .method + .iter() + .map(|method| ProtoMethod { + name: method.name.clone().unwrap_or_default(), + input_type: method.input_type.clone().unwrap_or_default(), + output_type: method.output_type.clone().unwrap_or_default(), + client_streaming: method.client_streaming.unwrap_or(false), + server_streaming: method.server_streaming.unwrap_or(false), + }) + .collect::>(); + methods.sort_by(|left, right| left.name.cmp(&right.name)); + ProtoService { + name: service.name.clone().unwrap_or_default(), + methods, + } + }) + .collect::>(); + services.sort_by(|left, right| left.name.cmp(&right.name)); + + let mut messages = Vec::new(); + let mut enums = file + .enum_type + .iter() + .map(enum_inventory) + .collect::>(); + for message in &file.message_type { + collect_message(message, "", &mut messages, &mut enums); + } + messages.sort_by(|left, right| left.name.cmp(&right.name)); + enums.sort_by(|left, right| left.name.cmp(&right.name)); + + let package = file.package.clone().unwrap_or_default(); + let descriptor_digest = + normalized_descriptor_digest(path, &package, &services, &messages, &enums)?; + Ok(ProtoFileInventory { + path: path.to_string(), + package, + descriptor_digest, + services, + messages, + enums, + }) +} + +fn normalized_descriptor_digest( + path: &str, + package: &str, + services: &[ProtoService], + messages: &[ProtoMessage], + enums: &[ProtoEnum], +) -> Result { + #[derive(Serialize)] + struct NormalizedDescriptor<'a> { + path: &'a str, + package: &'a str, + services: &'a [ProtoService], + messages: &'a [ProtoMessage], + enums: &'a [ProtoEnum], + } + let bytes = serde_json::to_vec(&NormalizedDescriptor { + path, + package, + services, + messages, + enums, + }) + .context("failed to serialize normalized Protobuf descriptor")?; + Ok(sha256(&bytes)) +} + +fn collect_message( + message: &DescriptorProto, + parent: &str, + messages: &mut Vec, + enums: &mut Vec, +) { + let local_name = message.name.clone().unwrap_or_default(); + let name = if parent.is_empty() { + local_name + } else { + format!("{parent}.{local_name}") + }; + let oneofs = message + .oneof_decl + .iter() + .map(|oneof| oneof.name.clone().unwrap_or_default()) + .collect::>(); + let mut fields = message + .field + .iter() + .map(|field| ProtoField { + name: field.name.clone().unwrap_or_default(), + number: field.number.unwrap_or_default(), + label: Label::try_from(field.label.unwrap_or_default()) + .map(|label| label.as_str_name().to_string()) + .unwrap_or_else(|_| "LABEL_UNKNOWN".to_string()), + field_type: Type::try_from(field.r#type.unwrap_or_default()) + .map(|field_type| field_type.as_str_name().to_string()) + .unwrap_or_else(|_| "TYPE_UNKNOWN".to_string()), + type_name: field.type_name.clone().filter(|value| !value.is_empty()), + oneof: field + .oneof_index + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| oneofs.get(index).cloned()), + }) + .collect::>(); + fields.sort_by_key(|field| field.number); + messages.push(ProtoMessage { + name: name.clone(), + fields, + }); + + for nested in &message.nested_type { + collect_message(nested, &name, messages, enums); + } + for nested in &message.enum_type { + let mut inventory = enum_inventory(nested); + inventory.name = format!("{name}.{}", inventory.name); + enums.push(inventory); + } +} + +fn enum_inventory(enumeration: &EnumDescriptorProto) -> ProtoEnum { + let mut values = enumeration + .value + .iter() + .map(|value| ProtoEnumValue { + name: value.name.clone().unwrap_or_default(), + number: value.number.unwrap_or_default(), + }) + .collect::>(); + values.sort_by_key(|value| value.number); + ProtoEnum { + name: enumeration.name.clone().unwrap_or_default(), + values, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn descriptor_inventory_preserves_streaming_and_oneofs() { + let file = FileDescriptorProto { + name: Some("process.proto".to_string()), + package: Some("process".to_string()), + service: vec![prost_types::ServiceDescriptorProto { + name: Some("Process".to_string()), + method: vec![prost_types::MethodDescriptorProto { + name: Some("Start".to_string()), + input_type: Some(".process.StartRequest".to_string()), + output_type: Some(".process.StartResponse".to_string()), + server_streaming: Some(true), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + let inventory = file_inventory("process.proto", &file).expect("build inventory"); + assert!(inventory.services[0].methods[0].server_streaming); + assert!(inventory.descriptor_digest.starts_with("sha256:")); + } +} diff --git a/src/compat/src/routing/lease.rs b/src/compat/src/routing/lease.rs new file mode 100644 index 00000000..45f73b67 --- /dev/null +++ b/src/compat/src/routing/lease.rs @@ -0,0 +1,630 @@ +use std::num::NonZeroU16; +use std::sync::Arc; + +use a3s_box_core::{ExecutionGeneration, ExecutionId}; +use axum::http::{HeaderMap, HeaderName}; +use chrono::{DateTime, Utc}; +use thiserror::Error; + +use crate::control::{ + Clock, EnvdMode, LifecycleState, RepositoryError, SandboxGeneration, SandboxId, SandboxRecord, + SandboxRepository, SecretToken, TokenIssuerError, TokenScope, TokenVerifier, +}; + +use super::ParsedSandboxRoute; + +pub const ENVD_ACCESS_TOKEN_HEADER: &str = "x-access-token"; +pub const TRAFFIC_ACCESS_TOKEN_HEADER: &str = "e2b-traffic-access-token"; +const MAX_TOKEN_BYTES: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouteLease { + sandbox_id: SandboxId, + execution_id: ExecutionId, + sandbox_generation: SandboxGeneration, + execution_generation: ExecutionGeneration, + port: NonZeroU16, + token_scope: TokenScope, + envd_mode: EnvdMode, + expires_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EnvdHealthResolution { + Running(RouteLease), + Inactive, +} + +impl RouteLease { + pub fn sandbox_id(&self) -> &SandboxId { + &self.sandbox_id + } + + pub fn execution_id(&self) -> &ExecutionId { + &self.execution_id + } + + pub const fn sandbox_generation(&self) -> SandboxGeneration { + self.sandbox_generation + } + + pub const fn execution_generation(&self) -> ExecutionGeneration { + self.execution_generation + } + + pub const fn port(&self) -> NonZeroU16 { + self.port + } + + pub const fn token_scope(&self) -> TokenScope { + self.token_scope + } + + pub const fn envd_mode(&self) -> EnvdMode { + self.envd_mode + } + + pub const fn expires_at(&self) -> DateTime { + self.expires_at + } + + pub fn is_current(&self, record: &SandboxRecord, now: DateTime) -> bool { + record.state() == LifecycleState::Running + && record.sandbox_id() == &self.sandbox_id + && record.generation() == self.sandbox_generation + && record.execution_id() == Some(&self.execution_id) + && record.execution_generation() == Some(self.execution_generation) + && record.expires_at() > now + && record.envd_mode() == self.envd_mode + && record.routing().token_scope(self.port.get()) == Some(self.token_scope) + } +} + +#[derive(Clone)] +pub struct RouteLeaseService { + repository: Arc, + tokens: Arc, + clock: Arc, +} + +impl RouteLeaseService { + pub fn new( + repository: Arc, + tokens: Arc, + clock: Arc, + ) -> Self { + Self { + repository, + tokens, + clock, + } + } + + pub async fn resolve( + &self, + route: &ParsedSandboxRoute, + headers: &HeaderMap, + ) -> RouteLeaseResult { + let record = self + .repository + .get(&route.sandbox_id) + .await? + .ok_or(RouteLeaseError::NotFound)?; + if record.state() != LifecycleState::Running { + return Err(RouteLeaseError::Inactive); + } + let now = self.clock.now(); + if record.expires_at() <= now { + return Err(RouteLeaseError::Expired); + } + let token_scope = self.verify_route_token(&record, route, headers).await?; + let execution_id = record + .execution_id() + .cloned() + .ok_or(RouteLeaseError::InvalidRecord)?; + let execution_generation = record + .execution_generation() + .ok_or(RouteLeaseError::InvalidRecord)?; + Ok(RouteLease { + sandbox_id: record.sandbox_id().clone(), + execution_id, + sandbox_generation: record.generation(), + execution_generation, + port: route.port, + token_scope, + envd_mode: record.envd_mode(), + expires_at: record.expires_at(), + }) + } + + /// Resolve an authenticated envd health request without issuing a live + /// route lease for an inactive or expired sandbox. + /// + /// This preserves the official client's `502 -> false` behavior after a + /// successful kill while ensuring an invalid token cannot probe terminal + /// sandbox state. All other data-plane requests continue to require a live + /// [`RouteLease`]. + pub async fn resolve_envd_health( + &self, + route: &ParsedSandboxRoute, + headers: &HeaderMap, + ) -> RouteLeaseResult { + let record = self + .repository + .get(&route.sandbox_id) + .await? + .ok_or(RouteLeaseError::NotFound)?; + let token_scope = self.verify_route_token(&record, route, headers).await?; + if token_scope != TokenScope::Envd { + return Err(RouteLeaseError::PortDenied); + } + let now = self.clock.now(); + if record.state() != LifecycleState::Running || record.expires_at() <= now { + return Ok(EnvdHealthResolution::Inactive); + } + let execution_id = record + .execution_id() + .cloned() + .ok_or(RouteLeaseError::InvalidRecord)?; + let execution_generation = record + .execution_generation() + .ok_or(RouteLeaseError::InvalidRecord)?; + Ok(EnvdHealthResolution::Running(RouteLease { + sandbox_id: record.sandbox_id().clone(), + execution_id, + sandbox_generation: record.generation(), + execution_generation, + port: route.port, + token_scope, + envd_mode: record.envd_mode(), + expires_at: record.expires_at(), + })) + } + + async fn verify_route_token( + &self, + record: &SandboxRecord, + route: &ParsedSandboxRoute, + headers: &HeaderMap, + ) -> RouteLeaseResult { + let token_scope = record + .routing() + .token_scope(route.port.get()) + .ok_or(RouteLeaseError::PortDenied)?; + let presented = presented_token(headers, token_scope)?; + let stored = match token_scope { + TokenScope::Envd => &record.credentials().envd, + TokenScope::Traffic => &record.credentials().traffic, + TokenScope::Volume => return Err(RouteLeaseError::PortDenied), + }; + if !self.tokens.verify(token_scope, &presented, stored).await? { + return Err(RouteLeaseError::Unauthorized); + } + Ok(token_scope) + } +} + +fn presented_token(headers: &HeaderMap, scope: TokenScope) -> RouteLeaseResult { + let name = HeaderName::from_static(match scope { + TokenScope::Envd => ENVD_ACCESS_TOKEN_HEADER, + TokenScope::Traffic => TRAFFIC_ACCESS_TOKEN_HEADER, + TokenScope::Volume => return Err(RouteLeaseError::PortDenied), + }); + let mut values = headers.get_all(name).iter(); + let value = values.next().ok_or(RouteLeaseError::MissingToken)?; + if values.next().is_some() { + return Err(RouteLeaseError::InvalidToken); + } + let value = value.to_str().map_err(|_| RouteLeaseError::InvalidToken)?; + if value.is_empty() || value.len() > MAX_TOKEN_BYTES { + return Err(RouteLeaseError::InvalidToken); + } + SecretToken::new(value).map_err(|_| RouteLeaseError::InvalidToken) +} + +#[derive(Debug, Error)] +pub enum RouteLeaseError { + #[error("sandbox route was not found")] + NotFound, + #[error("sandbox route is not active")] + Inactive, + #[error("sandbox route has expired")] + Expired, + #[error("sandbox port is not routed")] + PortDenied, + #[error("sandbox route token is missing")] + MissingToken, + #[error("sandbox route token is invalid")] + InvalidToken, + #[error("sandbox route is unauthorized")] + Unauthorized, + #[error("sandbox route record is invalid")] + InvalidRecord, + #[error(transparent)] + Repository(#[from] RepositoryError), + #[error(transparent)] + Token(#[from] TokenIssuerError), +} + +pub type RouteLeaseResult = std::result::Result; + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use a3s_box_core::{ + resolve_execution, BoxConfig, ExecutionIsolation, ExecutionLease, OperationId, + }; + use axum::http::HeaderValue; + use chrono::{Duration, TimeZone}; + + use crate::control::{ + LifecyclePolicy, MemorySandboxRepository, NewSandboxRecord, OnTimeoutAction, + RotatingTokenProvider, SandboxCredentials, SandboxRecord, SqliteSandboxRepository, + TokenIssuer, TokenKeyMaterial, + }; + use crate::routing::{SandboxRoutePolicy, CODE_INTERPRETER_PORT, ENVD_PORT}; + + use super::*; + + struct FixedClock(DateTime); + + impl Clock for FixedClock { + fn now(&self) -> DateTime { + self.0 + } + } + + struct Harness { + repository: Arc, + service: RouteLeaseService, + envd_secret: SecretToken, + traffic_secret: SecretToken, + now: DateTime, + sandbox_id: SandboxId, + } + + impl Harness { + async fn new() -> Self { + let now = Utc + .with_ymd_and_hms(2026, 7, 15, 10, 0, 0) + .single() + .unwrap(); + let tokens = Arc::new( + RotatingTokenProvider::new( + 1, + [TokenKeyMaterial::new(1, &[7; 32], &[8; 32]).unwrap()], + ) + .unwrap(), + ); + let envd = tokens.issue(TokenScope::Envd).await.unwrap(); + let traffic = tokens.issue(TokenScope::Traffic).await.unwrap(); + let sandbox_id = SandboxId::new("sandbox-route-1").unwrap(); + let config = BoxConfig { + isolation: ExecutionIsolation::Sandbox, + ..BoxConfig::default() + }; + let plan = resolve_execution(&config).unwrap(); + let routing = SandboxRoutePolicy::default() + .with_port(CODE_INTERPRETER_PORT, TokenScope::Traffic) + .unwrap(); + let mut record = SandboxRecord::creating(NewSandboxRecord { + sandbox_id: sandbox_id.clone(), + operation_id: OperationId::new("operation-route-1").unwrap(), + owner_id: "owner-route".to_string(), + template_id: "code-interpreter-v1".to_string(), + plan: plan.clone(), + resources: config.resources.clone(), + lifecycle: LifecyclePolicy { + on_timeout: OnTimeoutAction::Kill, + auto_resume: false, + keep_memory_on_pause: false, + }, + created_at: now, + expires_at: now + Duration::minutes(5), + metadata: BTreeMap::new(), + envd_version: "0.1.3".to_string(), + envd_mode: EnvdMode::Broker, + secure: true, + allow_internet_access: Some(false), + credentials: SandboxCredentials { + envd: envd.stored, + traffic: traffic.stored, + }, + routing, + }) + .unwrap(); + record + .mark_running(ExecutionLease { + execution_id: ExecutionId::new("execution-route-1").unwrap(), + generation: ExecutionGeneration::INITIAL, + plan, + resources: config.resources, + started_at: now, + }) + .unwrap(); + let repository = Arc::new(MemorySandboxRepository::default()); + repository.insert(record).await.unwrap(); + let service = + RouteLeaseService::new(repository.clone(), tokens, Arc::new(FixedClock(now))); + Self { + repository, + service, + envd_secret: envd.secret, + traffic_secret: traffic.secret, + now, + sandbox_id, + } + } + + fn route(&self, port: u16) -> ParsedSandboxRoute { + ParsedSandboxRoute { + sandbox_id: self.sandbox_id.clone(), + port: NonZeroU16::new(port).unwrap(), + form: super::super::RouteForm::Direct, + } + } + } + + fn token_headers(name: &'static str, token: &SecretToken) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(name, HeaderValue::from_str(token.expose_secret()).unwrap()); + headers + } + + #[tokio::test] + async fn resolves_scope_bound_generation_fenced_leases() { + let harness = Harness::new().await; + let envd = harness + .service + .resolve( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await + .unwrap(); + assert_eq!(envd.token_scope(), TokenScope::Envd); + assert_eq!(envd.execution_id().as_str(), "execution-route-1"); + assert!(matches!( + harness + .service + .resolve_envd_health( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await + .unwrap(), + EnvdHealthResolution::Running(lease) if lease == envd + )); + + let traffic = harness + .service + .resolve( + &harness.route(CODE_INTERPRETER_PORT), + &token_headers(TRAFFIC_ACCESS_TOKEN_HEADER, &harness.traffic_secret), + ) + .await + .unwrap(); + assert_eq!(traffic.token_scope(), TokenScope::Traffic); + assert!(traffic.is_current( + &harness + .repository + .get(&harness.sandbox_id) + .await + .unwrap() + .unwrap(), + harness.now + )); + } + + #[tokio::test] + async fn rejects_swapped_tokens_unrouted_ports_and_stale_generations() { + let harness = Harness::new().await; + assert!(matches!( + harness + .service + .resolve( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.traffic_secret), + ) + .await, + Err(RouteLeaseError::Unauthorized) + )); + assert!(matches!( + harness + .service + .resolve( + &harness.route(8080), + &token_headers(TRAFFIC_ACCESS_TOKEN_HEADER, &harness.traffic_secret), + ) + .await, + Err(RouteLeaseError::PortDenied) + )); + + let old = harness + .service + .resolve( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await + .unwrap(); + let mut record = harness + .repository + .get(&harness.sandbox_id) + .await + .unwrap() + .unwrap(); + let expected = record.generation(); + record + .replace_expiry(harness.now + Duration::minutes(10)) + .unwrap(); + harness + .repository + .compare_and_swap(&harness.sandbox_id, expected, record.clone()) + .await + .unwrap(); + assert!(!old.is_current(&record, harness.now)); + + let renewed = harness + .service + .resolve( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await + .unwrap(); + assert!(renewed.sandbox_generation() > old.sandbox_generation()); + + let expected = record.generation(); + record.begin_kill().unwrap(); + harness + .repository + .compare_and_swap(&harness.sandbox_id, expected, record.clone()) + .await + .unwrap(); + let expected = record.generation(); + record.mark_killed().unwrap(); + harness + .repository + .compare_and_swap(&harness.sandbox_id, expected, record) + .await + .unwrap(); + assert!(matches!( + harness + .service + .resolve( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await, + Err(RouteLeaseError::Inactive) + )); + assert_eq!( + harness + .service + .resolve_envd_health( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await + .unwrap(), + EnvdHealthResolution::Inactive + ); + assert!(matches!( + harness + .service + .resolve_envd_health( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.traffic_secret), + ) + .await, + Err(RouteLeaseError::Unauthorized) + )); + } + + #[tokio::test] + async fn rejects_missing_duplicate_oversized_expired_and_unknown_routes() { + let harness = Harness::new().await; + let route = harness.route(ENVD_PORT); + + assert!(matches!( + harness.service.resolve(&route, &HeaderMap::new()).await, + Err(RouteLeaseError::MissingToken) + )); + + let mut duplicate = token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret); + duplicate.append( + ENVD_ACCESS_TOKEN_HEADER, + HeaderValue::from_str(harness.envd_secret.expose_secret()).unwrap(), + ); + assert!(matches!( + harness.service.resolve(&route, &duplicate).await, + Err(RouteLeaseError::InvalidToken) + )); + + let mut oversized = HeaderMap::new(); + oversized.insert( + ENVD_ACCESS_TOKEN_HEADER, + HeaderValue::from_str(&"x".repeat(MAX_TOKEN_BYTES + 1)).unwrap(), + ); + assert!(matches!( + harness.service.resolve(&route, &oversized).await, + Err(RouteLeaseError::InvalidToken) + )); + + let unknown = ParsedSandboxRoute { + sandbox_id: SandboxId::new("missing-route").unwrap(), + port: NonZeroU16::new(ENVD_PORT).unwrap(), + form: super::super::RouteForm::Direct, + }; + assert!(matches!( + harness + .service + .resolve( + &unknown, + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await, + Err(RouteLeaseError::NotFound) + )); + + let mut record = harness + .repository + .get(&harness.sandbox_id) + .await + .unwrap() + .unwrap(); + let expected = record.generation(); + record.replace_expiry(harness.now).unwrap(); + harness + .repository + .compare_and_swap(&harness.sandbox_id, expected, record) + .await + .unwrap(); + assert!(matches!( + harness + .service + .resolve( + &route, + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await, + Err(RouteLeaseError::Expired) + )); + } + + #[tokio::test] + async fn resolves_a_generation_fenced_lease_after_sqlite_restart() { + let harness = Harness::new().await; + let record = harness + .repository + .get(&harness.sandbox_id) + .await + .unwrap() + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("routes.db"); + let repository = SqliteSandboxRepository::open(&path).await.unwrap(); + repository.insert(record).await.unwrap(); + drop(repository); + + let repository = Arc::new(SqliteSandboxRepository::open(&path).await.unwrap()); + let tokens = Arc::new( + RotatingTokenProvider::new(1, [TokenKeyMaterial::new(1, &[7; 32], &[8; 32]).unwrap()]) + .unwrap(), + ); + let service = RouteLeaseService::new(repository, tokens, Arc::new(FixedClock(harness.now))); + let lease = service + .resolve( + &harness.route(ENVD_PORT), + &token_headers(ENVD_ACCESS_TOKEN_HEADER, &harness.envd_secret), + ) + .await + .unwrap(); + + assert_eq!(lease.sandbox_id(), &harness.sandbox_id); + assert_eq!(lease.token_scope(), TokenScope::Envd); + assert_eq!(lease.expires_at(), harness.now + Duration::minutes(5)); + } +} diff --git a/src/compat/src/routing/mod.rs b/src/compat/src/routing/mod.rs new file mode 100644 index 00000000..2c3db0b1 --- /dev/null +++ b/src/compat/src/routing/mod.rs @@ -0,0 +1,16 @@ +mod lease; +mod parser; +mod policy; + +pub use lease::{ + EnvdHealthResolution, RouteLease, RouteLeaseError, RouteLeaseResult, RouteLeaseService, + ENVD_ACCESS_TOKEN_HEADER, TRAFFIC_ACCESS_TOKEN_HEADER, +}; +pub use parser::{ + ParsedSandboxRoute, RouteForm, RouteParseError, RouteParseResult, SandboxDomain, + SandboxRouteParser, SANDBOX_ID_HEADER, SANDBOX_PORT_HEADER, +}; +pub use policy::{ + RoutePolicyError, RoutePolicyResult, SandboxRoutePolicy, CODE_INTERPRETER_PORT, ENVD_PORT, + MCP_PORT, +}; diff --git a/src/compat/src/routing/parser.rs b/src/compat/src/routing/parser.rs new file mode 100644 index 00000000..4b288c58 --- /dev/null +++ b/src/compat/src/routing/parser.rs @@ -0,0 +1,349 @@ +use std::fmt; +use std::num::NonZeroU16; +use std::str::FromStr; + +use axum::http::header::HOST; +use axum::http::uri::Authority; +use axum::http::Uri; +use axum::http::{HeaderMap, HeaderName}; +use thiserror::Error; + +use crate::control::SandboxId; + +pub const SANDBOX_ID_HEADER: &str = "e2b-sandbox-id"; +pub const SANDBOX_PORT_HEADER: &str = "e2b-sandbox-port"; + +#[derive(Clone, PartialEq, Eq)] +pub struct SandboxDomain(String); + +impl SandboxDomain { + pub fn new(value: impl Into) -> RouteParseResult { + let value = value.into(); + if value.is_empty() + || value.len() > 253 + || value.ends_with('.') + || value.bytes().any(|byte| byte.is_ascii_uppercase()) + || !value.split('.').all(valid_dns_label) + { + return Err(RouteParseError::InvalidDomain); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn shared_hostname(&self) -> String { + format!("sandbox.{}", self.0) + } +} + +impl fmt::Debug for SandboxDomain { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("SandboxDomain") + .field(&self.0) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteForm { + Direct, + SharedHeaders, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedSandboxRoute { + pub sandbox_id: SandboxId, + pub port: NonZeroU16, + pub form: RouteForm, +} + +#[derive(Debug, Clone)] +pub struct SandboxRouteParser { + domain: SandboxDomain, +} + +impl SandboxRouteParser { + pub fn new(domain: SandboxDomain) -> Self { + Self { domain } + } + + pub fn parse(&self, headers: &HeaderMap) -> RouteParseResult { + let host = single_header(headers, &HOST)?.ok_or(RouteParseError::MissingHost)?; + self.parse_host(host, headers) + } + + /// Parse either an HTTP/1.1 Host header or an HTTP/2 `:authority` URI. + /// When both forms are present they must identify the same DNS host. + pub fn parse_uri( + &self, + uri: &Uri, + headers: &HeaderMap, + ) -> RouteParseResult { + let header_authority = single_header(headers, &HOST)?; + let uri_authority = uri.authority().map(Authority::as_str); + let authority = match (header_authority, uri_authority) { + (None, None) => return Err(RouteParseError::MissingHost), + (Some(authority), None) | (None, Some(authority)) => authority, + (Some(header), Some(uri)) => { + let header_host = parse_authority_host(header)?; + let uri_host = parse_authority_host(uri)?; + if !header_host.eq_ignore_ascii_case(&uri_host) { + return Err(RouteParseError::ConflictingAuthority); + } + header + } + }; + self.parse_host(authority, headers) + } + + pub fn parse_host( + &self, + authority: &str, + headers: &HeaderMap, + ) -> RouteParseResult { + let authority = Authority::from_str(authority).map_err(|_| RouteParseError::InvalidHost)?; + let host = authority.host().to_ascii_lowercase(); + let header_route = route_headers(headers)?; + + if host == self.domain.shared_hostname() { + let (sandbox_id, port) = header_route.ok_or(RouteParseError::MissingRouteHeaders)?; + return Ok(ParsedSandboxRoute { + sandbox_id, + port, + form: RouteForm::SharedHeaders, + }); + } + + let suffix = format!(".{}", self.domain.as_str()); + let label = host + .strip_suffix(&suffix) + .filter(|label| !label.is_empty() && !label.contains('.')) + .ok_or(RouteParseError::UnsupportedHost)?; + let (port, sandbox_id) = parse_direct_label(label)?; + if let Some((header_id, header_port)) = header_route { + if header_id != sandbox_id || header_port != port { + return Err(RouteParseError::ConflictingRouteHeaders); + } + } + Ok(ParsedSandboxRoute { + sandbox_id, + port, + form: RouteForm::Direct, + }) + } +} + +fn parse_authority_host(authority: &str) -> RouteParseResult { + Authority::from_str(authority) + .map(|authority| authority.host().to_string()) + .map_err(|_| RouteParseError::InvalidHost) +} + +fn route_headers(headers: &HeaderMap) -> RouteParseResult> { + let sandbox_id = single_named_header(headers, SANDBOX_ID_HEADER)?; + let port = single_named_header(headers, SANDBOX_PORT_HEADER)?; + match (sandbox_id, port) { + (None, None) => Ok(None), + (Some(sandbox_id), Some(port)) => Ok(Some(( + SandboxId::new(sandbox_id).map_err(|_| RouteParseError::InvalidRouteHeaders)?, + parse_port(port).map_err(|_| RouteParseError::InvalidRouteHeaders)?, + ))), + _ => Err(RouteParseError::MissingRouteHeaders), + } +} + +fn parse_direct_label(label: &str) -> RouteParseResult<(NonZeroU16, SandboxId)> { + let (port, sandbox_id) = label.split_once('-').ok_or(RouteParseError::InvalidHost)?; + Ok(( + parse_port(port).map_err(|_| RouteParseError::InvalidHost)?, + SandboxId::new(sandbox_id).map_err(|_| RouteParseError::InvalidHost)?, + )) +} + +fn parse_port(value: &str) -> Result { + if value.starts_with('0') { + return Err(()); + } + let port = value.parse::().map_err(|_| ())?; + NonZeroU16::new(port).ok_or(()) +} + +fn single_named_header<'a>( + headers: &'a HeaderMap, + name: &'static str, +) -> RouteParseResult> { + single_header(headers, &HeaderName::from_static(name)) +} + +fn single_header<'a>( + headers: &'a HeaderMap, + name: &HeaderName, +) -> RouteParseResult> { + let mut values = headers.get_all(name).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(RouteParseError::DuplicateHeader); + } + let value = value + .to_str() + .map_err(|_| RouteParseError::InvalidRouteHeaders)?; + if value.is_empty() { + return Err(RouteParseError::InvalidRouteHeaders); + } + Ok(Some(value)) +} + +fn valid_dns_label(label: &str) -> bool { + let bytes = label.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 63 + && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && (bytes[bytes.len() - 1].is_ascii_lowercase() || bytes[bytes.len() - 1].is_ascii_digit()) + && bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RouteParseError { + #[error("sandbox domain is invalid")] + InvalidDomain, + #[error("Host header is missing")] + MissingHost, + #[error("Host header is invalid")] + InvalidHost, + #[error("host is outside the configured sandbox domain")] + UnsupportedHost, + #[error("sandbox route headers are incomplete")] + MissingRouteHeaders, + #[error("sandbox route headers are invalid")] + InvalidRouteHeaders, + #[error("sandbox route headers conflict with the direct hostname")] + ConflictingRouteHeaders, + #[error("Host and HTTP/2 authority identify different sandbox domains")] + ConflictingAuthority, + #[error("routing header is duplicated")] + DuplicateHeader, +} + +pub type RouteParseResult = std::result::Result; + +#[cfg(test)] +mod tests { + use axum::http::{HeaderValue, Request}; + + use super::*; + + fn parser() -> SandboxRouteParser { + SandboxRouteParser::new(SandboxDomain::new("box.example.com").unwrap()) + } + + fn headers(host: &str) -> HeaderMap { + Request::builder() + .header(HOST, host) + .body(()) + .unwrap() + .into_parts() + .0 + .headers + } + + #[test] + fn parses_direct_and_shared_routes_without_string_split_ambiguity() { + let direct = parser() + .parse(&headers("49983-sandbox-abc.box.example.com:443")) + .unwrap(); + assert_eq!(direct.sandbox_id.as_str(), "sandbox-abc"); + assert_eq!(direct.port.get(), 49_983); + assert_eq!(direct.form, RouteForm::Direct); + + let mut shared = headers("sandbox.box.example.com"); + shared.insert(SANDBOX_ID_HEADER, HeaderValue::from_static("sandbox-abc")); + shared.insert(SANDBOX_PORT_HEADER, HeaderValue::from_static("49999")); + let shared = parser().parse(&shared).unwrap(); + assert_eq!(shared.sandbox_id.as_str(), "sandbox-abc"); + assert_eq!(shared.port.get(), 49_999); + assert_eq!(shared.form, RouteForm::SharedHeaders); + } + + #[test] + fn direct_route_headers_must_match_the_hostname() { + let mut matching = headers("49983-sandbox-abc.box.example.com"); + matching.insert(SANDBOX_ID_HEADER, HeaderValue::from_static("sandbox-abc")); + matching.insert(SANDBOX_PORT_HEADER, HeaderValue::from_static("49983")); + assert!(parser().parse(&matching).is_ok()); + + matching.insert(SANDBOX_PORT_HEADER, HeaderValue::from_static("49999")); + assert_eq!( + parser().parse(&matching).unwrap_err(), + RouteParseError::ConflictingRouteHeaders + ); + } + + #[test] + fn rejects_domain_confusion_invalid_ports_and_hostile_identities() { + for host in [ + "49983-sandbox-abc.box.example.com.evil.invalid", + "49983-sandbox-abc.evil.box.example.com", + "0-sandbox-abc.box.example.com", + "65536-sandbox-abc.box.example.com", + "049983-sandbox-abc.box.example.com", + "49983-../box.example.com", + "49983--leading.box.example.com", + ] { + assert!(parser().parse(&headers(host)).is_err(), "accepted {host}"); + } + } + + #[test] + fn shared_routes_require_one_complete_header_pair() { + let mut missing = headers("sandbox.box.example.com"); + missing.insert(SANDBOX_ID_HEADER, HeaderValue::from_static("sandbox-abc")); + assert_eq!( + parser().parse(&missing).unwrap_err(), + RouteParseError::MissingRouteHeaders + ); + + let mut duplicated = headers("sandbox.box.example.com"); + duplicated.append(SANDBOX_ID_HEADER, HeaderValue::from_static("sandbox-abc")); + duplicated.append(SANDBOX_ID_HEADER, HeaderValue::from_static("sandbox-other")); + duplicated.insert(SANDBOX_PORT_HEADER, HeaderValue::from_static("49983")); + assert_eq!( + parser().parse(&duplicated).unwrap_err(), + RouteParseError::DuplicateHeader + ); + } + + #[test] + fn validates_canonical_acl_domains() { + assert_eq!( + SandboxDomain::new("Box.Example.com").unwrap_err(), + RouteParseError::InvalidDomain + ); + for valid in ["localhost", "box.example", "sandbox-1.box.example"] { + assert!(SandboxDomain::new(valid).is_ok(), "rejected {valid}"); + } + } + + #[test] + fn parses_http2_authority_and_rejects_conflicting_host() { + let uri = "https://49983-sandbox-abc.box.example.com/health" + .parse::() + .unwrap(); + let route = parser().parse_uri(&uri, &HeaderMap::new()).unwrap(); + assert_eq!(route.sandbox_id.as_str(), "sandbox-abc"); + assert_eq!(route.port.get(), 49_983); + + let conflicting = headers("49983-sandbox-other.box.example.com"); + assert_eq!( + parser().parse_uri(&uri, &conflicting).unwrap_err(), + RouteParseError::ConflictingAuthority + ); + } +} diff --git a/src/compat/src/routing/policy.rs b/src/compat/src/routing/policy.rs new file mode 100644 index 00000000..c65f7060 --- /dev/null +++ b/src/compat/src/routing/policy.rs @@ -0,0 +1,184 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::control::TokenScope; + +pub const ENVD_PORT: u16 = 49_983; +pub const CODE_INTERPRETER_PORT: u16 = 49_999; +pub const MCP_PORT: u16 = 50_005; +const MAX_ROUTED_PORTS: usize = 64; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + try_from = "BTreeMap", + into = "BTreeMap" +)] +pub struct SandboxRoutePolicy { + ports: BTreeMap, +} + +impl SandboxRoutePolicy { + pub fn new(ports: impl IntoIterator) -> RoutePolicyResult { + let mut routed = BTreeMap::new(); + for (port, scope) in ports { + if port == 0 || routed.insert(port, scope).is_some() { + return Err(RoutePolicyError::InvalidPort(port)); + } + } + let policy = Self { ports: routed }; + policy.validate()?; + Ok(policy) + } + + pub fn with_port(mut self, port: u16, scope: TokenScope) -> RoutePolicyResult { + if port == 0 || self.ports.insert(port, scope).is_some() { + return Err(RoutePolicyError::InvalidPort(port)); + } + self.validate()?; + Ok(self) + } + + pub fn token_scope(&self, port: u16) -> Option { + self.ports.get(&port).copied() + } + + pub fn ports(&self) -> impl Iterator + '_ { + self.ports.iter().map(|(port, scope)| (*port, *scope)) + } + + pub fn validate(&self) -> RoutePolicyResult<()> { + if self.ports.len() > MAX_ROUTED_PORTS { + return Err(RoutePolicyError::TooManyPorts); + } + if self.ports.get(&ENVD_PORT) != Some(&TokenScope::Envd) { + return Err(RoutePolicyError::MissingEnvd); + } + if self.ports.iter().any(|(port, scope)| { + *port == 0 + || (*scope == TokenScope::Envd && *port != ENVD_PORT) + || *scope == TokenScope::Volume + }) { + return Err(RoutePolicyError::InvalidScope); + } + Ok(()) + } +} + +impl Default for SandboxRoutePolicy { + fn default() -> Self { + Self { + ports: BTreeMap::from([(ENVD_PORT, TokenScope::Envd)]), + } + } +} + +impl fmt::Debug for SandboxRoutePolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SandboxRoutePolicy") + .field("ports", &self.ports) + .finish() + } +} + +impl TryFrom> for SandboxRoutePolicy { + type Error = RoutePolicyError; + + fn try_from(ports: BTreeMap) -> Result { + let policy = Self { ports }; + policy.validate()?; + Ok(policy) + } +} + +impl From for BTreeMap { + fn from(policy: SandboxRoutePolicy) -> Self { + policy.ports + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RoutePolicyError { + #[error("routed port {0} is invalid or duplicated")] + InvalidPort(u16), + #[error("the envd compatibility port is required")] + MissingEnvd, + #[error("envd token scope is valid only for the envd compatibility port")] + InvalidScope, + #[error("too many routed ports")] + TooManyPorts, +} + +pub type RoutePolicyResult = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_requires_scope_separated_envd_and_traffic_ports() { + let policy = SandboxRoutePolicy::default() + .with_port(CODE_INTERPRETER_PORT, TokenScope::Traffic) + .unwrap() + .with_port(MCP_PORT, TokenScope::Traffic) + .unwrap(); + + assert_eq!(policy.token_scope(ENVD_PORT), Some(TokenScope::Envd)); + assert_eq!( + policy.token_scope(CODE_INTERPRETER_PORT), + Some(TokenScope::Traffic) + ); + assert_eq!(policy.ports().count(), 3); + assert_eq!( + SandboxRoutePolicy::new([(CODE_INTERPRETER_PORT, TokenScope::Traffic)]).unwrap_err(), + RoutePolicyError::MissingEnvd + ); + assert_eq!( + SandboxRoutePolicy::default() + .with_port(MCP_PORT, TokenScope::Envd) + .unwrap_err(), + RoutePolicyError::InvalidScope + ); + } + + #[test] + fn persisted_policy_revalidates_scope_invariants() { + let invalid = format!(r#"{{"{ENVD_PORT}":"traffic"}}"#); + assert!(serde_json::from_str::(&invalid).is_err()); + + let policy = SandboxRoutePolicy::default(); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&policy).unwrap()) + .unwrap(), + policy + ); + } + + #[test] + fn policy_rejects_zero_duplicates_and_excessive_ports() { + assert_eq!( + SandboxRoutePolicy::new([(ENVD_PORT, TokenScope::Envd), (0, TokenScope::Traffic),]) + .unwrap_err(), + RoutePolicyError::InvalidPort(0) + ); + assert_eq!( + SandboxRoutePolicy::new([ + (ENVD_PORT, TokenScope::Envd), + (8080, TokenScope::Traffic), + (8080, TokenScope::Traffic), + ]) + .unwrap_err(), + RoutePolicyError::InvalidPort(8080) + ); + + let excessive = std::iter::once((ENVD_PORT, TokenScope::Envd)) + .chain((1..=64).map(|port| (port, TokenScope::Traffic))); + assert_eq!( + SandboxRoutePolicy::new(excessive).unwrap_err(), + RoutePolicyError::TooManyPorts + ); + } +} diff --git a/src/compat/src/snapshot/coordination.rs b/src/compat/src/snapshot/coordination.rs new file mode 100644 index 00000000..9e860f19 --- /dev/null +++ b/src/compat/src/snapshot/coordination.rs @@ -0,0 +1,43 @@ +use std::collections::HashSet; +use std::sync::{Arc, Mutex, MutexGuard}; + +use super::SnapshotId; + +#[derive(Debug, Default)] +pub(super) struct SnapshotOperations { + claimed: Mutex>, +} + +impl SnapshotOperations { + pub(super) fn try_claim( + self: &Arc, + snapshot_id: &SnapshotId, + ) -> Option { + let mut claimed = lock_recovering_poison(&self.claimed); + if !claimed.insert(snapshot_id.clone()) { + return None; + } + Some(SnapshotOperationClaim { + operations: self.clone(), + snapshot_id: snapshot_id.clone(), + }) + } +} + +#[derive(Debug)] +pub(super) struct SnapshotOperationClaim { + operations: Arc, + snapshot_id: SnapshotId, +} + +impl Drop for SnapshotOperationClaim { + fn drop(&mut self) { + lock_recovering_poison(&self.operations.claimed).remove(&self.snapshot_id); + } +} + +fn lock_recovering_poison(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/src/compat/src/snapshot/memory.rs b/src/compat/src/snapshot/memory.rs new file mode 100644 index 00000000..fc297cbc --- /dev/null +++ b/src/compat/src/snapshot/memory.rs @@ -0,0 +1,125 @@ +use std::collections::BTreeMap; +use std::sync::{Mutex, MutexGuard}; + +use async_trait::async_trait; + +use super::{ + SnapshotId, SnapshotRecord, SnapshotReplaceResult, SnapshotRepository, SnapshotRepositoryError, + SnapshotRepositoryResult, SnapshotState, +}; + +#[derive(Debug, Default)] +pub struct MemorySnapshotRepository { + records: Mutex>, +} + +impl MemorySnapshotRepository { + fn records( + &self, + ) -> SnapshotRepositoryResult>> { + self.records.lock().map_err(|_| { + SnapshotRepositoryError::Unavailable("snapshot repository lock poisoned".to_string()) + }) + } +} + +#[async_trait] +impl SnapshotRepository for MemorySnapshotRepository { + async fn insert(&self, record: SnapshotRecord) -> SnapshotRepositoryResult<()> { + record + .validate() + .map_err(|error| SnapshotRepositoryError::Corrupt(error.to_string()))?; + let mut records = self.records()?; + if records.contains_key(record.snapshot_id()) + || records.values().any(|existing| { + existing.owner_id() == record.owner_id() + && existing.reference() == record.reference() + }) + { + return Err(SnapshotRepositoryError::Duplicate); + } + records.insert(record.snapshot_id().clone(), record); + Ok(()) + } + + async fn get( + &self, + snapshot_id: &SnapshotId, + ) -> SnapshotRepositoryResult> { + Ok(self.records()?.get(snapshot_id).cloned()) + } + + async fn get_by_reference( + &self, + owner_id: &str, + reference: &str, + ) -> SnapshotRepositoryResult> { + Ok(self + .records()? + .values() + .find(|record| record.owner_id() == owner_id && record.reference() == reference) + .cloned()) + } + + async fn list(&self, owner_id: &str) -> SnapshotRepositoryResult> { + let mut records = self + .records()? + .values() + .filter(|record| { + record.owner_id() == owner_id && record.state() == SnapshotState::Active + }) + .cloned() + .collect::>(); + records.sort_by_key(|record| (record.created_at(), record.snapshot_id().clone())); + Ok(records) + } + + async fn list_in_state( + &self, + state: SnapshotState, + ) -> SnapshotRepositoryResult> { + let mut records = self + .records()? + .values() + .filter(|record| record.state() == state) + .cloned() + .collect::>(); + records.sort_by_key(|record| (record.created_at(), record.snapshot_id().clone())); + Ok(records) + } + + async fn replace( + &self, + expected: SnapshotState, + replacement: SnapshotRecord, + ) -> SnapshotRepositoryResult { + replacement + .validate() + .map_err(|error| SnapshotRepositoryError::Corrupt(error.to_string()))?; + let mut records = self.records()?; + let Some(current) = records.get(replacement.snapshot_id()) else { + return Ok(SnapshotReplaceResult::NotFound); + }; + if current.state() != expected { + return Ok(SnapshotReplaceResult::Conflict); + } + records.insert(replacement.snapshot_id().clone(), replacement); + Ok(SnapshotReplaceResult::Updated) + } + + async fn delete( + &self, + snapshot_id: &SnapshotId, + expected: SnapshotState, + ) -> SnapshotRepositoryResult { + let mut records = self.records()?; + let Some(current) = records.get(snapshot_id) else { + return Ok(SnapshotReplaceResult::NotFound); + }; + if current.state() != expected { + return Ok(SnapshotReplaceResult::Conflict); + } + records.remove(snapshot_id); + Ok(SnapshotReplaceResult::Updated) + } +} diff --git a/src/compat/src/snapshot/mod.rs b/src/compat/src/snapshot/mod.rs new file mode 100644 index 00000000..8852e743 --- /dev/null +++ b/src/compat/src/snapshot/mod.rs @@ -0,0 +1,24 @@ +mod coordination; +mod memory; +mod model; +mod repository; +mod service; +mod sqlite; +mod template; + +pub use memory::MemorySnapshotRepository; +pub use model::{ + validate_snapshot_name, SnapshotId, SnapshotModelError, SnapshotRecord, SnapshotState, +}; +pub use repository::{ + SnapshotReplaceResult, SnapshotRepository, SnapshotRepositoryError, SnapshotRepositoryResult, +}; +pub use service::{ + PendingSnapshot, SnapshotCursor, SnapshotPage, SnapshotReconciliationReport, SnapshotService, + SnapshotServiceDependencies, SnapshotServiceError, SnapshotServiceResult, +}; +pub use sqlite::SqliteSnapshotRepository; +pub use template::SnapshotTemplateProvider; + +#[cfg(test)] +mod tests; diff --git a/src/compat/src/snapshot/model.rs b/src/compat/src/snapshot/model.rs new file mode 100644 index 00000000..bbee3d0c --- /dev/null +++ b/src/compat/src/snapshot/model.rs @@ -0,0 +1,284 @@ +use std::fmt; + +use a3s_box_core::{ExecutionGeneration, ExecutionId, ExecutionSnapshotId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::control::{PublicSandboxState, ResolvedTemplate, SandboxId}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct SnapshotId(String); + +impl SnapshotId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(SnapshotModelError::InvalidId); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SnapshotId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl TryFrom for SnapshotId { + type Error = SnapshotModelError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: SnapshotId) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SnapshotState { + Creating, + Active, + Deleting, +} + +impl SnapshotState { + pub const fn as_str(self) -> &'static str { + match self { + Self::Creating => "creating", + Self::Active => "active", + Self::Deleting => "deleting", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnapshotRecord { + snapshot_id: SnapshotId, + content_id: ExecutionSnapshotId, + owner_id: String, + source_sandbox_id: SandboxId, + source_execution_id: ExecutionId, + source_execution_generation: ExecutionGeneration, + source_state: PublicSandboxState, + name: Option, + namespace: String, + reference: String, + template: ResolvedTemplate, + state: SnapshotState, + created_at: DateTime, + size_bytes: Option, +} + +impl SnapshotRecord { + #[allow(clippy::too_many_arguments)] + pub fn creating( + snapshot_id: SnapshotId, + content_id: ExecutionSnapshotId, + owner_id: impl Into, + source_sandbox_id: SandboxId, + source_execution_id: ExecutionId, + source_execution_generation: ExecutionGeneration, + source_state: PublicSandboxState, + name: Option, + namespace: impl Into, + mut template: ResolvedTemplate, + created_at: DateTime, + ) -> Result { + let owner_id = owner_id.into(); + let namespace = namespace.into(); + if let Some(name) = name.as_deref() { + validate_snapshot_name(name)?; + } + let reference = match name.as_deref() { + Some(name) => format!("{namespace}/{name}:default"), + None => format!("{snapshot_id}:default"), + }; + template.rootfs_snapshot_id = Some(content_id.clone()); + let record = Self { + snapshot_id, + content_id, + owner_id, + source_sandbox_id, + source_execution_id, + source_execution_generation, + source_state, + name, + namespace, + reference, + template, + state: SnapshotState::Creating, + created_at, + size_bytes: None, + }; + record.validate()?; + Ok(record) + } + + pub fn snapshot_id(&self) -> &SnapshotId { + &self.snapshot_id + } + + pub fn content_id(&self) -> &ExecutionSnapshotId { + &self.content_id + } + + pub fn owner_id(&self) -> &str { + &self.owner_id + } + + pub fn source_sandbox_id(&self) -> &SandboxId { + &self.source_sandbox_id + } + + pub fn source_execution_id(&self) -> &ExecutionId { + &self.source_execution_id + } + + pub const fn source_execution_generation(&self) -> ExecutionGeneration { + self.source_execution_generation + } + + pub const fn source_state(&self) -> PublicSandboxState { + self.source_state + } + + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + pub fn reference(&self) -> &str { + &self.reference + } + + pub fn names(&self) -> Vec { + self.name + .as_ref() + .map(|_| vec![self.reference.clone()]) + .unwrap_or_default() + } + + pub fn template(&self) -> &ResolvedTemplate { + &self.template + } + + pub const fn state(&self) -> SnapshotState { + self.state + } + + pub const fn created_at(&self) -> DateTime { + self.created_at + } + + pub const fn size_bytes(&self) -> Option { + self.size_bytes + } + + pub fn mark_active(&mut self, size_bytes: u64) -> Result<(), SnapshotModelError> { + if self.state != SnapshotState::Creating { + return Err(SnapshotModelError::InvalidTransition); + } + self.size_bytes = Some(size_bytes); + self.state = SnapshotState::Active; + Ok(()) + } + + pub fn begin_delete(&mut self) -> Result<(), SnapshotModelError> { + if self.state != SnapshotState::Active { + return Err(SnapshotModelError::InvalidTransition); + } + self.state = SnapshotState::Deleting; + Ok(()) + } + + pub fn abort_delete(&mut self) -> Result<(), SnapshotModelError> { + if self.state != SnapshotState::Deleting { + return Err(SnapshotModelError::InvalidTransition); + } + self.state = SnapshotState::Active; + Ok(()) + } + + pub fn validate(&self) -> Result<(), SnapshotModelError> { + if self.owner_id.trim().is_empty() + || !valid_namespace(&self.namespace) + || self + .name + .as_deref() + .is_some_and(|name| validate_snapshot_name(name).is_err()) + || self.reference + != match self.name.as_deref() { + Some(name) => format!("{}/{name}:default", self.namespace), + None => format!("{}:default", self.snapshot_id), + } + || self.template.rootfs_snapshot_id.as_ref() != Some(&self.content_id) + || (self.state == SnapshotState::Creating) != self.size_bytes.is_none() + { + return Err(SnapshotModelError::InvalidRecord); + } + Ok(()) + } +} + +pub fn validate_snapshot_name(value: &str) -> Result<(), SnapshotModelError> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(SnapshotModelError::InvalidName); + } + Ok(()) +} + +fn valid_namespace(value: &str) -> bool { + value.starts_with("a3s-") + && value.len() == 16 + && value[4..] + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SnapshotModelError { + #[error("invalid snapshot ID")] + InvalidId, + #[error("invalid snapshot name")] + InvalidName, + #[error("invalid snapshot record")] + InvalidRecord, + #[error("invalid snapshot state transition")] + InvalidTransition, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_reference_delimiters_in_snapshot_names() { + for valid in ["state", "State_01", "state-v2.1"] { + assert!(validate_snapshot_name(valid).is_ok()); + } + for invalid in ["", "team/state", "state:latest", "../state", "with space"] { + assert!(validate_snapshot_name(invalid).is_err()); + } + } +} diff --git a/src/compat/src/snapshot/repository.rs b/src/compat/src/snapshot/repository.rs new file mode 100644 index 00000000..226603a9 --- /dev/null +++ b/src/compat/src/snapshot/repository.rs @@ -0,0 +1,58 @@ +use async_trait::async_trait; +use thiserror::Error; + +use super::{SnapshotId, SnapshotRecord, SnapshotState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapshotReplaceResult { + Updated, + NotFound, + Conflict, +} + +#[derive(Debug, Error)] +pub enum SnapshotRepositoryError { + #[error("snapshot already exists")] + Duplicate, + #[error("snapshot repository is unavailable: {0}")] + Unavailable(String), + #[error("snapshot repository contains invalid data: {0}")] + Corrupt(String), +} + +pub type SnapshotRepositoryResult = std::result::Result; + +#[async_trait] +pub trait SnapshotRepository: Send + Sync { + async fn insert(&self, record: SnapshotRecord) -> SnapshotRepositoryResult<()>; + + async fn get( + &self, + snapshot_id: &SnapshotId, + ) -> SnapshotRepositoryResult>; + + async fn get_by_reference( + &self, + owner_id: &str, + reference: &str, + ) -> SnapshotRepositoryResult>; + + async fn list(&self, owner_id: &str) -> SnapshotRepositoryResult>; + + async fn list_in_state( + &self, + state: SnapshotState, + ) -> SnapshotRepositoryResult>; + + async fn replace( + &self, + expected: SnapshotState, + replacement: SnapshotRecord, + ) -> SnapshotRepositoryResult; + + async fn delete( + &self, + snapshot_id: &SnapshotId, + expected: SnapshotState, + ) -> SnapshotRepositoryResult; +} diff --git a/src/compat/src/snapshot/service.rs b/src/compat/src/snapshot/service.rs new file mode 100644 index 00000000..acfc49ac --- /dev/null +++ b/src/compat/src/snapshot/service.rs @@ -0,0 +1,458 @@ +use std::num::NonZeroU32; +use std::sync::Arc; + +use a3s_box_core::{ExecutionManager, ExecutionSnapshot}; +use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +use crate::control::{Clock, PublicSandboxState, ResolvedTemplate, SandboxId, SandboxRecord}; + +use super::{ + coordination::{SnapshotOperationClaim, SnapshotOperations}, + validate_snapshot_name, SnapshotId, SnapshotModelError, SnapshotRecord, SnapshotReplaceResult, + SnapshotRepository, SnapshotRepositoryError, SnapshotState, +}; + +#[derive(Debug)] +pub struct PendingSnapshot { + pub record: SnapshotRecord, + pub execution: ExecutionSnapshot, + _operation: SnapshotOperationClaim, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SnapshotCursor { + pub created_at: DateTime, + pub snapshot_id: SnapshotId, +} + +#[derive(Debug, Clone)] +pub struct SnapshotPage { + pub records: Vec, + pub next: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct SnapshotReconciliationReport { + pub examined: usize, + pub completed: usize, + pub deferred: usize, + pub failures: Vec, +} + +#[derive(Debug, Error)] +pub enum SnapshotServiceError { + #[error("invalid snapshot request: {0}")] + InvalidRequest(String), + #[error("snapshot not found")] + NotFound, + #[error("snapshot already exists")] + Duplicate, + #[error("snapshot is in use or changing state")] + Conflict, + #[error(transparent)] + Repository(#[from] SnapshotRepositoryError), + #[error(transparent)] + Execution(#[from] a3s_box_core::ExecutionManagerError), + #[error(transparent)] + Model(#[from] SnapshotModelError), +} + +pub type SnapshotServiceResult = std::result::Result; + +#[derive(Clone)] +pub struct SnapshotService { + repository: Arc, + executions: Arc, + clock: Arc, + operations: Arc, +} + +pub struct SnapshotServiceDependencies { + pub repository: Arc, + pub executions: Arc, + pub clock: Arc, +} + +impl SnapshotService { + pub fn new(dependencies: SnapshotServiceDependencies) -> Self { + Self { + repository: dependencies.repository, + executions: dependencies.executions, + clock: dependencies.clock, + operations: Arc::new(SnapshotOperations::default()), + } + } + + pub async fn capture( + &self, + owner_id: &str, + source: &SandboxRecord, + name: Option<&str>, + template: ResolvedTemplate, + ) -> SnapshotServiceResult { + if owner_id.trim().is_empty() || source.owner_id() != owner_id { + return Err(SnapshotServiceError::NotFound); + } + if let Some(name) = name { + validate_snapshot_name(name)?; + } + let source_state = source + .public_state() + .ok_or(SnapshotServiceError::NotFound)?; + let execution_id = source + .execution_id() + .ok_or(SnapshotServiceError::Conflict)?; + let generation = source + .execution_generation() + .ok_or(SnapshotServiceError::Conflict)?; + let snapshot_id = SnapshotId::new(format!("snap-{}", Uuid::new_v4().simple()))?; + let operation = self + .operations + .try_claim(&snapshot_id) + .ok_or(SnapshotServiceError::Conflict)?; + let content_id = + a3s_box_core::ExecutionSnapshotId::new(format!("e2bsnap-{}", Uuid::new_v4().simple()))?; + let record = SnapshotRecord::creating( + snapshot_id, + content_id.clone(), + owner_id, + source.sandbox_id().clone(), + execution_id.clone(), + generation, + source_state, + name.map(str::to_string), + owner_namespace(owner_id), + template, + self.clock.now(), + )?; + match self.repository.insert(record.clone()).await { + Ok(()) => {} + Err(SnapshotRepositoryError::Duplicate) => return Err(SnapshotServiceError::Duplicate), + Err(error) => return Err(error.into()), + } + + let execution = match self + .executions + .create_filesystem_snapshot(execution_id, generation, &content_id) + .await + { + Ok(snapshot) => snapshot, + Err(error) => { + if matches!( + self.executions.filesystem_snapshot_size(&content_id).await, + Ok(None) + ) { + let _ = self + .repository + .delete(record.snapshot_id(), SnapshotState::Creating) + .await; + } + return Err(match error { + a3s_box_core::ExecutionManagerError::Conflict { .. } => { + SnapshotServiceError::Conflict + } + error => SnapshotServiceError::Execution(error), + }); + } + }; + if !consistent_execution(&record, &execution) { + return Err(SnapshotServiceError::Execution( + a3s_box_core::ExecutionManagerError::Internal( + "runtime returned inconsistent snapshot completion evidence".to_string(), + ), + )); + } + Ok(PendingSnapshot { + record, + execution, + _operation: operation, + }) + } + + pub async fn publish( + &self, + mut pending: PendingSnapshot, + ) -> SnapshotServiceResult { + pending.record.mark_active(pending.execution.size_bytes)?; + self.replace(SnapshotState::Creating, pending.record.clone()) + .await?; + Ok(pending.record) + } + + pub async fn list( + &self, + owner_id: &str, + source_sandbox_id: Option<&SandboxId>, + limit: NonZeroU32, + after: Option<&SnapshotCursor>, + ) -> SnapshotServiceResult { + let records = self.repository.list(owner_id).await?; + let mut eligible = records.into_iter().filter(|record| { + source_sandbox_id.is_none_or(|source| record.source_sandbox_id() == source) + && after.is_none_or(|cursor| { + (record.created_at(), record.snapshot_id()) + > (cursor.created_at, &cursor.snapshot_id) + }) + }); + let mut page = eligible + .by_ref() + .take(limit.get() as usize + 1) + .collect::>(); + let has_more = page.len() > limit.get() as usize; + if has_more { + page.pop(); + } + let next = if has_more { + page.last().map(|last| SnapshotCursor { + created_at: last.created_at(), + snapshot_id: last.snapshot_id().clone(), + }) + } else { + None + }; + Ok(SnapshotPage { + records: page, + next, + }) + } + + pub async fn delete(&self, owner_id: &str, reference: &str) -> SnapshotServiceResult { + let normalized_reference = normalize_reference(reference); + let Some(record) = self + .repository + .get_by_reference(owner_id, &normalized_reference) + .await? + .filter(|record| record.state() == SnapshotState::Active) + else { + return Ok(false); + }; + let snapshot_id = record.snapshot_id().clone(); + let Some(_operation) = self.operations.try_claim(&snapshot_id) else { + return Err(SnapshotServiceError::Conflict); + }; + let Some(mut record) = self.repository.get(&snapshot_id).await?.filter(|current| { + current.owner_id() == owner_id + && current.reference() == normalized_reference + && current.state() == SnapshotState::Active + }) else { + return Ok(false); + }; + record.begin_delete()?; + self.replace(SnapshotState::Active, record.clone()).await?; + match self + .executions + .delete_filesystem_snapshot(record.content_id()) + .await + { + Ok(_) => {} + Err(a3s_box_core::ExecutionManagerError::Conflict { .. }) => { + record.abort_delete()?; + self.replace(SnapshotState::Deleting, record).await?; + return Err(SnapshotServiceError::Conflict); + } + Err(error) => { + record.abort_delete()?; + self.replace(SnapshotState::Deleting, record).await?; + return Err(error.into()); + } + } + self.delete_record(record.snapshot_id(), SnapshotState::Deleting) + .await?; + Ok(true) + } + + pub async fn reconcile_startup(&self) -> SnapshotServiceResult { + let mut report = SnapshotReconciliationReport::default(); + for state in [SnapshotState::Creating, SnapshotState::Deleting] { + for listed_record in self.repository.list_in_state(state).await? { + report.examined += 1; + let Some(_operation) = self.operations.try_claim(listed_record.snapshot_id()) + else { + report.deferred += 1; + continue; + }; + let Some(mut record) = self.repository.get(listed_record.snapshot_id()).await? + else { + report.completed += 1; + continue; + }; + if record.state() != state { + report.deferred += 1; + continue; + } + let outcome = match state { + SnapshotState::Creating => { + self.reconcile_creating(&mut record, &mut report).await? + } + SnapshotState::Deleting => match self + .executions + .delete_filesystem_snapshot(record.content_id()) + .await + { + Ok(_) => { + self.delete_record(record.snapshot_id(), SnapshotState::Deleting) + .await?; + ReconciliationOutcome::Completed + } + Err(a3s_box_core::ExecutionManagerError::Conflict { .. }) => { + record.abort_delete()?; + self.replace(SnapshotState::Deleting, record).await?; + ReconciliationOutcome::Deferred + } + Err(error) => { + report.failures.push(error.to_string()); + ReconciliationOutcome::Deferred + } + }, + SnapshotState::Active => { + report.failures.push(format!( + "active snapshot {} was returned by a transitional-state query", + record.snapshot_id() + )); + ReconciliationOutcome::Deferred + } + }; + match outcome { + ReconciliationOutcome::Completed => report.completed += 1, + ReconciliationOutcome::Deferred => report.deferred += 1, + } + } + } + Ok(report) + } + + async fn replace( + &self, + expected: SnapshotState, + record: SnapshotRecord, + ) -> SnapshotServiceResult<()> { + match self.repository.replace(expected, record).await? { + SnapshotReplaceResult::Updated => Ok(()), + SnapshotReplaceResult::NotFound => Err(SnapshotServiceError::NotFound), + SnapshotReplaceResult::Conflict => Err(SnapshotServiceError::Conflict), + } + } + + async fn delete_record( + &self, + snapshot_id: &SnapshotId, + expected: SnapshotState, + ) -> SnapshotServiceResult<()> { + match self.repository.delete(snapshot_id, expected).await? { + SnapshotReplaceResult::Updated => Ok(()), + SnapshotReplaceResult::NotFound => Err(SnapshotServiceError::NotFound), + SnapshotReplaceResult::Conflict => Err(SnapshotServiceError::Conflict), + } + } + + async fn reconcile_creating( + &self, + record: &mut SnapshotRecord, + report: &mut SnapshotReconciliationReport, + ) -> SnapshotServiceResult { + match self + .executions + .create_filesystem_snapshot( + record.source_execution_id(), + record.source_execution_generation(), + record.content_id(), + ) + .await + { + Ok(execution) if consistent_execution(record, &execution) => { + record.mark_active(execution.size_bytes)?; + self.replace(SnapshotState::Creating, record.clone()) + .await?; + Ok(ReconciliationOutcome::Completed) + } + Ok(_) => { + report.failures.push(format!( + "runtime returned inconsistent recovery evidence for snapshot {}", + record.snapshot_id() + )); + Ok(ReconciliationOutcome::Deferred) + } + Err(error @ a3s_box_core::ExecutionManagerError::NotFound(_)) => { + self.resolve_missing_source(record, report, error).await + } + Err(error @ a3s_box_core::ExecutionManagerError::Conflict { .. }) => { + self.resolve_missing_source(record, report, error).await + } + Err(error) => { + report.failures.push(error.to_string()); + Ok(ReconciliationOutcome::Deferred) + } + } + } + + async fn resolve_missing_source( + &self, + record: &mut SnapshotRecord, + report: &mut SnapshotReconciliationReport, + error: a3s_box_core::ExecutionManagerError, + ) -> SnapshotServiceResult { + match self + .executions + .filesystem_snapshot_size(record.content_id()) + .await + { + Ok(Some(size_bytes)) => { + record.mark_active(size_bytes)?; + self.replace(SnapshotState::Creating, record.clone()) + .await?; + Ok(ReconciliationOutcome::Completed) + } + Ok(None) if matches!(error, a3s_box_core::ExecutionManagerError::NotFound(_)) => { + self.delete_record(record.snapshot_id(), SnapshotState::Creating) + .await?; + Ok(ReconciliationOutcome::Completed) + } + Ok(None) => { + report.failures.push(error.to_string()); + Ok(ReconciliationOutcome::Deferred) + } + Err(inspect_error) => { + report.failures.push(format!( + "{error}; snapshot inspection failed: {inspect_error}" + )); + Ok(ReconciliationOutcome::Deferred) + } + } + } +} + +#[derive(Debug, Clone, Copy)] +enum ReconciliationOutcome { + Completed, + Deferred, +} + +fn owner_namespace(owner_id: &str) -> String { + let digest = hex::encode(Sha256::digest(owner_id.as_bytes())); + format!("a3s-{}", &digest[..12]) +} + +fn normalize_reference(reference: &str) -> String { + if reference.contains(':') { + reference.to_string() + } else { + format!("{reference}:default") + } +} + +fn public_execution_state(state: PublicSandboxState) -> a3s_box_core::ExecutionState { + match state { + PublicSandboxState::Running => a3s_box_core::ExecutionState::Running, + PublicSandboxState::Paused => a3s_box_core::ExecutionState::Paused, + } +} + +fn consistent_execution(record: &SnapshotRecord, execution: &ExecutionSnapshot) -> bool { + &execution.snapshot_id == record.content_id() + && execution.state == public_execution_state(record.source_state()) + && &execution.lease.execution_id == record.source_execution_id() + && execution.lease.generation == record.source_execution_generation() +} diff --git a/src/compat/src/snapshot/sqlite.rs b/src/compat/src/snapshot/sqlite.rs new file mode 100644 index 00000000..d91f93a2 --- /dev/null +++ b/src/compat/src/snapshot/sqlite.rs @@ -0,0 +1,269 @@ +use async_trait::async_trait; +use tokio_rusqlite::rusqlite::{params, ErrorCode, OptionalExtension}; +use tokio_rusqlite::Connection; + +use super::{ + SnapshotId, SnapshotRecord, SnapshotReplaceResult, SnapshotRepository, SnapshotRepositoryError, + SnapshotRepositoryResult, SnapshotState, +}; + +#[derive(Clone)] +pub struct SqliteSnapshotRepository { + connection: Connection, +} + +impl SqliteSnapshotRepository { + pub(crate) fn new(connection: Connection) -> Self { + Self { connection } + } + + async fn call(&self, function: F) -> SnapshotRepositoryResult + where + F: FnOnce(&mut tokio_rusqlite::rusqlite::Connection) -> SnapshotRepositoryResult + + Send + + 'static, + R: Send + 'static, + { + self.connection + .call(function) + .await + .map_err(map_async_error) + } +} + +impl std::fmt::Debug for SqliteSnapshotRepository { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SqliteSnapshotRepository") + .finish_non_exhaustive() + } +} + +#[async_trait] +impl SnapshotRepository for SqliteSnapshotRepository { + async fn insert(&self, record: SnapshotRecord) -> SnapshotRepositoryResult<()> { + validate_record(&record)?; + let snapshot_id = record.snapshot_id().clone(); + let record_json = serialize_record(&record)?; + self.call(move |connection| { + match connection.execute( + "INSERT INTO snapshot_records(snapshot_id, record_json) VALUES (?1, ?2)", + params![snapshot_id.as_str(), record_json], + ) { + Ok(_) => Ok(()), + Err(error) + if error + .sqlite_error_code() + .is_some_and(|code| code == ErrorCode::ConstraintViolation) => + { + Err(SnapshotRepositoryError::Duplicate) + } + Err(error) => Err(unavailable("insert SQLite snapshot record", error)), + } + }) + .await + } + + async fn get( + &self, + snapshot_id: &SnapshotId, + ) -> SnapshotRepositoryResult> { + let snapshot_id = snapshot_id.clone(); + let record = self + .call(move |connection| { + connection + .query_row( + "SELECT record_json FROM snapshot_records WHERE snapshot_id = ?1", + [snapshot_id.as_str()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| unavailable("read SQLite snapshot record", error)) + }) + .await?; + record + .map(|serialized| deserialize_record(&serialized)) + .transpose() + } + + async fn get_by_reference( + &self, + owner_id: &str, + reference: &str, + ) -> SnapshotRepositoryResult> { + let owner_id = owner_id.to_string(); + let reference = reference.to_string(); + let record = self + .call(move |connection| { + connection + .query_row( + "SELECT record_json FROM snapshot_records \ + WHERE owner_id = ?1 AND reference = ?2", + params![owner_id, reference], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| unavailable("read SQLite snapshot reference", error)) + }) + .await?; + record + .map(|serialized| deserialize_record(&serialized)) + .transpose() + } + + async fn list(&self, owner_id: &str) -> SnapshotRepositoryResult> { + let owner_id = owner_id.to_string(); + let records = self + .call(move |connection| { + let mut statement = connection + .prepare( + "SELECT record_json FROM snapshot_records \ + WHERE owner_id = ?1 AND state = 'active' \ + ORDER BY julianday(created_at), snapshot_id", + ) + .map_err(|error| unavailable("prepare SQLite snapshot list", error))?; + let records = statement + .query_map([owner_id], |row| row.get::<_, String>(0)) + .map_err(|error| unavailable("query SQLite snapshot list", error))? + .collect::, _>>() + .map_err(|error| unavailable("read SQLite snapshot list", error))?; + Ok(records) + }) + .await?; + records + .iter() + .map(|record| deserialize_record(record)) + .collect() + } + + async fn list_in_state( + &self, + state: SnapshotState, + ) -> SnapshotRepositoryResult> { + let state = state.as_str().to_string(); + let records = self + .call(move |connection| { + let mut statement = connection + .prepare( + "SELECT record_json FROM snapshot_records WHERE state = ?1 \ + ORDER BY julianday(created_at), snapshot_id", + ) + .map_err(|error| { + unavailable("prepare SQLite snapshot reconciliation", error) + })?; + let records = statement + .query_map([state], |row| row.get::<_, String>(0)) + .map_err(|error| unavailable("query SQLite snapshot reconciliation", error))? + .collect::, _>>() + .map_err(|error| unavailable("read SQLite snapshot reconciliation", error))?; + Ok(records) + }) + .await?; + records + .iter() + .map(|record| deserialize_record(record)) + .collect() + } + + async fn replace( + &self, + expected: SnapshotState, + replacement: SnapshotRecord, + ) -> SnapshotRepositoryResult { + validate_record(&replacement)?; + let snapshot_id = replacement.snapshot_id().clone(); + let expected = expected.as_str().to_string(); + let record_json = serialize_record(&replacement)?; + self.call(move |connection| { + let updated = connection + .execute( + "UPDATE snapshot_records SET record_json = ?1 \ + WHERE snapshot_id = ?2 AND state = ?3", + params![record_json, snapshot_id.as_str(), expected], + ) + .map_err(|error| unavailable("replace SQLite snapshot record", error))?; + if updated == 1 { + return Ok(SnapshotReplaceResult::Updated); + } + existence_result(connection, &snapshot_id) + }) + .await + } + + async fn delete( + &self, + snapshot_id: &SnapshotId, + expected: SnapshotState, + ) -> SnapshotRepositoryResult { + let snapshot_id = snapshot_id.clone(); + let expected = expected.as_str().to_string(); + self.call(move |connection| { + let deleted = connection + .execute( + "DELETE FROM snapshot_records WHERE snapshot_id = ?1 AND state = ?2", + params![snapshot_id.as_str(), expected], + ) + .map_err(|error| unavailable("delete SQLite snapshot record", error))?; + if deleted == 1 { + return Ok(SnapshotReplaceResult::Updated); + } + existence_result(connection, &snapshot_id) + }) + .await + } +} + +fn existence_result( + connection: &tokio_rusqlite::rusqlite::Connection, + snapshot_id: &SnapshotId, +) -> SnapshotRepositoryResult { + let exists = connection + .query_row( + "SELECT 1 FROM snapshot_records WHERE snapshot_id = ?1", + [snapshot_id.as_str()], + |_| Ok(()), + ) + .optional() + .map_err(|error| unavailable("inspect SQLite snapshot conflict", error))?; + Ok(if exists.is_some() { + SnapshotReplaceResult::Conflict + } else { + SnapshotReplaceResult::NotFound + }) +} + +fn validate_record(record: &SnapshotRecord) -> SnapshotRepositoryResult<()> { + record + .validate() + .map_err(|error| SnapshotRepositoryError::Corrupt(error.to_string())) +} + +fn serialize_record(record: &SnapshotRecord) -> SnapshotRepositoryResult { + serde_json::to_string(record).map_err(|error| { + SnapshotRepositoryError::Corrupt(format!("serialize SQLite snapshot record: {error}")) + }) +} + +fn deserialize_record(record: &str) -> SnapshotRepositoryResult { + let record: SnapshotRecord = serde_json::from_str(record).map_err(|error| { + SnapshotRepositoryError::Corrupt(format!("deserialize SQLite snapshot record: {error}")) + })?; + validate_record(&record)?; + Ok(record) +} + +fn unavailable(context: &str, error: impl std::fmt::Display) -> SnapshotRepositoryError { + SnapshotRepositoryError::Unavailable(format!("{context}: {error}")) +} + +fn map_async_error( + error: tokio_rusqlite::Error, +) -> SnapshotRepositoryError { + match error { + tokio_rusqlite::Error::Error(error) => error, + tokio_rusqlite::Error::ConnectionClosed => { + SnapshotRepositoryError::Unavailable("SQLite repository connection closed".to_string()) + } + _ => SnapshotRepositoryError::Unavailable(format!("SQLite repository failed: {error}")), + } +} diff --git a/src/compat/src/snapshot/template.rs b/src/compat/src/snapshot/template.rs new file mode 100644 index 00000000..044fd3e4 --- /dev/null +++ b/src/compat/src/snapshot/template.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::control::{ + ResolvedTemplate, TemplateProvider, TemplateProviderError, TemplateProviderResult, +}; + +use super::{SnapshotRepository, SnapshotRepositoryError, SnapshotState}; + +#[derive(Clone)] +pub struct SnapshotTemplateProvider { + configured: Arc, + snapshots: Arc, +} + +impl SnapshotTemplateProvider { + pub fn new( + configured: Arc, + snapshots: Arc, + ) -> Self { + Self { + configured, + snapshots, + } + } +} + +#[async_trait] +impl TemplateProvider for SnapshotTemplateProvider { + async fn resolve( + &self, + owner_id: &str, + template_id: &str, + ) -> TemplateProviderResult { + let normalized = if template_id.contains(':') { + template_id.to_string() + } else { + format!("{template_id}:default") + }; + let snapshot = self + .snapshots + .get_by_reference(owner_id, &normalized) + .await + .map_err(map_repository_error)?; + if let Some(snapshot) = snapshot.filter(|record| record.state() == SnapshotState::Active) { + return Ok(snapshot.template().clone()); + } + self.configured.resolve(owner_id, template_id).await + } +} + +fn map_repository_error(error: SnapshotRepositoryError) -> TemplateProviderError { + match error { + SnapshotRepositoryError::Unavailable(message) => { + TemplateProviderError::Unavailable(message) + } + SnapshotRepositoryError::Corrupt(message) => TemplateProviderError::Unavailable(message), + SnapshotRepositoryError::Duplicate => TemplateProviderError::Unavailable( + "snapshot repository returned an impossible duplicate read".to_string(), + ), + } +} diff --git a/src/compat/src/snapshot/tests/mod.rs b/src/compat/src/snapshot/tests/mod.rs new file mode 100644 index 00000000..63f4e356 --- /dev/null +++ b/src/compat/src/snapshot/tests/mod.rs @@ -0,0 +1,4 @@ +mod repository; +mod service; +mod support; +mod template; diff --git a/src/compat/src/snapshot/tests/repository.rs b/src/compat/src/snapshot/tests/repository.rs new file mode 100644 index 00000000..5aa69a4d --- /dev/null +++ b/src/compat/src/snapshot/tests/repository.rs @@ -0,0 +1,132 @@ +use std::sync::Arc; + +use tempfile::tempdir; + +use crate::control::SqliteSandboxRepository; + +use super::super::*; +use super::support::record; + +#[tokio::test] +async fn memory_repository_enforces_the_snapshot_contract() { + exercise_repository(Arc::new(MemorySnapshotRepository::default())).await; +} + +#[tokio::test] +async fn sqlite_repository_enforces_the_snapshot_contract() { + let directory = tempdir().unwrap(); + let control = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + exercise_repository(Arc::new(SqliteSnapshotRepository::new( + control.connection(), + ))) + .await; +} + +async fn exercise_repository(repository: Arc) { + let owner_a = record( + "snapshot-a", + "owner-a", + Some("state"), + SnapshotState::Active, + 2, + ); + let owner_b = record( + "snapshot-b", + "owner-b", + Some("state"), + SnapshotState::Active, + 1, + ); + let creating = record( + "snapshot-c", + "owner-a", + Some("cache"), + SnapshotState::Creating, + 0, + ); + repository.insert(owner_a.clone()).await.unwrap(); + repository.insert(owner_b.clone()).await.unwrap(); + repository.insert(creating.clone()).await.unwrap(); + + let listed = repository.list("owner-a").await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].snapshot_id(), owner_a.snapshot_id()); + assert_eq!( + repository + .get_by_reference("owner-b", owner_b.reference()) + .await + .unwrap() + .unwrap() + .snapshot_id(), + owner_b.snapshot_id() + ); + assert_eq!( + repository + .get_by_reference("owner-a", owner_b.reference()) + .await + .unwrap() + .unwrap() + .snapshot_id(), + owner_a.snapshot_id() + ); + let transitional = repository + .list_in_state(SnapshotState::Creating) + .await + .unwrap(); + assert_eq!(transitional.len(), 1); + assert_eq!(transitional[0].snapshot_id(), creating.snapshot_id()); + + let duplicate_reference = record( + "snapshot-d", + "owner-a", + Some("state"), + SnapshotState::Active, + 3, + ); + assert!(matches!( + repository.insert(duplicate_reference).await, + Err(SnapshotRepositoryError::Duplicate) + )); + assert!(matches!( + repository.insert(owner_a).await, + Err(SnapshotRepositoryError::Duplicate) + )); + + assert_eq!( + repository + .replace(SnapshotState::Active, creating.clone()) + .await + .unwrap(), + SnapshotReplaceResult::Conflict + ); + let mut active = creating; + active.mark_active(8_192).unwrap(); + assert_eq!( + repository + .replace(SnapshotState::Creating, active.clone()) + .await + .unwrap(), + SnapshotReplaceResult::Updated + ); + assert_eq!( + repository + .delete(active.snapshot_id(), SnapshotState::Creating) + .await + .unwrap(), + SnapshotReplaceResult::Conflict + ); + assert_eq!( + repository + .delete(active.snapshot_id(), SnapshotState::Active) + .await + .unwrap(), + SnapshotReplaceResult::Updated + ); + assert!(repository + .get(active.snapshot_id()) + .await + .unwrap() + .is_none()); +} diff --git a/src/compat/src/snapshot/tests/service.rs b/src/compat/src/snapshot/tests/service.rs new file mode 100644 index 00000000..c418e2ca --- /dev/null +++ b/src/compat/src/snapshot/tests/service.rs @@ -0,0 +1,330 @@ +use std::num::NonZeroU32; + +use crate::control::test_support::{create_request, TestHarness}; +use crate::control::{ControlServiceError, TemplateProviderError}; + +use super::super::*; +use super::support::{record, template}; + +#[tokio::test] +async fn capture_restores_after_source_deletion_and_enforces_owner_and_active_use() { + let harness = TestHarness::new(); + let source = harness + .service + .create(create_request("owner-a")) + .await + .unwrap(); + let snapshot = harness + .service + .create_snapshot("owner-a", source.record.sandbox_id(), Some("fixture-state")) + .await + .unwrap(); + assert_eq!(snapshot.state(), SnapshotState::Active); + assert!(snapshot.reference().ends_with("/fixture-state:default")); + assert_eq!(snapshot.names(), vec![snapshot.reference().to_string()]); + assert_eq!(harness.executions.snapshot_ids().len(), 1); + assert!(matches!( + harness + .service + .create_snapshot("owner-a", source.record.sandbox_id(), Some("fixture-state"),) + .await, + Err(ControlServiceError::Snapshot( + SnapshotServiceError::Duplicate + )) + )); + + let mut forbidden = create_request("owner-b"); + forbidden.template_id = snapshot.reference().to_string(); + assert!(matches!( + harness.service.create(forbidden).await, + Err(ControlServiceError::Template( + TemplateProviderError::NotFound(_) + )) + )); + + assert!(harness + .service + .kill("owner-a", source.record.sandbox_id()) + .await + .unwrap()); + let mut restore_request = create_request("owner-a"); + restore_request.template_id = snapshot.reference().to_string(); + let restored = harness.service.create(restore_request).await.unwrap(); + let runtime_request = harness.executions.requests().last().cloned().unwrap(); + assert_eq!( + runtime_request.rootfs_snapshot_id.as_ref(), + Some(snapshot.content_id()) + ); + + assert!(matches!( + harness + .snapshots + .delete("owner-a", snapshot.reference()) + .await, + Err(SnapshotServiceError::Conflict) + )); + assert!(harness + .service + .kill("owner-a", restored.record.sandbox_id()) + .await + .unwrap()); + assert!(harness + .snapshots + .delete("owner-a", snapshot.reference()) + .await + .unwrap()); + assert!(!harness + .snapshots + .delete("owner-a", snapshot.reference()) + .await + .unwrap()); +} + +#[tokio::test] +async fn list_filters_and_paginates_with_stable_cursors() { + let harness = TestHarness::new(); + let first_source = harness + .service + .create(create_request("owner-a")) + .await + .unwrap(); + let second_source = harness + .service + .create(create_request("owner-a")) + .await + .unwrap(); + for (source, name) in [ + (&first_source, "first"), + (&first_source, "second"), + (&second_source, "third"), + ] { + harness + .service + .create_snapshot("owner-a", source.record.sandbox_id(), Some(name)) + .await + .unwrap(); + } + + let limit = NonZeroU32::new(1).unwrap(); + let first = harness + .snapshots + .list( + "owner-a", + Some(first_source.record.sandbox_id()), + limit, + None, + ) + .await + .unwrap(); + assert_eq!(first.records.len(), 1); + let second = harness + .snapshots + .list( + "owner-a", + Some(first_source.record.sandbox_id()), + limit, + first.next.as_ref(), + ) + .await + .unwrap(); + assert_eq!(second.records.len(), 1); + assert!(second.next.is_none()); + assert_ne!( + first.records[0].snapshot_id(), + second.records[0].snapshot_id() + ); + assert!(harness + .snapshots + .list("owner-b", None, NonZeroU32::new(100).unwrap(), None) + .await + .unwrap() + .records + .is_empty()); +} + +#[tokio::test] +async fn snapshot_of_snapshot_has_independent_content() { + let harness = TestHarness::new(); + let source = harness + .service + .create(create_request("owner-a")) + .await + .unwrap(); + let first = harness + .service + .create_snapshot("owner-a", source.record.sandbox_id(), Some("first")) + .await + .unwrap(); + let mut restore_request = create_request("owner-a"); + restore_request.template_id = first.reference().to_string(); + let restored = harness.service.create(restore_request).await.unwrap(); + let second = harness + .service + .create_snapshot("owner-a", restored.record.sandbox_id(), Some("second")) + .await + .unwrap(); + assert_ne!(first.content_id(), second.content_id()); + assert_eq!( + second.template().rootfs_snapshot_id.as_ref(), + Some(second.content_id()) + ); + + assert!(harness + .service + .kill("owner-a", restored.record.sandbox_id()) + .await + .unwrap()); + assert!(harness + .snapshots + .delete("owner-a", first.reference()) + .await + .unwrap()); + let mut second_restore = create_request("owner-a"); + second_restore.template_id = second.reference().to_string(); + let nested = harness.service.create(second_restore).await.unwrap(); + assert_eq!( + harness + .executions + .requests() + .last() + .unwrap() + .rootfs_snapshot_id + .as_ref(), + Some(second.content_id()) + ); + assert!(harness + .service + .kill("owner-a", nested.record.sandbox_id()) + .await + .unwrap()); +} + +#[tokio::test] +async fn startup_reconciliation_publishes_or_removes_creating_records() { + let harness = TestHarness::new(); + let source = harness + .service + .create(create_request("owner-a")) + .await + .unwrap(); + let pending = harness + .snapshots + .capture("owner-a", &source.record, Some("recover"), template()) + .await + .unwrap(); + let pending_id = pending.record.snapshot_id().clone(); + assert_eq!(pending.record.state(), SnapshotState::Creating); + drop(pending); + + let report = harness.snapshots.reconcile_startup().await.unwrap(); + assert_eq!(report.examined, 1); + assert_eq!(report.completed, 1); + assert_eq!( + harness + .snapshot_repository + .get(&pending_id) + .await + .unwrap() + .unwrap() + .state(), + SnapshotState::Active + ); + + let orphan = record( + "orphan-snapshot", + "owner-a", + Some("orphan"), + SnapshotState::Creating, + 10, + ); + let orphan_id = orphan.snapshot_id().clone(); + harness.snapshot_repository.insert(orphan).await.unwrap(); + let report = harness.snapshots.reconcile_startup().await.unwrap(); + assert_eq!(report.completed, 1); + assert!(harness + .snapshot_repository + .get(&orphan_id) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn periodic_reconciliation_defers_a_snapshot_owned_by_live_capture() { + let harness = TestHarness::new(); + let source = harness + .service + .create(create_request("owner-a")) + .await + .unwrap(); + let pending = harness + .snapshots + .capture("owner-a", &source.record, Some("live"), template()) + .await + .unwrap(); + let pending_id = pending.record.snapshot_id().clone(); + + let report = harness.snapshots.reconcile_startup().await.unwrap(); + assert_eq!(report.examined, 1); + assert_eq!(report.completed, 0); + assert_eq!(report.deferred, 1); + assert_eq!( + harness + .snapshot_repository + .get(&pending_id) + .await + .unwrap() + .unwrap() + .state(), + SnapshotState::Creating + ); + + let published = harness.snapshots.publish(pending).await.unwrap(); + assert_eq!(published.state(), SnapshotState::Active); +} + +#[tokio::test] +async fn startup_reconciliation_rolls_an_in_use_delete_back_to_active() { + let harness = TestHarness::new(); + let source = harness + .service + .create(create_request("owner-a")) + .await + .unwrap(); + let snapshot = harness + .service + .create_snapshot("owner-a", source.record.sandbox_id(), Some("active")) + .await + .unwrap(); + let mut restore_request = create_request("owner-a"); + restore_request.template_id = snapshot.reference().to_string(); + let restored = harness.service.create(restore_request).await.unwrap(); + let mut deleting = snapshot.clone(); + deleting.begin_delete().unwrap(); + assert_eq!( + harness + .snapshot_repository + .replace(SnapshotState::Active, deleting) + .await + .unwrap(), + SnapshotReplaceResult::Updated + ); + + let report = harness.snapshots.reconcile_startup().await.unwrap(); + assert_eq!(report.deferred, 1); + assert_eq!( + harness + .snapshot_repository + .get(snapshot.snapshot_id()) + .await + .unwrap() + .unwrap() + .state(), + SnapshotState::Active + ); + assert!(harness + .service + .kill("owner-a", restored.record.sandbox_id()) + .await + .unwrap()); +} diff --git a/src/compat/src/snapshot/tests/support.rs b/src/compat/src/snapshot/tests/support.rs new file mode 100644 index 00000000..2f3fc190 --- /dev/null +++ b/src/compat/src/snapshot/tests/support.rs @@ -0,0 +1,66 @@ +use a3s_box_core::{ + BoxConfig, ExecutionGeneration, ExecutionId, ExecutionIsolation, ExecutionSnapshotId, + ResourceConfig, +}; +use chrono::{DateTime, Duration, TimeZone, Utc}; + +use crate::control::{EnvdMode, PublicSandboxState, ResolvedTemplate, SandboxId}; + +use super::super::*; + +pub fn test_time(second: i64) -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 16, 12, 0, 0) + .single() + .unwrap() + + Duration::seconds(second) +} + +pub fn template() -> ResolvedTemplate { + ResolvedTemplate { + config: BoxConfig { + image: "alpine:3.20".to_string(), + isolation: ExecutionIsolation::Sandbox, + resources: ResourceConfig { + vcpus: 2, + memory_mb: 512, + disk_mb: 1024, + timeout: 300, + }, + ..BoxConfig::default() + }, + envd_version: "0.1.3".to_string(), + envd_mode: EnvdMode::Broker, + routing: crate::routing::SandboxRoutePolicy::default(), + rootfs_snapshot_id: None, + } +} + +pub fn record( + id: &str, + owner: &str, + name: Option<&str>, + state: SnapshotState, + second: i64, +) -> SnapshotRecord { + let mut record = SnapshotRecord::creating( + SnapshotId::new(id).unwrap(), + ExecutionSnapshotId::new(format!("content-{id}")).unwrap(), + owner, + SandboxId::new(format!("sandbox-{id}")).unwrap(), + ExecutionId::new(format!("execution-{id}")).unwrap(), + ExecutionGeneration::INITIAL, + PublicSandboxState::Running, + name.map(str::to_string), + "a3s-0123456789ab", + template(), + test_time(second), + ) + .unwrap(); + if matches!(state, SnapshotState::Active | SnapshotState::Deleting) { + record.mark_active(4_096).unwrap(); + } + if state == SnapshotState::Deleting { + record.begin_delete().unwrap(); + } + record +} diff --git a/src/compat/src/snapshot/tests/template.rs b/src/compat/src/snapshot/tests/template.rs new file mode 100644 index 00000000..be5cb2c0 --- /dev/null +++ b/src/compat/src/snapshot/tests/template.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::control::{ + ResolvedTemplate, TemplateProvider, TemplateProviderError, TemplateProviderResult, +}; + +use super::super::*; +use super::support::{record, template}; + +struct ConfiguredTemplate; + +#[async_trait] +impl TemplateProvider for ConfiguredTemplate { + async fn resolve( + &self, + _owner_id: &str, + template_id: &str, + ) -> TemplateProviderResult { + if template_id == "configured" { + Ok(template()) + } else { + Err(TemplateProviderError::NotFound(template_id.to_string())) + } + } +} + +#[tokio::test] +async fn dynamic_templates_are_owner_scoped_and_fall_back_to_configuration() { + let snapshots = Arc::new(MemorySnapshotRepository::default()); + let active = record( + "snapshot-a", + "owner-a", + Some("state"), + SnapshotState::Active, + 0, + ); + snapshots.insert(active.clone()).await.unwrap(); + let provider = SnapshotTemplateProvider::new(Arc::new(ConfiguredTemplate), snapshots); + + let resolved = provider + .resolve("owner-a", active.reference()) + .await + .unwrap(); + assert_eq!( + resolved.rootfs_snapshot_id.as_ref(), + Some(active.content_id()) + ); + assert!(matches!( + provider.resolve("owner-b", active.reference()).await, + Err(TemplateProviderError::NotFound(_)) + )); + assert!(provider + .resolve("owner-b", "configured") + .await + .unwrap() + .rootfs_snapshot_id + .is_none()); +} diff --git a/src/compat/src/volume/filesystem/mod.rs b/src/compat/src/volume/filesystem/mod.rs new file mode 100644 index 00000000..25eb2bab --- /dev/null +++ b/src/compat/src/volume/filesystem/mod.rs @@ -0,0 +1,201 @@ +use std::sync::Arc; + +#[cfg(target_os = "linux")] +use a3s_box_runtime::sandbox::probe_sandbox_capabilities; +use a3s_box_runtime::sandbox::{ + map_container_gid, map_container_uid, unmap_host_gid, unmap_host_uid, UserNamespaceEvidence, +}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use thiserror::Error; + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +mod unix_ops; +#[cfg(not(unix))] +mod unsupported; + +#[cfg(unix)] +pub use unix::{PendingVolumeWrite, VolumeFilesystem}; +#[cfg(not(unix))] +pub use unsupported::{PendingVolumeWrite, VolumeFilesystem}; + +pub const MAX_DIRECTORY_DEPTH: u32 = 64; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct VolumeMetadataUpdate { + pub uid: Option, + pub gid: Option, + pub mode: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum VolumeEntryType { + Unknown, + File, + Directory, + Symlink, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VolumeEntry { + pub name: String, + #[serde(rename = "type")] + pub entry_type: VolumeEntryType, + pub path: String, + pub size: i64, + pub mode: u32, + pub uid: u32, + pub gid: u32, + pub atime: DateTime, + pub mtime: DateTime, + pub ctime: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +#[derive(Debug, Error)] +pub enum VolumeContentError { + #[error("invalid volume path: {0}")] + InvalidPath(String), + #[error("volume path was not found")] + NotFound, + #[error("volume path conflicts with an existing or active entry")] + Conflict, + #[error("volume path operation is not permitted")] + PermissionDenied, + #[error("volume content operations are unsupported: {0}")] + Unsupported(String), + #[error("volume content storage is unavailable: {0}")] + Unavailable(String), +} + +pub type VolumeContentResult = std::result::Result; + +pub trait VolumeIdMapper: Send + Sync { + fn host_uid(&self, container_uid: u32) -> VolumeContentResult; + fn host_gid(&self, container_gid: u32) -> VolumeContentResult; + fn container_uid(&self, host_uid: u32) -> VolumeContentResult; + fn container_gid(&self, host_gid: u32) -> VolumeContentResult; +} + +#[derive(Debug, Clone)] +pub struct SandboxVolumeIdMapper { + evidence: UserNamespaceEvidence, +} + +impl SandboxVolumeIdMapper { + pub fn new(evidence: UserNamespaceEvidence) -> Self { + Self { evidence } + } +} + +impl VolumeIdMapper for SandboxVolumeIdMapper { + fn host_uid(&self, container_uid: u32) -> VolumeContentResult { + map_container_uid(&self.evidence, container_uid).map_err(mapping_error) + } + + fn host_gid(&self, container_gid: u32) -> VolumeContentResult { + map_container_gid(&self.evidence, container_gid).map_err(mapping_error) + } + + fn container_uid(&self, host_uid: u32) -> VolumeContentResult { + unmap_host_uid(&self.evidence, host_uid).map_err(mapping_error) + } + + fn container_gid(&self, host_gid: u32) -> VolumeContentResult { + unmap_host_gid(&self.evidence, host_gid).map_err(mapping_error) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct IdentityVolumeIdMapper { + effective_uid: u32, + effective_gid: u32, +} + +impl IdentityVolumeIdMapper { + pub fn current() -> Self { + #[cfg(unix)] + { + Self { + effective_uid: unsafe { libc::geteuid() } as u32, + effective_gid: unsafe { libc::getegid() } as u32, + } + } + + #[cfg(not(unix))] + { + Self { + effective_uid: 0, + effective_gid: 0, + } + } + } +} + +impl Default for IdentityVolumeIdMapper { + fn default() -> Self { + Self::current() + } +} + +impl VolumeIdMapper for IdentityVolumeIdMapper { + fn host_uid(&self, container_uid: u32) -> VolumeContentResult { + Ok(if container_uid == 0 { + self.effective_uid + } else { + container_uid + }) + } + + fn host_gid(&self, container_gid: u32) -> VolumeContentResult { + Ok(if container_gid == 0 { + self.effective_gid + } else { + container_gid + }) + } + + fn container_uid(&self, host_uid: u32) -> VolumeContentResult { + Ok(if host_uid == self.effective_uid { + 0 + } else { + host_uid + }) + } + + fn container_gid(&self, host_gid: u32) -> VolumeContentResult { + Ok(if host_gid == self.effective_gid { + 0 + } else { + host_gid + }) + } +} + +pub fn current_volume_id_mapper() -> VolumeContentResult> { + #[cfg(target_os = "linux")] + { + let snapshot = probe_sandbox_capabilities(None); + let evidence = snapshot.user_namespace.ok_or_else(|| { + VolumeContentError::Unsupported( + "Sandbox user-namespace identity mappings are unavailable".to_string(), + ) + })?; + Ok(Arc::new(SandboxVolumeIdMapper::new(evidence))) + } + + #[cfg(not(target_os = "linux"))] + { + Ok(Arc::new(IdentityVolumeIdMapper::current())) + } +} + +fn mapping_error(error: impl std::fmt::Display) -> VolumeContentError { + VolumeContentError::InvalidPath(format!( + "volume ownership is outside Sandbox mappings: {error}" + )) +} diff --git a/src/compat/src/volume/filesystem/unix.rs b/src/compat/src/volume/filesystem/unix.rs new file mode 100644 index 00000000..5ae250eb --- /dev/null +++ b/src/compat/src/volume/filesystem/unix.rs @@ -0,0 +1,188 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio::io::AsyncWriteExt; + +use super::unix_ops; +use super::{ + VolumeContentError, VolumeContentResult, VolumeEntry, VolumeIdMapper, VolumeMetadataUpdate, +}; + +#[derive(Clone)] +pub struct VolumeFilesystem { + ids: Arc, +} + +impl VolumeFilesystem { + pub fn new(ids: Arc) -> Self { + Self { ids } + } + + pub async fn initialize_root(&self, root: &Path) -> VolumeContentResult<()> { + let root = root.to_path_buf(); + let ids = self.ids.clone(); + blocking("initialize volume root", move || { + unix_ops::initialize_root(&root, ids.as_ref()) + }) + .await + } + + pub async fn stat(&self, root: &Path, path: &str) -> VolumeContentResult { + let root = root.to_path_buf(); + let path = path.to_string(); + let ids = self.ids.clone(); + blocking("stat volume path", move || { + unix_ops::stat_path(&root, &path, ids.as_ref()) + }) + .await + } + + pub async fn list( + &self, + root: &Path, + path: &str, + depth: u32, + ) -> VolumeContentResult> { + let root = root.to_path_buf(); + let path = path.to_string(); + let ids = self.ids.clone(); + blocking("list volume directory", move || { + unix_ops::list_path(&root, &path, depth, ids.as_ref()) + }) + .await + } + + pub async fn make_dir( + &self, + root: &Path, + path: &str, + metadata: VolumeMetadataUpdate, + force: bool, + ) -> VolumeContentResult { + let root = root.to_path_buf(); + let path = path.to_string(); + let ids = self.ids.clone(); + blocking("create volume directory", move || { + unix_ops::make_dir(&root, &path, metadata, force, ids.as_ref()) + }) + .await + } + + pub async fn update_metadata( + &self, + root: &Path, + path: &str, + metadata: VolumeMetadataUpdate, + ) -> VolumeContentResult { + let root = root.to_path_buf(); + let path = path.to_string(); + let ids = self.ids.clone(); + blocking("update volume metadata", move || { + unix_ops::update_metadata(&root, &path, metadata, ids.as_ref()) + }) + .await + } + + pub async fn remove(&self, root: &Path, path: &str) -> VolumeContentResult<()> { + let root = root.to_path_buf(); + let path = path.to_string(); + blocking("remove volume path", move || { + unix_ops::remove_path(&root, &path) + }) + .await + } + + pub async fn open_file(&self, root: &Path, path: &str) -> VolumeContentResult { + let root = root.to_path_buf(); + let path = path.to_string(); + let file = blocking("open volume file", move || { + unix_ops::open_file(&root, &path) + }) + .await?; + Ok(tokio::fs::File::from_std(file)) + } + + pub async fn begin_write( + &self, + root: &Path, + path: &str, + metadata: VolumeMetadataUpdate, + force: bool, + ) -> VolumeContentResult { + let root = root.to_path_buf(); + let path = path.to_string(); + let ids = self.ids.clone(); + let prepared_root = root.clone(); + let prepared_path = path.clone(); + let (prepared, file) = blocking("prepare volume upload", move || { + unix_ops::prepare_upload( + &prepared_root, + &prepared_path, + metadata, + force, + ids.as_ref(), + ) + }) + .await?; + Ok(PendingVolumeWrite { + filesystem: self.clone(), + root, + path, + prepared: Some(prepared), + file: Some(tokio::fs::File::from_std(file)), + }) + } +} + +pub struct PendingVolumeWrite { + filesystem: VolumeFilesystem, + root: PathBuf, + path: String, + prepared: Option, + file: Option, +} + +impl PendingVolumeWrite { + pub async fn write_all(&mut self, bytes: &[u8]) -> VolumeContentResult<()> { + self.file + .as_mut() + .ok_or_else(|| VolumeContentError::Unavailable("upload is already complete".into()))? + .write_all(bytes) + .await + .map_err(|error| VolumeContentError::Unavailable(format!("write upload: {error}"))) + } + + pub async fn finish(mut self) -> VolumeContentResult { + let mut file = self + .file + .take() + .ok_or_else(|| VolumeContentError::Unavailable("upload file is missing".into()))?; + file.flush() + .await + .map_err(|error| VolumeContentError::Unavailable(format!("flush upload: {error}")))?; + file.sync_all() + .await + .map_err(|error| VolumeContentError::Unavailable(format!("sync upload: {error}")))?; + let file = file.into_std().await; + let prepared = self.prepared.take().ok_or_else(|| { + VolumeContentError::Unavailable("upload transaction is missing".into()) + })?; + blocking("commit volume upload", move || { + unix_ops::finish_upload(prepared, file) + }) + .await?; + self.filesystem.stat(&self.root, &self.path).await + } +} + +async fn blocking(operation: &'static str, function: F) -> VolumeContentResult +where + T: Send + 'static, + F: FnOnce() -> VolumeContentResult + Send + 'static, +{ + tokio::task::spawn_blocking(function) + .await + .map_err(|error| { + VolumeContentError::Unavailable(format!("{operation} task failed: {error}")) + })? +} diff --git a/src/compat/src/volume/filesystem/unix_ops.rs b/src/compat/src/volume/filesystem/unix_ops.rs new file mode 100644 index 00000000..31a94cba --- /dev/null +++ b/src/compat/src/volume/filesystem/unix_ops.rs @@ -0,0 +1,856 @@ +use std::ffi::{CStr, CString, OsString}; +use std::fs::File; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::path::Path; + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::{ + VolumeContentError, VolumeContentResult, VolumeEntry, VolumeEntryType, VolumeIdMapper, + VolumeMetadataUpdate, MAX_DIRECTORY_DEPTH, +}; + +const MAX_PATH_BYTES: usize = 4096; +const MAX_COMPONENT_BYTES: usize = 255; +const DEFAULT_DIRECTORY_MODE: u32 = 0o755; +const DEFAULT_FILE_MODE: u32 = 0o644; +const INTERNAL_UPLOAD_PREFIX: &str = ".a3s-upload-"; + +pub struct PreparedUpload { + parent: OwnedFd, + temporary_name: CString, + final_name: CString, + temporary_device: libc::dev_t, + temporary_inode: libc::ino_t, + host_uid: u32, + host_gid: u32, + mode: u32, + force: bool, + armed: bool, +} + +impl Drop for PreparedUpload { + fn drop(&mut self) { + if !self.armed { + return; + } + let Ok(stat) = stat_at(&self.parent, &self.temporary_name) else { + return; + }; + if stat.st_dev == self.temporary_device && stat.st_ino == self.temporary_inode { + unsafe { + libc::unlinkat(self.parent.as_raw_fd(), self.temporary_name.as_ptr(), 0); + } + } + } +} + +pub fn initialize_root(root: &Path, ids: &dyn VolumeIdMapper) -> VolumeContentResult<()> { + let root = open_root(root)?; + set_identity_and_mode( + root.as_raw_fd(), + ids.host_uid(0)?, + ids.host_gid(0)?, + DEFAULT_DIRECTORY_MODE, + ) +} + +pub fn stat_path( + root: &Path, + path: &str, + ids: &dyn VolumeIdMapper, +) -> VolumeContentResult { + let path = NormalizedPath::parse(path)?; + let root = open_root(root)?; + if path.components.is_empty() { + let stat = fstat(&root)?; + return entry_from_stat(&root, None, "/", "/", &stat, ids); + } + let (parent, name) = open_parent(root, &path.components)?; + let stat = stat_at(&parent, name)?; + entry_from_stat(&parent, Some(name), path.name(), &path.display, &stat, ids) +} + +pub fn list_path( + root: &Path, + path: &str, + depth: u32, + ids: &dyn VolumeIdMapper, +) -> VolumeContentResult> { + if depth > MAX_DIRECTORY_DEPTH { + return Err(VolumeContentError::InvalidPath(format!( + "directory depth cannot exceed {MAX_DIRECTORY_DEPTH}" + ))); + } + let path = NormalizedPath::parse(path)?; + let root = open_root(root)?; + let directory = open_directory_path(root, &path.components)?; + let mut entries = Vec::new(); + if depth > 0 { + list_directory(&directory, &path.display, depth, ids, &mut entries)?; + } + entries.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(entries) +} + +pub fn make_dir( + root: &Path, + path: &str, + metadata: VolumeMetadataUpdate, + force: bool, + ids: &dyn VolumeIdMapper, +) -> VolumeContentResult { + let path = NormalizedPath::parse_non_root(path)?; + let requested_mode = validate_mode(metadata.mode, DEFAULT_DIRECTORY_MODE)?; + let requested_uid = ids.host_uid(metadata.uid.unwrap_or(0))?; + let requested_gid = ids.host_gid(metadata.gid.unwrap_or(0))?; + let default_uid = ids.host_uid(0)?; + let default_gid = ids.host_gid(0)?; + let mut current = open_root(root)?; + + if !force { + let (name, parents) = path.components.split_last().ok_or_else(|| { + VolumeContentError::InvalidPath("path identifies the root".to_string()) + })?; + current = open_directory_path(current, parents)?; + mkdir_at(¤t, name, 0o700)?; + let directory = open_directory_at(¤t, name)?; + set_identity_and_mode( + directory.as_raw_fd(), + requested_uid, + requested_gid, + requested_mode, + )?; + let stat = fstat(&directory)?; + return entry_from_stat(&directory, None, path.name(), &path.display, &stat, ids); + } + + for (index, name) in path.components.iter().enumerate() { + let last = index + 1 == path.components.len(); + let created = match mkdir_at(¤t, name, 0o700) { + Ok(()) => true, + Err(VolumeContentError::Conflict) => false, + Err(error) => return Err(error), + }; + let next = open_directory_at(¤t, name)?; + if created { + let (uid, gid, mode) = if last { + (requested_uid, requested_gid, requested_mode) + } else { + (default_uid, default_gid, DEFAULT_DIRECTORY_MODE) + }; + set_identity_and_mode(next.as_raw_fd(), uid, gid, mode)?; + } + current = next; + } + + let stat = fstat(¤t)?; + entry_from_stat(¤t, None, path.name(), &path.display, &stat, ids) +} + +pub fn update_metadata( + root: &Path, + path: &str, + metadata: VolumeMetadataUpdate, + ids: &dyn VolumeIdMapper, +) -> VolumeContentResult { + let path = NormalizedPath::parse_non_root(path)?; + let root = open_root(root)?; + let (parent, name) = open_parent(root, &path.components)?; + let before = stat_at(&parent, name)?; + let uid = metadata.uid.map(|value| ids.host_uid(value)).transpose()?; + let gid = metadata.gid.map(|value| ids.host_gid(value)).transpose()?; + let mode = metadata + .mode + .map(|value| validate_mode(Some(value), value)) + .transpose()?; + + if file_type(&before) == VolumeEntryType::Symlink { + if mode.is_some() { + return Err(VolumeContentError::InvalidPath( + "symlink mode updates are not supported".to_string(), + )); + } + if uid.is_some() || gid.is_some() { + let result = unsafe { + libc::fchownat( + parent.as_raw_fd(), + name.as_ptr(), + uid.unwrap_or(u32::MAX) as libc::uid_t, + gid.unwrap_or(u32::MAX) as libc::gid_t, + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + cvt(result, "change symlink ownership")?; + } + } else { + let mut flags = libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK; + if file_type(&before) == VolumeEntryType::Directory { + flags |= libc::O_DIRECTORY; + } + let entry = open_at(&parent, name, flags, 0)?; + if uid.is_some() || gid.is_some() { + let result = unsafe { + libc::fchown( + entry.as_raw_fd(), + uid.unwrap_or(u32::MAX) as libc::uid_t, + gid.unwrap_or(u32::MAX) as libc::gid_t, + ) + }; + cvt(result, "change volume ownership")?; + } + if let Some(mode) = mode { + let result = unsafe { libc::fchmod(entry.as_raw_fd(), mode as libc::mode_t) }; + cvt(result, "change volume mode")?; + } + } + + let stat = stat_at(&parent, name)?; + entry_from_stat(&parent, Some(name), path.name(), &path.display, &stat, ids) +} + +pub fn remove_path(root: &Path, path: &str) -> VolumeContentResult<()> { + let path = NormalizedPath::parse_non_root(path)?; + let root = open_root(root)?; + let (parent, name) = open_parent(root, &path.components)?; + remove_entry(&parent, name) +} + +pub fn open_file(root: &Path, path: &str) -> VolumeContentResult { + let path = NormalizedPath::parse_non_root(path)?; + let root = open_root(root)?; + let (parent, name) = open_parent(root, &path.components)?; + let file = open_at( + &parent, + name, + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + 0, + )?; + let stat = fstat(&file)?; + if file_type(&stat) != VolumeEntryType::File { + return Err(VolumeContentError::InvalidPath( + "requested path is not a regular file".to_string(), + )); + } + Ok(File::from(file)) +} + +pub fn prepare_upload( + root: &Path, + path: &str, + metadata: VolumeMetadataUpdate, + force: bool, + ids: &dyn VolumeIdMapper, +) -> VolumeContentResult<(PreparedUpload, File)> { + let path = NormalizedPath::parse_non_root(path)?; + let root = open_root(root)?; + let (parent, final_name) = open_parent(root, &path.components)?; + match stat_at(&parent, final_name) { + Ok(stat) if file_type(&stat) == VolumeEntryType::Directory => { + return Err(VolumeContentError::Conflict) + } + Ok(_) if !force => return Err(VolumeContentError::Conflict), + Ok(_) | Err(VolumeContentError::NotFound) => {} + Err(error) => return Err(error), + } + + let mode = validate_mode(metadata.mode, DEFAULT_FILE_MODE)?; + let host_uid = ids.host_uid(metadata.uid.unwrap_or(0))?; + let host_gid = ids.host_gid(metadata.gid.unwrap_or(0))?; + let (temporary_name, file) = create_temporary_file(&parent)?; + let stat = fstat(&file)?; + let prepared = PreparedUpload { + parent, + temporary_name, + final_name: final_name.clone(), + temporary_device: stat.st_dev, + temporary_inode: stat.st_ino, + host_uid, + host_gid, + mode, + force, + armed: true, + }; + Ok((prepared, File::from(file))) +} + +pub fn finish_upload(mut prepared: PreparedUpload, file: File) -> VolumeContentResult<()> { + let stat = fstat_file(&file)?; + if stat.st_dev != prepared.temporary_device || stat.st_ino != prepared.temporary_inode { + return Err(VolumeContentError::Conflict); + } + let linked = stat_at(&prepared.parent, &prepared.temporary_name)?; + if linked.st_dev != prepared.temporary_device || linked.st_ino != prepared.temporary_inode { + return Err(VolumeContentError::Conflict); + } + set_identity_and_mode( + file.as_raw_fd(), + prepared.host_uid, + prepared.host_gid, + prepared.mode, + )?; + file.sync_all() + .map_err(|error| unavailable("sync uploaded file", error))?; + rename_at( + &prepared.parent, + &prepared.temporary_name, + &prepared.final_name, + !prepared.force, + )?; + prepared.armed = false; + sync_directory(&prepared.parent)?; + Ok(()) +} + +struct NormalizedPath { + components: Vec, + display: String, +} + +impl NormalizedPath { + fn parse(value: &str) -> VolumeContentResult { + if !value.starts_with('/') || value.len() > MAX_PATH_BYTES { + return Err(VolumeContentError::InvalidPath( + "path must be absolute and no longer than 4096 bytes".to_string(), + )); + } + let mut components = Vec::new(); + let mut display_parts = Vec::new(); + for component in value.split('/') { + if component.is_empty() { + continue; + } + if component == "." || component == ".." { + return Err(VolumeContentError::InvalidPath( + "path traversal components are forbidden".to_string(), + )); + } + if component.starts_with(INTERNAL_UPLOAD_PREFIX) { + return Err(VolumeContentError::InvalidPath( + "path uses a reserved volume component".to_string(), + )); + } + if component.len() > MAX_COMPONENT_BYTES { + return Err(VolumeContentError::InvalidPath( + "path component exceeds 255 bytes".to_string(), + )); + } + components.push(CString::new(component).map_err(|_| { + VolumeContentError::InvalidPath("path contains a NUL byte".to_string()) + })?); + display_parts.push(component); + } + let display = if display_parts.is_empty() { + "/".to_string() + } else { + format!("/{}", display_parts.join("/")) + }; + Ok(Self { + components, + display, + }) + } + + fn parse_non_root(value: &str) -> VolumeContentResult { + let path = Self::parse(value)?; + if path.components.is_empty() { + return Err(VolumeContentError::InvalidPath( + "the volume root cannot be mutated".to_string(), + )); + } + Ok(path) + } + + fn name(&self) -> &str { + self.display.rsplit('/').next().unwrap_or("/") + } +} + +fn open_root(path: &Path) -> VolumeContentResult { + let path = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + VolumeContentError::Unavailable("volume root contains a NUL byte".to_string()) + })?; + open_raw( + libc::AT_FDCWD, + &path, + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + 0, + ) +} + +fn open_parent(root: OwnedFd, components: &[CString]) -> VolumeContentResult<(OwnedFd, &CString)> { + let (name, parents) = components + .split_last() + .ok_or_else(|| VolumeContentError::InvalidPath("path identifies the root".to_string()))?; + let parent = open_directory_path(root, parents)?; + Ok((parent, name)) +} + +fn open_directory_path( + mut current: OwnedFd, + components: &[CString], +) -> VolumeContentResult { + for component in components { + current = open_directory_at(¤t, component)?; + } + Ok(current) +} + +fn open_directory_at(parent: &OwnedFd, name: &CString) -> VolumeContentResult { + open_at( + parent, + name, + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + 0, + ) +} + +fn open_at( + parent: &OwnedFd, + name: &CString, + flags: libc::c_int, + mode: libc::mode_t, +) -> VolumeContentResult { + open_raw(parent.as_raw_fd(), name, flags, mode) +} + +fn open_raw( + parent: libc::c_int, + name: &CString, + flags: libc::c_int, + mode: libc::mode_t, +) -> VolumeContentResult { + // C variadic arguments apply integer promotion to mode_t. This is + // observable on Apple targets where mode_t is narrower than c_uint. + let promoted_mode = libc::c_uint::from(mode); + let fd = unsafe { libc::openat(parent, name.as_ptr(), flags, promoted_mode) }; + if fd < 0 { + return Err(io_error("open volume path", io::Error::last_os_error())); + } + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +fn mkdir_at(parent: &OwnedFd, name: &CString, mode: u32) -> VolumeContentResult<()> { + let result = unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), mode as libc::mode_t) }; + cvt(result, "create volume directory").map(|_| ()) +} + +fn stat_at(parent: &OwnedFd, name: &CString) -> VolumeContentResult { + let mut stat = std::mem::MaybeUninit::::uninit(); + let result = unsafe { + libc::fstatat( + parent.as_raw_fd(), + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + cvt(result, "stat volume path")?; + Ok(unsafe { stat.assume_init() }) +} + +fn fstat(fd: &OwnedFd) -> VolumeContentResult { + fstat_raw(fd.as_raw_fd()) +} + +fn fstat_file(file: &File) -> VolumeContentResult { + fstat_raw(file.as_raw_fd()) +} + +fn fstat_raw(fd: libc::c_int) -> VolumeContentResult { + let mut stat = std::mem::MaybeUninit::::uninit(); + let result = unsafe { libc::fstat(fd, stat.as_mut_ptr()) }; + cvt(result, "stat open volume path")?; + Ok(unsafe { stat.assume_init() }) +} + +fn set_identity_and_mode( + fd: libc::c_int, + uid: u32, + gid: u32, + mode: u32, +) -> VolumeContentResult<()> { + let result = unsafe { libc::fchown(fd, uid as libc::uid_t, gid as libc::gid_t) }; + cvt(result, "set volume ownership")?; + let result = unsafe { libc::fchmod(fd, mode as libc::mode_t) }; + cvt(result, "set volume mode")?; + Ok(()) +} + +fn validate_mode(value: Option, default: u32) -> VolumeContentResult { + let value = value.unwrap_or(default); + if value > 0o7777 { + return Err(VolumeContentError::InvalidPath( + "mode must contain only Unix permission and special bits".to_string(), + )); + } + Ok(value) +} + +fn list_directory( + directory: &OwnedFd, + base: &str, + depth: u32, + ids: &dyn VolumeIdMapper, + output: &mut Vec, +) -> VolumeContentResult<()> { + for (name, display_name) in read_directory_names(directory)? { + if display_name.starts_with(INTERNAL_UPLOAD_PREFIX) { + continue; + } + let path = if base == "/" { + format!("/{display_name}") + } else { + format!("{base}/{display_name}") + }; + let stat = match stat_at(directory, &name) { + Ok(stat) => stat, + Err(VolumeContentError::NotFound) => continue, + Err(error) => return Err(error), + }; + let entry = entry_from_stat(directory, Some(&name), &display_name, &path, &stat, ids)?; + let recurse = depth > 1 && entry.entry_type == VolumeEntryType::Directory; + output.push(entry); + if recurse { + match open_directory_at(directory, &name) { + Ok(child) => list_directory(&child, &path, depth - 1, ids, output)?, + Err(VolumeContentError::NotFound | VolumeContentError::InvalidPath(_)) => {} + Err(error) => return Err(error), + } + } + } + Ok(()) +} + +fn read_directory_names(directory: &OwnedFd) -> VolumeContentResult> { + let duplicate = unsafe { libc::fcntl(directory.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + if duplicate < 0 { + return Err(io_error( + "duplicate volume directory descriptor", + io::Error::last_os_error(), + )); + } + let stream = unsafe { libc::fdopendir(duplicate) }; + if stream.is_null() { + unsafe { libc::close(duplicate) }; + return Err(io_error( + "open volume directory stream", + io::Error::last_os_error(), + )); + } + let guard = DirectoryStream(stream); + let mut names = Vec::new(); + loop { + let entry = unsafe { libc::readdir(guard.0) }; + if entry.is_null() { + break; + } + let bytes = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if bytes == b"." || bytes == b".." { + continue; + } + let name = CString::new(bytes).map_err(|_| { + VolumeContentError::Unavailable("directory entry contains a NUL byte".to_string()) + })?; + let display = OsString::from_vec(bytes.to_vec()) + .to_string_lossy() + .into_owned(); + names.push((name, display)); + } + names.sort_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes())); + Ok(names) +} + +struct DirectoryStream(*mut libc::DIR); + +impl Drop for DirectoryStream { + fn drop(&mut self) { + unsafe { + libc::closedir(self.0); + } + } +} + +fn remove_entry(parent: &OwnedFd, name: &CString) -> VolumeContentResult<()> { + let stat = stat_at(parent, name)?; + if file_type(&stat) == VolumeEntryType::Directory { + let directory = open_directory_at(parent, name)?; + for (child, _) in read_directory_names(&directory)? { + match remove_entry(&directory, &child) { + Ok(()) | Err(VolumeContentError::NotFound) => {} + Err(error) => return Err(error), + } + } + let result = + unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), libc::AT_REMOVEDIR) }; + cvt(result, "remove volume directory")?; + } else { + let result = unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), 0) }; + cvt(result, "remove volume entry")?; + } + Ok(()) +} + +fn create_temporary_file(parent: &OwnedFd) -> VolumeContentResult<(CString, OwnedFd)> { + for _ in 0..16 { + let name = CString::new(format!( + "{INTERNAL_UPLOAD_PREFIX}{}", + Uuid::new_v4().simple() + )) + .map_err(|_| VolumeContentError::Unavailable("invalid upload name".to_string()))?; + match open_at( + parent, + &name, + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) { + Ok(file) => return Ok((name, file)), + Err(VolumeContentError::Conflict) => continue, + Err(error) => return Err(error), + } + } + Err(VolumeContentError::Unavailable( + "could not allocate a unique upload file".to_string(), + )) +} + +fn rename_at( + parent: &OwnedFd, + source: &CString, + destination: &CString, + no_replace: bool, +) -> VolumeContentResult<()> { + #[cfg(target_os = "linux")] + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + parent.as_raw_fd(), + source.as_ptr(), + parent.as_raw_fd(), + destination.as_ptr(), + if no_replace { + libc::RENAME_NOREPLACE + } else { + 0 + }, + ) as libc::c_int + }; + + #[cfg(not(target_os = "linux"))] + let result = { + if no_replace && stat_at(parent, destination).is_ok() { + return Err(VolumeContentError::Conflict); + } + unsafe { + libc::renameat( + parent.as_raw_fd(), + source.as_ptr(), + parent.as_raw_fd(), + destination.as_ptr(), + ) + } + }; + + cvt(result, "commit volume upload")?; + Ok(()) +} + +fn sync_directory(directory: &OwnedFd) -> VolumeContentResult<()> { + let result = unsafe { libc::fsync(directory.as_raw_fd()) }; + if result == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EINVAL) { + return Ok(()); + } + Err(io_error("sync volume directory", error)) +} + +fn entry_from_stat( + parent: &OwnedFd, + name: Option<&CString>, + display_name: &str, + path: &str, + stat: &libc::stat, + ids: &dyn VolumeIdMapper, +) -> VolumeContentResult { + let entry_type = file_type(stat); + let target = if entry_type == VolumeEntryType::Symlink { + name.map(|name| read_link(parent, name)).transpose()? + } else { + None + }; + let (atime_seconds, atime_nanos) = atime(stat); + let (mtime_seconds, mtime_nanos) = mtime(stat); + let (ctime_seconds, ctime_nanos) = ctime(stat); + Ok(VolumeEntry { + name: display_name.to_string(), + entry_type, + path: path.to_string(), + size: file_size(stat.st_size), + mode: permission_mode(stat.st_mode), + uid: ids.container_uid(stat.st_uid)?, + gid: ids.container_gid(stat.st_gid)?, + atime: timestamp(atime_seconds, atime_nanos)?, + mtime: timestamp(mtime_seconds, mtime_nanos)?, + ctime: timestamp(ctime_seconds, ctime_nanos)?, + target, + }) +} + +fn file_type(stat: &libc::stat) -> VolumeEntryType { + match stat.st_mode & libc::S_IFMT { + libc::S_IFREG => VolumeEntryType::File, + libc::S_IFDIR => VolumeEntryType::Directory, + libc::S_IFLNK => VolumeEntryType::Symlink, + _ => VolumeEntryType::Unknown, + } +} + +fn read_link(parent: &OwnedFd, name: &CString) -> VolumeContentResult { + let mut capacity = 256; + loop { + let mut buffer = vec![0_u8; capacity]; + let length = unsafe { + libc::readlinkat( + parent.as_raw_fd(), + name.as_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + ) + }; + if length < 0 { + return Err(io_error("read volume symlink", io::Error::last_os_error())); + } + let length = length as usize; + if length < buffer.len() { + buffer.truncate(length); + return Ok(OsString::from_vec(buffer).to_string_lossy().into_owned()); + } + capacity = capacity.checked_mul(2).ok_or_else(|| { + VolumeContentError::Unavailable("volume symlink target is too large".to_string()) + })?; + if capacity > 64 * 1024 { + return Err(VolumeContentError::Unavailable( + "volume symlink target is too large".to_string(), + )); + } + } +} + +fn timestamp(seconds: i64, nanos: i64) -> VolumeContentResult> { + let nanos = u32::try_from(nanos).map_err(|_| { + VolumeContentError::Unavailable("filesystem timestamp is invalid".to_string()) + })?; + DateTime::from_timestamp(seconds, nanos).ok_or_else(|| { + VolumeContentError::Unavailable("filesystem timestamp is out of range".to_string()) + }) +} + +// libc scalar aliases vary across Unix targets even when the protocol's wire +// representation does not. Normalize them once at the ABI boundary rather +// than spreading target-dependent casts through filesystem logic. +#[allow(clippy::unnecessary_cast)] +fn file_size(size: libc::off_t) -> i64 { + size as i64 +} + +#[allow(clippy::unnecessary_cast)] +fn permission_mode(mode: libc::mode_t) -> u32 { + (mode as u32) & 0o7777 +} + +#[allow(clippy::unnecessary_cast)] +fn timestamp_parts(seconds: libc::time_t, nanos: libc::c_long) -> (i64, i64) { + (seconds as i64, nanos as i64) +} + +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +))] +fn atime(stat: &libc::stat) -> (i64, i64) { + timestamp_parts(stat.st_atime, stat.st_atime_nsec) +} + +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +))] +fn mtime(stat: &libc::stat) -> (i64, i64) { + timestamp_parts(stat.st_mtime, stat.st_mtime_nsec) +} + +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +))] +fn ctime(stat: &libc::stat) -> (i64, i64) { + timestamp_parts(stat.st_ctime, stat.st_ctime_nsec) +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +)))] +fn atime(stat: &libc::stat) -> (i64, i64) { + (stat.st_atime as i64, 0) +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +)))] +fn mtime(stat: &libc::stat) -> (i64, i64) { + (stat.st_mtime as i64, 0) +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +)))] +fn ctime(stat: &libc::stat) -> (i64, i64) { + (stat.st_ctime as i64, 0) +} + +fn cvt(result: libc::c_int, operation: &str) -> VolumeContentResult { + if result >= 0 { + Ok(result) + } else { + Err(io_error(operation, io::Error::last_os_error())) + } +} + +fn io_error(operation: &str, error: io::Error) -> VolumeContentError { + match error.raw_os_error() { + Some(libc::ENOENT) => VolumeContentError::NotFound, + Some(libc::EEXIST) | Some(libc::ENOTEMPTY) | Some(libc::EBUSY) => { + VolumeContentError::Conflict + } + Some(libc::EACCES) | Some(libc::EPERM) => VolumeContentError::PermissionDenied, + Some(libc::ELOOP) | Some(libc::ENOTDIR) | Some(libc::EINVAL) | Some(libc::ENAMETOOLONG) => { + VolumeContentError::InvalidPath(format!("{operation}: {error}")) + } + _ => unavailable(operation, error), + } +} + +fn unavailable(operation: &str, error: impl std::fmt::Display) -> VolumeContentError { + VolumeContentError::Unavailable(format!("{operation}: {error}")) +} diff --git a/src/compat/src/volume/filesystem/unsupported.rs b/src/compat/src/volume/filesystem/unsupported.rs new file mode 100644 index 00000000..ee998dd3 --- /dev/null +++ b/src/compat/src/volume/filesystem/unsupported.rs @@ -0,0 +1,91 @@ +use std::path::Path; +use std::sync::Arc; + +use super::{ + VolumeContentError, VolumeContentResult, VolumeEntry, VolumeIdMapper, VolumeMetadataUpdate, +}; + +#[derive(Clone)] +pub struct VolumeFilesystem { + _ids: Arc, +} + +impl VolumeFilesystem { + pub fn new(ids: Arc) -> Self { + Self { _ids: ids } + } + + pub async fn initialize_root(&self, _root: &Path) -> VolumeContentResult<()> { + Err(unsupported()) + } + + pub async fn stat(&self, _root: &Path, _path: &str) -> VolumeContentResult { + Err(unsupported()) + } + + pub async fn list( + &self, + _root: &Path, + _path: &str, + _depth: u32, + ) -> VolumeContentResult> { + Err(unsupported()) + } + + pub async fn make_dir( + &self, + _root: &Path, + _path: &str, + _metadata: VolumeMetadataUpdate, + _force: bool, + ) -> VolumeContentResult { + Err(unsupported()) + } + + pub async fn update_metadata( + &self, + _root: &Path, + _path: &str, + _metadata: VolumeMetadataUpdate, + ) -> VolumeContentResult { + Err(unsupported()) + } + + pub async fn remove(&self, _root: &Path, _path: &str) -> VolumeContentResult<()> { + Err(unsupported()) + } + + pub async fn open_file( + &self, + _root: &Path, + _path: &str, + ) -> VolumeContentResult { + Err(unsupported()) + } + + pub async fn begin_write( + &self, + _root: &Path, + _path: &str, + _metadata: VolumeMetadataUpdate, + _force: bool, + ) -> VolumeContentResult { + Err(unsupported()) + } +} + +pub struct PendingVolumeWrite; + +impl PendingVolumeWrite { + pub async fn write_all(&mut self, _bytes: &[u8]) -> VolumeContentResult<()> { + Err(unsupported()) + } + + pub async fn finish(self) -> VolumeContentResult { + Err(unsupported()) + } +} + +fn unsupported() -> VolumeContentError { + VolumeContentError::Unsupported("descriptor-relative filesystem APIs require Unix".to_string()) +} diff --git a/src/compat/src/volume/memory.rs b/src/compat/src/volume/memory.rs new file mode 100644 index 00000000..efa9c95c --- /dev/null +++ b/src/compat/src/volume/memory.rs @@ -0,0 +1,123 @@ +use std::collections::BTreeMap; +use std::sync::Mutex; + +use async_trait::async_trait; + +use super::{ + VolumeId, VolumeRecord, VolumeReplaceResult, VolumeRepository, VolumeRepositoryError, + VolumeRepositoryResult, VolumeState, +}; + +#[derive(Debug, Default)] +pub struct MemoryVolumeRepository { + records: Mutex>, +} + +#[async_trait] +impl VolumeRepository for MemoryVolumeRepository { + async fn insert(&self, record: VolumeRecord) -> VolumeRepositoryResult<()> { + record + .validate() + .map_err(|error| VolumeRepositoryError::Corrupt(error.to_string()))?; + let mut records = self.records.lock().map_err(lock_error)?; + if records.contains_key(record.volume_id()) + || records.values().any(|existing| { + existing.owner_id() == record.owner_id() && existing.name() == record.name() + }) + { + return Err(VolumeRepositoryError::Duplicate); + } + records.insert(record.volume_id().clone(), record); + Ok(()) + } + + async fn get(&self, volume_id: &VolumeId) -> VolumeRepositoryResult> { + Ok(self + .records + .lock() + .map_err(lock_error)? + .get(volume_id) + .cloned()) + } + + async fn get_by_owner_name( + &self, + owner_id: &str, + name: &str, + ) -> VolumeRepositoryResult> { + Ok(self + .records + .lock() + .map_err(lock_error)? + .values() + .find(|record| record.owner_id() == owner_id && record.name() == name) + .cloned()) + } + + async fn list(&self, owner_id: &str) -> VolumeRepositoryResult> { + let mut records = self + .records + .lock() + .map_err(lock_error)? + .values() + .filter(|record| record.owner_id() == owner_id && record.state() == VolumeState::Active) + .cloned() + .collect::>(); + records.sort_by(|left, right| { + left.created_at() + .cmp(&right.created_at()) + .then_with(|| left.volume_id().cmp(right.volume_id())) + }); + Ok(records) + } + + async fn list_in_state(&self, state: VolumeState) -> VolumeRepositoryResult> { + Ok(self + .records + .lock() + .map_err(lock_error)? + .values() + .filter(|record| record.state() == state) + .cloned() + .collect()) + } + + async fn replace( + &self, + expected: VolumeState, + replacement: VolumeRecord, + ) -> VolumeRepositoryResult { + replacement + .validate() + .map_err(|error| VolumeRepositoryError::Corrupt(error.to_string()))?; + let mut records = self.records.lock().map_err(lock_error)?; + let Some(current) = records.get(replacement.volume_id()) else { + return Ok(VolumeReplaceResult::NotFound); + }; + if current.state() != expected { + return Ok(VolumeReplaceResult::Conflict); + } + records.insert(replacement.volume_id().clone(), replacement); + Ok(VolumeReplaceResult::Updated) + } + + async fn delete( + &self, + volume_id: &VolumeId, + expected: VolumeState, + ) -> VolumeRepositoryResult { + let mut records = self.records.lock().map_err(lock_error)?; + let Some(current) = records.get(volume_id) else { + return Ok(VolumeReplaceResult::NotFound); + }; + if current.state() != expected { + return Ok(VolumeReplaceResult::Conflict); + } + records.remove(volume_id); + Ok(VolumeReplaceResult::Updated) + } +} + +fn lock_error(_: std::sync::PoisonError) -> VolumeRepositoryError { + VolumeRepositoryError::Unavailable("memory volume repository lock is poisoned".to_string()) +} diff --git a/src/compat/src/volume/mod.rs b/src/compat/src/volume/mod.rs new file mode 100644 index 00000000..2ed6b03e --- /dev/null +++ b/src/compat/src/volume/mod.rs @@ -0,0 +1,32 @@ +mod filesystem; +mod memory; +mod model; +mod mount; +mod repository; +mod runtime; +mod service; +mod sqlite; + +pub use filesystem::{ + current_volume_id_mapper, IdentityVolumeIdMapper, PendingVolumeWrite, SandboxVolumeIdMapper, + VolumeContentError, VolumeContentResult, VolumeEntry, VolumeEntryType, VolumeFilesystem, + VolumeIdMapper, VolumeMetadataUpdate, MAX_DIRECTORY_DEPTH, +}; +pub use memory::MemoryVolumeRepository; +pub use model::{valid_volume_name, VolumeId, VolumeModelError, VolumeRecord, VolumeState}; +pub use mount::{validate_mounts, ResolvedVolumeMount, VolumeMount, VolumeMountResolver}; +pub use repository::{ + VolumeReplaceResult, VolumeRepository, VolumeRepositoryError, VolumeRepositoryResult, +}; +pub use runtime::{ + A3sRuntimeVolumeStore, RuntimeVolume, RuntimeVolumeError, RuntimeVolumeRemoveResult, + RuntimeVolumeResult, RuntimeVolumeStore, +}; +pub use service::{ + AuthorizedVolume, VolumeConnection, VolumeReconciliationReport, VolumeService, + VolumeServiceDependencies, VolumeServiceError, VolumeServiceResult, +}; +pub use sqlite::SqliteVolumeRepository; + +#[cfg(test)] +pub(crate) mod tests; diff --git a/src/compat/src/volume/model.rs b/src/compat/src/volume/model.rs new file mode 100644 index 00000000..73e86f2a --- /dev/null +++ b/src/compat/src/volume/model.rs @@ -0,0 +1,204 @@ +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::control::StoredToken; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct VolumeId(String); + +impl VolumeId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(VolumeModelError::InvalidId); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for VolumeId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl TryFrom for VolumeId { + type Error = VolumeModelError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: VolumeId) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VolumeState { + Creating, + Active, + Deleting, +} + +impl VolumeState { + pub const fn as_str(self) -> &'static str { + match self { + Self::Creating => "creating", + Self::Active => "active", + Self::Deleting => "deleting", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VolumeRecord { + volume_id: VolumeId, + owner_id: String, + name: String, + runtime_name: String, + token: StoredToken, + state: VolumeState, + created_at: DateTime, +} + +impl VolumeRecord { + pub fn creating( + volume_id: VolumeId, + owner_id: impl Into, + name: impl Into, + runtime_name: impl Into, + token: StoredToken, + created_at: DateTime, + ) -> Result { + let record = Self { + volume_id, + owner_id: owner_id.into(), + name: name.into(), + runtime_name: runtime_name.into(), + token, + state: VolumeState::Creating, + created_at, + }; + record.validate()?; + Ok(record) + } + + pub fn volume_id(&self) -> &VolumeId { + &self.volume_id + } + + pub fn owner_id(&self) -> &str { + &self.owner_id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn runtime_name(&self) -> &str { + &self.runtime_name + } + + pub fn token(&self) -> &StoredToken { + &self.token + } + + pub const fn state(&self) -> VolumeState { + self.state + } + + pub const fn created_at(&self) -> DateTime { + self.created_at + } + + pub fn mark_active(&mut self) -> Result<(), VolumeModelError> { + if self.state != VolumeState::Creating { + return Err(VolumeModelError::InvalidTransition); + } + self.state = VolumeState::Active; + Ok(()) + } + + pub fn begin_delete(&mut self) -> Result<(), VolumeModelError> { + if self.state != VolumeState::Active { + return Err(VolumeModelError::InvalidTransition); + } + self.state = VolumeState::Deleting; + Ok(()) + } + + pub fn abort_delete(&mut self) -> Result<(), VolumeModelError> { + if self.state != VolumeState::Deleting { + return Err(VolumeModelError::InvalidTransition); + } + self.state = VolumeState::Active; + Ok(()) + } + + pub fn validate(&self) -> Result<(), VolumeModelError> { + if self.owner_id.trim().is_empty() + || !valid_volume_name(&self.name) + || !valid_runtime_name(&self.runtime_name) + { + return Err(VolumeModelError::InvalidRecord); + } + Ok(()) + } +} + +pub fn valid_volume_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn valid_runtime_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum VolumeModelError { + #[error("invalid volume ID")] + InvalidId, + #[error("invalid volume record")] + InvalidRecord, + #[error("invalid volume state transition")] + InvalidTransition, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_protocol_volume_names_without_using_them_as_paths() { + for valid in ["data", "Data_01", "a-b"] { + assert!(valid_volume_name(valid)); + } + for invalid in ["", "data/other", "../escape", "with space"] { + assert!(!valid_volume_name(invalid)); + } + } +} diff --git a/src/compat/src/volume/mount.rs b/src/compat/src/volume/mount.rs new file mode 100644 index 00000000..7fbecbed --- /dev/null +++ b/src/compat/src/volume/mount.rs @@ -0,0 +1,102 @@ +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use super::{VolumeServiceError, VolumeServiceResult}; + +const MAX_MOUNT_PATH_BYTES: usize = 4096; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VolumeMount { + pub name: String, + pub path: String, +} + +impl VolumeMount { + pub fn new(name: impl Into, path: impl Into) -> VolumeServiceResult { + let mount = Self { + name: name.into(), + path: path.into(), + }; + mount.validate()?; + Ok(mount) + } + + pub fn validate(&self) -> VolumeServiceResult<()> { + if !super::valid_volume_name(&self.name) { + return Err(VolumeServiceError::InvalidRequest( + "volume mount name is invalid".to_string(), + )); + } + if self.path.len() > MAX_MOUNT_PATH_BYTES || self.path.contains(':') { + return Err(VolumeServiceError::InvalidRequest( + "volume mount path is invalid".to_string(), + )); + } + let path = Path::new(&self.path); + if !path.is_absolute() || path == Path::new("/") { + return Err(VolumeServiceError::InvalidRequest( + "volume mount path must be an absolute non-root path".to_string(), + )); + } + if self + .path + .split('/') + .skip(1) + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(VolumeServiceError::InvalidRequest( + "volume mount path must be lexically normalized".to_string(), + )); + } + for component in path.components() { + if matches!( + component, + Component::ParentDir | Component::CurDir | Component::Prefix(_) + ) { + return Err(VolumeServiceError::InvalidRequest( + "volume mount path cannot contain traversal components".to_string(), + )); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedVolumeMount { + pub public: VolumeMount, + pub runtime_name: String, + pub host_path: PathBuf, +} + +impl ResolvedVolumeMount { + pub fn runtime_spec(&self) -> String { + format!("{}:{}:rw", self.host_path.display(), self.public.path) + } +} + +pub fn validate_mounts(mounts: &[VolumeMount]) -> VolumeServiceResult<()> { + let mut paths = BTreeSet::new(); + for mount in mounts { + mount.validate()?; + if !paths.insert(mount.path.as_str()) { + return Err(VolumeServiceError::InvalidRequest( + "volume mount paths must be unique".to_string(), + )); + } + } + Ok(()) +} + +#[async_trait] +pub trait VolumeMountResolver: Send + Sync { + async fn resolve_mounts( + &self, + owner_id: &str, + mounts: &[VolumeMount], + ) -> VolumeServiceResult>; +} diff --git a/src/compat/src/volume/repository.rs b/src/compat/src/volume/repository.rs new file mode 100644 index 00000000..32d923f8 --- /dev/null +++ b/src/compat/src/volume/repository.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use thiserror::Error; + +use super::{VolumeId, VolumeRecord, VolumeState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VolumeReplaceResult { + Updated, + NotFound, + Conflict, +} + +#[derive(Debug, Error)] +pub enum VolumeRepositoryError { + #[error("volume already exists")] + Duplicate, + #[error("volume repository is unavailable: {0}")] + Unavailable(String), + #[error("volume repository contains invalid data: {0}")] + Corrupt(String), +} + +pub type VolumeRepositoryResult = std::result::Result; + +#[async_trait] +pub trait VolumeRepository: Send + Sync { + async fn insert(&self, record: VolumeRecord) -> VolumeRepositoryResult<()>; + + async fn get(&self, volume_id: &VolumeId) -> VolumeRepositoryResult>; + + async fn get_by_owner_name( + &self, + owner_id: &str, + name: &str, + ) -> VolumeRepositoryResult>; + + async fn list(&self, owner_id: &str) -> VolumeRepositoryResult>; + + async fn list_in_state(&self, state: VolumeState) -> VolumeRepositoryResult>; + + async fn replace( + &self, + expected: VolumeState, + replacement: VolumeRecord, + ) -> VolumeRepositoryResult; + + async fn delete( + &self, + volume_id: &VolumeId, + expected: VolumeState, + ) -> VolumeRepositoryResult; +} diff --git a/src/compat/src/volume/runtime.rs b/src/compat/src/volume/runtime.rs new file mode 100644 index 00000000..305d432a --- /dev/null +++ b/src/compat/src/volume/runtime.rs @@ -0,0 +1,156 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use a3s_box_core::volume::VolumeConfig; +use a3s_box_runtime::VolumeStore; +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeVolume { + pub name: String, + pub mount_point: PathBuf, + pub in_use_by: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeVolumeRemoveResult { + Removed, + NotFound, +} + +#[derive(Debug, Error)] +pub enum RuntimeVolumeError { + #[error("runtime volume is in use")] + InUse, + #[error("runtime volume store is unavailable: {0}")] + Unavailable(String), +} + +pub type RuntimeVolumeResult = std::result::Result; + +#[async_trait] +pub trait RuntimeVolumeStore: Send + Sync { + async fn materialize(&self, name: &str) -> RuntimeVolumeResult; + + async fn get(&self, name: &str) -> RuntimeVolumeResult>; + + async fn remove(&self, name: &str) -> RuntimeVolumeResult; +} + +#[derive(Debug, Clone)] +pub struct A3sRuntimeVolumeStore { + store: Arc, + volume_root: PathBuf, +} + +impl A3sRuntimeVolumeStore { + pub fn new(runtime_home: impl AsRef) -> Self { + let runtime_home = runtime_home.as_ref(); + let volume_root = runtime_home.join("volumes"); + Self { + store: Arc::new(VolumeStore::new( + runtime_home.join("volumes.json"), + &volume_root, + )), + volume_root, + } + } +} + +#[async_trait] +impl RuntimeVolumeStore for A3sRuntimeVolumeStore { + async fn materialize(&self, name: &str) -> RuntimeVolumeResult { + let name = name.to_string(); + let store = self.store.clone(); + let volume_root = self.volume_root.clone(); + tokio::task::spawn_blocking(move || { + let volume = store + .get_or_create(VolumeConfig::new(&name, "")) + .map_err(|error| unavailable("materialize volume", error))?; + runtime_volume(volume, &volume_root) + }) + .await + .map_err(|error| RuntimeVolumeError::Unavailable(format!("volume task failed: {error}")))? + } + + async fn get(&self, name: &str) -> RuntimeVolumeResult> { + let name = name.to_string(); + let store = self.store.clone(); + let volume_root = self.volume_root.clone(); + tokio::task::spawn_blocking(move || { + store + .get(&name) + .map_err(|error| unavailable("load volume", error))? + .map(|volume| runtime_volume(volume, &volume_root)) + .transpose() + }) + .await + .map_err(|error| RuntimeVolumeError::Unavailable(format!("volume task failed: {error}")))? + } + + async fn remove(&self, name: &str) -> RuntimeVolumeResult { + let name = name.to_string(); + let store = self.store.clone(); + let volume_root = self.volume_root.clone(); + tokio::task::spawn_blocking(move || { + let configured = store + .get(&name) + .map_err(|error| unavailable("load volume before removal", error))?; + if configured.as_ref().is_some_and(VolumeConfig::is_in_use) { + return Err(RuntimeVolumeError::InUse); + } + + let data_path = volume_root.join(&name); + let result = match configured { + Some(_) => match store.remove(&name, false) { + Ok(_) => RuntimeVolumeRemoveResult::Removed, + Err(error) => { + let current = store.get(&name).map_err(|load_error| { + unavailable("reload volume after removal", load_error) + })?; + if current.as_ref().is_some_and(VolumeConfig::is_in_use) { + return Err(RuntimeVolumeError::InUse); + } + return Err(unavailable("remove volume", error)); + } + }, + None => RuntimeVolumeRemoveResult::NotFound, + }; + + if data_path.exists() { + std::fs::remove_dir_all(&data_path) + .map_err(|error| unavailable("remove volume data", error))?; + } + Ok(result) + }) + .await + .map_err(|error| RuntimeVolumeError::Unavailable(format!("volume task failed: {error}")))? + } +} + +fn runtime_volume(volume: VolumeConfig, volume_root: &Path) -> RuntimeVolumeResult { + let canonical_root = volume_root + .canonicalize() + .map_err(|error| unavailable("canonicalize volume root", error))?; + let mount_point = PathBuf::from(&volume.mount_point) + .canonicalize() + .map_err(|error| unavailable("canonicalize volume mount point", error))?; + let metadata = std::fs::metadata(&mount_point) + .map_err(|error| unavailable("inspect volume mount point", error))?; + if !metadata.is_dir() || mount_point.parent() != Some(canonical_root.as_path()) { + return Err(RuntimeVolumeError::Unavailable(format!( + "volume '{}' resolved outside its managed root", + volume.name + ))); + } + Ok(RuntimeVolume { + name: volume.name, + mount_point, + in_use_by: volume.in_use_by, + }) +} + +fn unavailable(context: &str, error: impl std::fmt::Display) -> RuntimeVolumeError { + RuntimeVolumeError::Unavailable(format!("{context}: {error}")) +} diff --git a/src/compat/src/volume/service.rs b/src/compat/src/volume/service.rs new file mode 100644 index 00000000..6fde59ee --- /dev/null +++ b/src/compat/src/volume/service.rs @@ -0,0 +1,360 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use thiserror::Error; +use uuid::Uuid; + +use crate::control::{ + Clock, SecretToken, TokenIssuer, TokenIssuerError, TokenResolver, TokenScope, TokenVerifier, +}; + +use super::{ + validate_mounts, ResolvedVolumeMount, RuntimeVolumeError, RuntimeVolumeRemoveResult, + RuntimeVolumeStore, VolumeContentError, VolumeFilesystem, VolumeId, VolumeModelError, + VolumeMount, VolumeMountResolver, VolumeRecord, VolumeReplaceResult, VolumeRepository, + VolumeRepositoryError, VolumeState, +}; + +#[derive(Debug)] +pub struct VolumeConnection { + pub record: VolumeRecord, + pub token: SecretToken, +} + +#[derive(Debug, Clone)] +pub struct AuthorizedVolume { + pub record: VolumeRecord, + pub root: std::path::PathBuf, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct VolumeReconciliationReport { + pub examined: usize, + pub completed: usize, + pub deferred: usize, + pub failures: Vec, +} + +#[derive(Debug, Error)] +pub enum VolumeServiceError { + #[error("invalid volume request: {0}")] + InvalidRequest(String), + #[error("volume not found")] + NotFound, + #[error("volume already exists")] + Duplicate, + #[error("volume is in use or changing state")] + Conflict, + #[error("volume token is invalid")] + Forbidden, + #[error(transparent)] + Repository(#[from] VolumeRepositoryError), + #[error(transparent)] + Runtime(#[from] RuntimeVolumeError), + #[error(transparent)] + Credential(#[from] TokenIssuerError), + #[error(transparent)] + Model(#[from] VolumeModelError), + #[error(transparent)] + Content(#[from] VolumeContentError), +} + +pub type VolumeServiceResult = std::result::Result; + +#[derive(Clone)] +pub struct VolumeService { + repository: Arc, + runtime: Arc, + clock: Arc, + token_issuer: Arc, + token_resolver: Arc, + token_verifier: Arc, + filesystem: Arc, +} + +pub struct VolumeServiceDependencies { + pub repository: Arc, + pub runtime: Arc, + pub clock: Arc, + pub token_issuer: Arc, + pub token_resolver: Arc, + pub token_verifier: Arc, + pub filesystem: Arc, +} + +impl VolumeService { + pub fn new(dependencies: VolumeServiceDependencies) -> Self { + Self { + repository: dependencies.repository, + runtime: dependencies.runtime, + clock: dependencies.clock, + token_issuer: dependencies.token_issuer, + token_resolver: dependencies.token_resolver, + token_verifier: dependencies.token_verifier, + filesystem: dependencies.filesystem, + } + } + + pub fn filesystem(&self) -> &VolumeFilesystem { + &self.filesystem + } + + pub async fn create( + &self, + owner_id: &str, + name: &str, + ) -> VolumeServiceResult { + if owner_id.trim().is_empty() || !super::valid_volume_name(name) { + return Err(VolumeServiceError::InvalidRequest( + "volume name must match [A-Za-z0-9_-]+".to_string(), + )); + } + + let volume_id = VolumeId::new(Uuid::new_v4().to_string())?; + let runtime_name = format!("e2b-{}", Uuid::new_v4().simple()); + let token = self.token_issuer.issue(TokenScope::Volume).await?; + let mut record = VolumeRecord::creating( + volume_id, + owner_id, + name, + runtime_name, + token.stored, + self.clock.now(), + )?; + match self.repository.insert(record.clone()).await { + Ok(()) => {} + Err(VolumeRepositoryError::Duplicate) => return Err(VolumeServiceError::Duplicate), + Err(error) => return Err(error.into()), + } + + let runtime = match self.runtime.materialize(record.runtime_name()).await { + Ok(runtime) => runtime, + Err(error) => { + let _ = self + .repository + .delete(record.volume_id(), VolumeState::Creating) + .await; + return Err(error.into()); + } + }; + if let Err(error) = self.filesystem.initialize_root(&runtime.mount_point).await { + let _ = self.runtime.remove(record.runtime_name()).await; + let _ = self + .repository + .delete(record.volume_id(), VolumeState::Creating) + .await; + return Err(error.into()); + } + record.mark_active()?; + self.replace(VolumeState::Creating, record.clone()).await?; + Ok(VolumeConnection { + record, + token: token.secret, + }) + } + + pub async fn get( + &self, + owner_id: &str, + volume_id: &VolumeId, + ) -> VolumeServiceResult { + let record = self.require_visible(owner_id, volume_id).await?; + let token = self + .token_resolver + .resolve(TokenScope::Volume, record.token()) + .await?; + Ok(VolumeConnection { record, token }) + } + + pub async fn list(&self, owner_id: &str) -> VolumeServiceResult> { + Ok(self.repository.list(owner_id).await?) + } + + pub async fn delete(&self, owner_id: &str, volume_id: &VolumeId) -> VolumeServiceResult<()> { + let mut record = self.require_visible(owner_id, volume_id).await?; + record.begin_delete()?; + self.replace(VolumeState::Active, record.clone()).await?; + + match self.runtime.remove(record.runtime_name()).await { + Ok(RuntimeVolumeRemoveResult::Removed | RuntimeVolumeRemoveResult::NotFound) => {} + Err(RuntimeVolumeError::InUse) => { + self.restore_active(record).await?; + return Err(VolumeServiceError::Conflict); + } + Err(error) => { + self.restore_active(record).await?; + return Err(error.into()); + } + } + self.delete_record(record.volume_id(), VolumeState::Deleting) + .await + } + + pub async fn authorize( + &self, + volume_id: &VolumeId, + presented: &SecretToken, + ) -> VolumeServiceResult { + let record = self + .repository + .get(volume_id) + .await? + .filter(|record| record.state() == VolumeState::Active) + .ok_or(VolumeServiceError::NotFound)?; + if !self + .token_verifier + .verify(TokenScope::Volume, presented, record.token()) + .await? + { + return Err(VolumeServiceError::Forbidden); + } + let runtime = self + .runtime + .get(record.runtime_name()) + .await? + .ok_or_else(|| { + RuntimeVolumeError::Unavailable(format!( + "runtime volume '{}' is missing", + record.runtime_name() + )) + })?; + Ok(AuthorizedVolume { + record, + root: runtime.mount_point, + }) + } + + pub async fn reconcile_startup(&self) -> VolumeServiceResult { + let mut report = VolumeReconciliationReport::default(); + for state in [VolumeState::Creating, VolumeState::Deleting] { + for record in self.repository.list_in_state(state).await? { + report.examined += 1; + let result = match state { + VolumeState::Creating => self.reconcile_create(record).await, + VolumeState::Deleting => self.reconcile_delete(record).await, + VolumeState::Active => unreachable!(), + }; + match result { + Ok(ReconciliationOutcome::Completed) => report.completed += 1, + Ok(ReconciliationOutcome::Deferred) => report.deferred += 1, + Err(error) => report.failures.push(error.to_string()), + } + } + } + Ok(report) + } + + async fn reconcile_create( + &self, + mut record: VolumeRecord, + ) -> VolumeServiceResult { + let runtime = self.runtime.materialize(record.runtime_name()).await?; + self.filesystem + .initialize_root(&runtime.mount_point) + .await?; + record.mark_active()?; + self.replace(VolumeState::Creating, record).await?; + Ok(ReconciliationOutcome::Completed) + } + + async fn reconcile_delete( + &self, + mut record: VolumeRecord, + ) -> VolumeServiceResult { + match self.runtime.remove(record.runtime_name()).await { + Ok(RuntimeVolumeRemoveResult::Removed | RuntimeVolumeRemoveResult::NotFound) => { + self.delete_record(record.volume_id(), VolumeState::Deleting) + .await?; + Ok(ReconciliationOutcome::Completed) + } + Err(RuntimeVolumeError::InUse) => { + record.abort_delete()?; + self.replace(VolumeState::Deleting, record).await?; + Ok(ReconciliationOutcome::Deferred) + } + Err(error) => Err(error.into()), + } + } + + async fn require_visible( + &self, + owner_id: &str, + volume_id: &VolumeId, + ) -> VolumeServiceResult { + self.repository + .get(volume_id) + .await? + .filter(|record| record.owner_id() == owner_id && record.state() == VolumeState::Active) + .ok_or(VolumeServiceError::NotFound) + } + + async fn restore_active(&self, mut record: VolumeRecord) -> VolumeServiceResult<()> { + record.abort_delete()?; + self.replace(VolumeState::Deleting, record).await + } + + async fn replace( + &self, + expected: VolumeState, + record: VolumeRecord, + ) -> VolumeServiceResult<()> { + match self.repository.replace(expected, record).await? { + VolumeReplaceResult::Updated => Ok(()), + VolumeReplaceResult::NotFound => Err(VolumeServiceError::NotFound), + VolumeReplaceResult::Conflict => Err(VolumeServiceError::Conflict), + } + } + + async fn delete_record( + &self, + volume_id: &VolumeId, + expected: VolumeState, + ) -> VolumeServiceResult<()> { + match self.repository.delete(volume_id, expected).await? { + VolumeReplaceResult::Updated => Ok(()), + VolumeReplaceResult::NotFound => Err(VolumeServiceError::NotFound), + VolumeReplaceResult::Conflict => Err(VolumeServiceError::Conflict), + } + } +} + +#[async_trait] +impl VolumeMountResolver for VolumeService { + async fn resolve_mounts( + &self, + owner_id: &str, + mounts: &[VolumeMount], + ) -> VolumeServiceResult> { + validate_mounts(mounts)?; + let mut resolved = Vec::with_capacity(mounts.len()); + for mount in mounts { + let record = self + .repository + .get_by_owner_name(owner_id, &mount.name) + .await? + .filter(|record| record.state() == VolumeState::Active) + .ok_or(VolumeServiceError::NotFound)?; + let runtime = self + .runtime + .get(record.runtime_name()) + .await? + .ok_or_else(|| { + RuntimeVolumeError::Unavailable(format!( + "runtime volume '{}' is missing", + record.runtime_name() + )) + })?; + resolved.push(ResolvedVolumeMount { + public: mount.clone(), + runtime_name: record.runtime_name().to_string(), + host_path: runtime.mount_point, + }); + } + Ok(resolved) + } +} + +enum ReconciliationOutcome { + Completed, + Deferred, +} diff --git a/src/compat/src/volume/sqlite.rs b/src/compat/src/volume/sqlite.rs new file mode 100644 index 00000000..c6f09a75 --- /dev/null +++ b/src/compat/src/volume/sqlite.rs @@ -0,0 +1,259 @@ +use async_trait::async_trait; +use tokio_rusqlite::rusqlite::{params, ErrorCode, OptionalExtension}; +use tokio_rusqlite::Connection; + +use super::{ + VolumeId, VolumeRecord, VolumeReplaceResult, VolumeRepository, VolumeRepositoryError, + VolumeRepositoryResult, VolumeState, +}; + +#[derive(Clone)] +pub struct SqliteVolumeRepository { + connection: Connection, +} + +impl SqliteVolumeRepository { + pub(crate) fn new(connection: Connection) -> Self { + Self { connection } + } + + async fn call(&self, function: F) -> VolumeRepositoryResult + where + F: FnOnce(&mut tokio_rusqlite::rusqlite::Connection) -> VolumeRepositoryResult + + Send + + 'static, + R: Send + 'static, + { + self.connection + .call(function) + .await + .map_err(map_async_error) + } +} + +impl std::fmt::Debug for SqliteVolumeRepository { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SqliteVolumeRepository") + .finish_non_exhaustive() + } +} + +#[async_trait] +impl VolumeRepository for SqliteVolumeRepository { + async fn insert(&self, record: VolumeRecord) -> VolumeRepositoryResult<()> { + validate_record(&record)?; + let volume_id = record.volume_id().clone(); + let record_json = serialize_record(&record)?; + self.call(move |connection| { + match connection.execute( + "INSERT INTO volume_records(volume_id, record_json) VALUES (?1, ?2)", + params![volume_id.as_str(), record_json], + ) { + Ok(_) => Ok(()), + Err(error) + if error + .sqlite_error_code() + .is_some_and(|code| code == ErrorCode::ConstraintViolation) => + { + Err(VolumeRepositoryError::Duplicate) + } + Err(error) => Err(unavailable("insert SQLite volume record", error)), + } + }) + .await + } + + async fn get(&self, volume_id: &VolumeId) -> VolumeRepositoryResult> { + let volume_id = volume_id.clone(); + let record = self + .call(move |connection| { + connection + .query_row( + "SELECT record_json FROM volume_records WHERE volume_id = ?1", + [volume_id.as_str()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| unavailable("read SQLite volume record", error)) + }) + .await?; + record + .map(|serialized| deserialize_record(&serialized)) + .transpose() + } + + async fn get_by_owner_name( + &self, + owner_id: &str, + name: &str, + ) -> VolumeRepositoryResult> { + let owner_id = owner_id.to_string(); + let name = name.to_string(); + let record = self + .call(move |connection| { + connection + .query_row( + "SELECT record_json FROM volume_records \ + WHERE owner_id = ?1 AND name = ?2", + params![owner_id, name], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| unavailable("read SQLite volume by owner and name", error)) + }) + .await?; + record + .map(|serialized| deserialize_record(&serialized)) + .transpose() + } + + async fn list(&self, owner_id: &str) -> VolumeRepositoryResult> { + let owner_id = owner_id.to_string(); + let records = self + .call(move |connection| { + let mut statement = connection + .prepare( + "SELECT record_json FROM volume_records \ + WHERE owner_id = ?1 AND state = 'active' \ + ORDER BY julianday(created_at), volume_id", + ) + .map_err(|error| unavailable("prepare SQLite volume list", error))?; + let records = statement + .query_map([owner_id], |row| row.get::<_, String>(0)) + .map_err(|error| unavailable("query SQLite volume list", error))? + .collect::, _>>() + .map_err(|error| unavailable("read SQLite volume list", error))?; + Ok(records) + }) + .await?; + records + .iter() + .map(|record| deserialize_record(record)) + .collect() + } + + async fn list_in_state(&self, state: VolumeState) -> VolumeRepositoryResult> { + let state = state.as_str().to_string(); + let records = self + .call(move |connection| { + let mut statement = connection + .prepare( + "SELECT record_json FROM volume_records WHERE state = ?1 \ + ORDER BY julianday(created_at), volume_id", + ) + .map_err(|error| unavailable("prepare SQLite volume reconciliation", error))?; + let records = statement + .query_map([state], |row| row.get::<_, String>(0)) + .map_err(|error| unavailable("query SQLite volume reconciliation", error))? + .collect::, _>>() + .map_err(|error| unavailable("read SQLite volume reconciliation", error))?; + Ok(records) + }) + .await?; + records + .iter() + .map(|record| deserialize_record(record)) + .collect() + } + + async fn replace( + &self, + expected: VolumeState, + replacement: VolumeRecord, + ) -> VolumeRepositoryResult { + validate_record(&replacement)?; + let volume_id = replacement.volume_id().clone(); + let expected = expected.as_str().to_string(); + let record_json = serialize_record(&replacement)?; + self.call(move |connection| { + let updated = connection + .execute( + "UPDATE volume_records SET record_json = ?1 \ + WHERE volume_id = ?2 AND state = ?3", + params![record_json, volume_id.as_str(), expected], + ) + .map_err(|error| unavailable("replace SQLite volume record", error))?; + if updated == 1 { + return Ok(VolumeReplaceResult::Updated); + } + existence_result(connection, &volume_id) + }) + .await + } + + async fn delete( + &self, + volume_id: &VolumeId, + expected: VolumeState, + ) -> VolumeRepositoryResult { + let volume_id = volume_id.clone(); + let expected = expected.as_str().to_string(); + self.call(move |connection| { + let deleted = connection + .execute( + "DELETE FROM volume_records WHERE volume_id = ?1 AND state = ?2", + params![volume_id.as_str(), expected], + ) + .map_err(|error| unavailable("delete SQLite volume record", error))?; + if deleted == 1 { + return Ok(VolumeReplaceResult::Updated); + } + existence_result(connection, &volume_id) + }) + .await + } +} + +fn existence_result( + connection: &tokio_rusqlite::rusqlite::Connection, + volume_id: &VolumeId, +) -> VolumeRepositoryResult { + let exists = connection + .query_row( + "SELECT 1 FROM volume_records WHERE volume_id = ?1", + [volume_id.as_str()], + |_| Ok(()), + ) + .optional() + .map_err(|error| unavailable("inspect SQLite volume conflict", error))?; + Ok(if exists.is_some() { + VolumeReplaceResult::Conflict + } else { + VolumeReplaceResult::NotFound + }) +} + +fn validate_record(record: &VolumeRecord) -> VolumeRepositoryResult<()> { + record + .validate() + .map_err(|error| VolumeRepositoryError::Corrupt(error.to_string())) +} + +fn serialize_record(record: &VolumeRecord) -> VolumeRepositoryResult { + serde_json::to_string(record).map_err(|error| { + VolumeRepositoryError::Corrupt(format!("serialize SQLite volume record: {error}")) + }) +} + +fn deserialize_record(record: &str) -> VolumeRepositoryResult { + let record: VolumeRecord = serde_json::from_str(record).map_err(|error| { + VolumeRepositoryError::Corrupt(format!("deserialize SQLite volume record: {error}")) + })?; + validate_record(&record)?; + Ok(record) +} + +fn unavailable(context: &str, error: impl std::fmt::Display) -> VolumeRepositoryError { + VolumeRepositoryError::Unavailable(format!("{context}: {error}")) +} + +fn map_async_error(error: tokio_rusqlite::Error) -> VolumeRepositoryError { + match error { + tokio_rusqlite::Error::Error(error) => error, + tokio_rusqlite::Error::ConnectionClosed => { + VolumeRepositoryError::Unavailable("SQLite repository connection closed".to_string()) + } + _ => VolumeRepositoryError::Unavailable(format!("SQLite repository failed: {error}")), + } +} diff --git a/src/compat/src/volume/tests/filesystem.rs b/src/compat/src/volume/tests/filesystem.rs new file mode 100644 index 00000000..8c3c241c --- /dev/null +++ b/src/compat/src/volume/tests/filesystem.rs @@ -0,0 +1,199 @@ +#![cfg(unix)] + +use std::os::unix::fs::symlink; +use std::sync::Arc; + +use tempfile::tempdir; +use tokio::io::AsyncReadExt; + +use super::super::*; + +async fn filesystem() -> (tempfile::TempDir, std::path::PathBuf, VolumeFilesystem) { + let directory = tempdir().unwrap(); + let root = directory.path().join("volume"); + std::fs::create_dir(&root).unwrap(); + let filesystem = VolumeFilesystem::new(Arc::new(IdentityVolumeIdMapper::current())); + filesystem.initialize_root(&root).await.unwrap(); + (directory, root, filesystem) +} + +#[tokio::test] +async fn streams_atomic_writes_and_returns_stable_depth_limited_metadata() { + let (_directory, root, filesystem) = filesystem().await; + filesystem + .make_dir(&root, "/nested/deep", VolumeMetadataUpdate::default(), true) + .await + .unwrap(); + + let mut initial = filesystem + .begin_write( + &root, + "/nested/deep/data.txt", + VolumeMetadataUpdate::default(), + true, + ) + .await + .unwrap(); + initial.write_all(b"old").await.unwrap(); + initial.finish().await.unwrap(); + + let mut replacement = filesystem + .begin_write( + &root, + "/nested/deep/data.txt", + VolumeMetadataUpdate::default(), + true, + ) + .await + .unwrap(); + replacement.write_all(b"new-").await.unwrap(); + replacement.write_all(b"value").await.unwrap(); + + let visible_during_upload = filesystem.list(&root, "/nested/deep", 1).await.unwrap(); + assert_eq!( + visible_during_upload + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(), + vec!["/nested/deep/data.txt"] + ); + assert_eq!( + read(&filesystem, &root, "/nested/deep/data.txt").await, + b"old" + ); + + let entry = replacement.finish().await.unwrap(); + assert_eq!(entry.size, 9); + assert_eq!(entry.mode, 0o644); + assert_eq!(entry.uid, 0); + assert_eq!(entry.gid, 0); + assert_eq!( + read(&filesystem, &root, "/nested/deep/data.txt").await, + b"new-value" + ); + + assert_eq!( + paths(filesystem.list(&root, "/", 1).await.unwrap()), + vec!["/nested"] + ); + assert_eq!( + paths(filesystem.list(&root, "/", 2).await.unwrap()), + vec!["/nested", "/nested/deep"] + ); + assert_eq!( + paths(filesystem.list(&root, "/", 3).await.unwrap()), + vec!["/nested", "/nested/deep", "/nested/deep/data.txt"] + ); + + let updated = filesystem + .update_metadata( + &root, + "/nested/deep/data.txt", + VolumeMetadataUpdate { + mode: Some(0o600), + ..VolumeMetadataUpdate::default() + }, + ) + .await + .unwrap(); + assert_eq!(updated.mode, 0o600); + assert!(matches!( + filesystem + .begin_write( + &root, + "/nested/deep/data.txt", + VolumeMetadataUpdate::default(), + false, + ) + .await, + Err(VolumeContentError::Conflict) + )); + + let mut abandoned = filesystem + .begin_write( + &root, + "/nested/deep/abandoned.txt", + VolumeMetadataUpdate::default(), + true, + ) + .await + .unwrap(); + abandoned.write_all(b"partial").await.unwrap(); + drop(abandoned); + assert!(matches!( + filesystem.stat(&root, "/nested/deep/abandoned.txt").await, + Err(VolumeContentError::NotFound) + )); + + filesystem.remove(&root, "/nested").await.unwrap(); + assert!(filesystem.list(&root, "/", 3).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn rejects_traversal_reserved_paths_symlink_escape_and_root_mutation() { + let (_directory, root, filesystem) = filesystem().await; + let outside = tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap(); + symlink(outside.path(), root.join("escape")).unwrap(); + + let link = filesystem.stat(&root, "/escape").await.unwrap(); + assert_eq!(link.entry_type, VolumeEntryType::Symlink); + assert!(matches!( + filesystem.stat(&root, "/escape/secret.txt").await, + Err(VolumeContentError::InvalidPath(_)) + )); + assert!(matches!( + filesystem + .begin_write( + &root, + "/escape/new.txt", + VolumeMetadataUpdate::default(), + true, + ) + .await, + Err(VolumeContentError::InvalidPath(_)) + )); + + for path in [ + "relative", + "/../escape", + "/nested/../escape", + "/.a3s-upload-user", + ] { + assert!(matches!( + filesystem.stat(&root, path).await, + Err(VolumeContentError::InvalidPath(_)) + )); + } + assert!(matches!( + filesystem.remove(&root, "/").await, + Err(VolumeContentError::InvalidPath(_)) + )); + assert!(matches!( + filesystem + .make_dir(&root, "/", VolumeMetadataUpdate::default(), true) + .await, + Err(VolumeContentError::InvalidPath(_)) + )); + assert!(matches!( + filesystem.list(&root, "/", MAX_DIRECTORY_DEPTH + 1).await, + Err(VolumeContentError::InvalidPath(_)) + )); + + filesystem.remove(&root, "/escape").await.unwrap(); + assert_eq!( + std::fs::read(outside.path().join("secret.txt")).unwrap(), + b"secret" + ); +} + +async fn read(filesystem: &VolumeFilesystem, root: &std::path::Path, path: &str) -> Vec { + let mut file = filesystem.open_file(root, path).await.unwrap(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).await.unwrap(); + bytes +} + +fn paths(entries: Vec) -> Vec { + entries.into_iter().map(|entry| entry.path).collect() +} diff --git a/src/compat/src/volume/tests/mod.rs b/src/compat/src/volume/tests/mod.rs new file mode 100644 index 00000000..82332fea --- /dev/null +++ b/src/compat/src/volume/tests/mod.rs @@ -0,0 +1,5 @@ +mod filesystem; +mod mount; +mod repository; +mod service; +pub(crate) mod support; diff --git a/src/compat/src/volume/tests/mount.rs b/src/compat/src/volume/tests/mount.rs new file mode 100644 index 00000000..1776724e --- /dev/null +++ b/src/compat/src/volume/tests/mount.rs @@ -0,0 +1,14 @@ +use super::super::*; + +#[test] +fn mount_validation_rejects_ambiguous_or_overlapping_destinations() { + for path in ["", "/", "relative", "/a/../b", "/a/./b", "/a:b"] { + assert!(VolumeMount::new("data", path).is_err(), "accepted {path:?}"); + } + assert!(VolumeMount::new("../data", "/mnt/data").is_err()); + assert!(validate_mounts(&[ + VolumeMount::new("one", "/mnt/data").unwrap(), + VolumeMount::new("two", "/mnt/data").unwrap(), + ]) + .is_err()); +} diff --git a/src/compat/src/volume/tests/repository.rs b/src/compat/src/volume/tests/repository.rs new file mode 100644 index 00000000..c8943f55 --- /dev/null +++ b/src/compat/src/volume/tests/repository.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; + +use tempfile::tempdir; + +use crate::control::SqliteSandboxRepository; + +use super::super::*; +use super::support::record; + +#[tokio::test] +async fn memory_repository_enforces_the_volume_contract() { + exercise_repository(Arc::new(MemoryVolumeRepository::default())).await; +} + +#[tokio::test] +async fn sqlite_repository_enforces_the_volume_contract() { + let directory = tempdir().unwrap(); + let control = SqliteSandboxRepository::open(directory.path().join("control.db")) + .await + .unwrap(); + exercise_repository(Arc::new(SqliteVolumeRepository::new(control.connection()))).await; +} + +async fn exercise_repository(repository: Arc) { + let owner_a = record( + "volume-a", + "owner-a", + "data", + "runtime-a", + VolumeState::Active, + 2, + ); + let owner_b = record( + "volume-b", + "owner-b", + "data", + "runtime-b", + VolumeState::Active, + 1, + ); + let creating = record( + "volume-c", + "owner-a", + "cache", + "runtime-c", + VolumeState::Creating, + 0, + ); + repository.insert(owner_a.clone()).await.unwrap(); + repository.insert(owner_b.clone()).await.unwrap(); + repository.insert(creating.clone()).await.unwrap(); + + let listed = repository.list("owner-a").await.unwrap(); + assert_eq!(listed, vec![owner_a.clone()]); + assert_eq!( + repository + .get_by_owner_name("owner-b", "data") + .await + .unwrap(), + Some(owner_b) + ); + assert_eq!( + repository + .list_in_state(VolumeState::Creating) + .await + .unwrap(), + vec![creating.clone()] + ); + + let duplicate_name = record( + "volume-d", + "owner-a", + "data", + "runtime-d", + VolumeState::Active, + 3, + ); + assert!(matches!( + repository.insert(duplicate_name).await, + Err(VolumeRepositoryError::Duplicate) + )); + assert!(matches!( + repository.insert(owner_a).await, + Err(VolumeRepositoryError::Duplicate) + )); + + assert_eq!( + repository + .replace(VolumeState::Active, creating.clone()) + .await + .unwrap(), + VolumeReplaceResult::Conflict + ); + let mut active = creating; + active.mark_active().unwrap(); + assert_eq!( + repository + .replace(VolumeState::Creating, active.clone()) + .await + .unwrap(), + VolumeReplaceResult::Updated + ); + assert_eq!( + repository + .delete(active.volume_id(), VolumeState::Creating) + .await + .unwrap(), + VolumeReplaceResult::Conflict + ); + assert_eq!( + repository + .delete(active.volume_id(), VolumeState::Active) + .await + .unwrap(), + VolumeReplaceResult::Updated + ); + assert!(repository.get(active.volume_id()).await.unwrap().is_none()); +} diff --git a/src/compat/src/volume/tests/service.rs b/src/compat/src/volume/tests/service.rs new file mode 100644 index 00000000..967cf465 --- /dev/null +++ b/src/compat/src/volume/tests/service.rs @@ -0,0 +1,177 @@ +use crate::control::SecretToken; + +use super::super::*; +use super::support::{record, ServiceHarness}; + +#[tokio::test] +async fn service_scopes_names_owners_and_tokens_without_leaking_runtime_names() { + let harness = ServiceHarness::new(); + let owner_a = harness.service.create("owner-a", "data").await.unwrap(); + let owner_b = harness.service.create("owner-b", "data").await.unwrap(); + + assert_ne!(owner_a.record.volume_id(), owner_b.record.volume_id()); + assert_ne!(owner_a.record.runtime_name(), owner_b.record.runtime_name()); + assert_ne!(owner_a.token.expose_secret(), owner_b.token.expose_secret()); + assert!(matches!( + harness + .service + .get("owner-b", owner_a.record.volume_id()) + .await, + Err(VolumeServiceError::NotFound) + )); + assert_eq!(harness.service.list("owner-a").await.unwrap().len(), 1); + + let authorized = harness + .service + .authorize(owner_a.record.volume_id(), &owner_a.token) + .await + .unwrap(); + assert_eq!(authorized.record.owner_id(), "owner-a"); + assert!(authorized.root.ends_with(owner_a.record.runtime_name())); + assert!(matches!( + harness + .service + .authorize(owner_a.record.volume_id(), &owner_b.token) + .await, + Err(VolumeServiceError::Forbidden) + )); + assert!(matches!( + harness + .service + .authorize( + owner_a.record.volume_id(), + &SecretToken::new("invalid-volume-token").unwrap() + ) + .await, + Err(VolumeServiceError::Forbidden) + )); + + let mounts = harness + .service + .resolve_mounts("owner-a", &[VolumeMount::new("data", "/mnt/data").unwrap()]) + .await + .unwrap(); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].public.name, "data"); + assert_eq!(mounts[0].runtime_name, owner_a.record.runtime_name()); + assert_eq!( + mounts[0].runtime_spec(), + format!("{}:/mnt/data:rw", authorized.root.display()) + ); +} + +#[tokio::test] +async fn deletion_conflict_restores_visibility_and_success_removes_content() { + let harness = ServiceHarness::new(); + let created = harness.service.create("owner-a", "data").await.unwrap(); + let id = created.record.volume_id().clone(); + let runtime_name = created.record.runtime_name().to_string(); + let root = harness + .service + .authorize(&id, &created.token) + .await + .unwrap() + .root; + std::fs::write(root.join("value.txt"), b"value").unwrap(); + + harness.runtime.set_in_use(&runtime_name, true); + assert!(matches!( + harness.service.delete("owner-a", &id).await, + Err(VolumeServiceError::Conflict) + )); + assert_eq!( + harness + .service + .get("owner-a", &id) + .await + .unwrap() + .record + .state(), + VolumeState::Active + ); + assert!(root.join("value.txt").exists()); + + harness.runtime.set_in_use(&runtime_name, false); + harness.service.delete("owner-a", &id).await.unwrap(); + assert!(!root.exists()); + assert!(matches!( + harness.service.get("owner-a", &id).await, + Err(VolumeServiceError::NotFound) + )); +} + +#[tokio::test] +async fn startup_reconciliation_completes_creates_and_safe_deletes() { + let harness = ServiceHarness::new(); + let creating = record( + "creating-volume", + "owner-a", + "creating", + "runtime-creating", + VolumeState::Creating, + 0, + ); + let deleting = record( + "deleting-volume", + "owner-a", + "deleting", + "runtime-deleting", + VolumeState::Deleting, + 1, + ); + let busy = record( + "busy-volume", + "owner-a", + "busy", + "runtime-busy", + VolumeState::Deleting, + 2, + ); + harness.repository.insert(creating.clone()).await.unwrap(); + harness.repository.insert(deleting.clone()).await.unwrap(); + harness.repository.insert(busy.clone()).await.unwrap(); + harness + .runtime + .materialize(deleting.runtime_name()) + .await + .unwrap(); + harness + .runtime + .materialize(busy.runtime_name()) + .await + .unwrap(); + harness.runtime.set_in_use(busy.runtime_name(), true); + + let report = harness.service.reconcile_startup().await.unwrap(); + + assert_eq!(report.examined, 3); + assert_eq!(report.completed, 2); + assert_eq!(report.deferred, 1); + assert!(report.failures.is_empty()); + assert_eq!( + harness + .repository + .get(creating.volume_id()) + .await + .unwrap() + .unwrap() + .state(), + VolumeState::Active + ); + assert!(harness + .repository + .get(deleting.volume_id()) + .await + .unwrap() + .is_none()); + assert_eq!( + harness + .repository + .get(busy.volume_id()) + .await + .unwrap() + .unwrap() + .state(), + VolumeState::Active + ); +} diff --git a/src/compat/src/volume/tests/support.rs b/src/compat/src/volume/tests/support.rs new file mode 100644 index 00000000..c91c3796 --- /dev/null +++ b/src/compat/src/volume/tests/support.rs @@ -0,0 +1,221 @@ +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::{DateTime, Duration, TimeZone, Utc}; +use sha2::{Digest, Sha256}; +use tempfile::{tempdir, TempDir}; + +use crate::control::{ + Clock, IssuedToken, SecretToken, StoredToken, TokenIssuer, TokenIssuerError, TokenIssuerResult, + TokenResolver, TokenScope, TokenVerifier, +}; + +use super::super::*; + +pub fn test_time(second: i64) -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 16, 12, 0, 0) + .single() + .unwrap() + + Duration::seconds(second) +} + +pub fn stored_token(secret: &str) -> StoredToken { + let ciphertext = secret.as_bytes().to_vec(); + StoredToken::new(1, ciphertext.clone(), Sha256::digest(ciphertext).to_vec()).unwrap() +} + +pub fn record( + id: &str, + owner: &str, + name: &str, + runtime_name: &str, + state: VolumeState, + second: i64, +) -> VolumeRecord { + let mut record = VolumeRecord::creating( + VolumeId::new(id).unwrap(), + owner, + name, + runtime_name, + stored_token(&format!("token-{id}")), + test_time(second), + ) + .unwrap(); + if matches!(state, VolumeState::Active | VolumeState::Deleting) { + record.mark_active().unwrap(); + } + if state == VolumeState::Deleting { + record.begin_delete().unwrap(); + } + record +} + +#[derive(Debug)] +pub struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> DateTime { + test_time(0) + } +} + +#[derive(Debug, Default)] +pub struct TestTokens { + sequence: AtomicU64, +} + +#[async_trait] +impl TokenIssuer for TestTokens { + async fn issue(&self, scope: TokenScope) -> TokenIssuerResult { + if scope != TokenScope::Volume { + return Err(TokenIssuerError::InvalidMaterial); + } + let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; + let value = format!("volume-token-{sequence}"); + Ok(IssuedToken { + secret: SecretToken::new(&value)?, + stored: stored_token(&value), + }) + } +} + +#[async_trait] +impl TokenResolver for TestTokens { + async fn resolve( + &self, + scope: TokenScope, + stored: &StoredToken, + ) -> TokenIssuerResult { + if scope != TokenScope::Volume { + return Err(TokenIssuerError::InvalidMaterial); + } + let value = std::str::from_utf8(stored.ciphertext()) + .map_err(|_| TokenIssuerError::InvalidMaterial)?; + SecretToken::new(value) + } +} + +#[async_trait] +impl TokenVerifier for TestTokens { + async fn verify( + &self, + scope: TokenScope, + presented: &SecretToken, + stored: &StoredToken, + ) -> TokenIssuerResult { + if scope != TokenScope::Volume { + return Ok(false); + } + let digest = Sha256::digest(presented.expose_secret().as_bytes()); + Ok(digest[..] == stored.digest()[..]) + } +} + +#[derive(Debug)] +pub struct TestRuntime { + root: std::path::PathBuf, + volumes: Mutex>, +} + +impl TestRuntime { + fn new(root: std::path::PathBuf) -> Self { + Self { + root, + volumes: Mutex::new(BTreeMap::new()), + } + } + + pub fn set_in_use(&self, name: &str, in_use: bool) { + let mut volumes = self.volumes.lock().unwrap(); + let volume = volumes.get_mut(name).expect("runtime volume must exist"); + volume.in_use_by = if in_use { + vec!["sandbox-1".to_string()] + } else { + Vec::new() + }; + } +} + +#[async_trait] +impl RuntimeVolumeStore for TestRuntime { + async fn materialize(&self, name: &str) -> RuntimeVolumeResult { + let mut volumes = self.volumes.lock().unwrap(); + if let Some(volume) = volumes.get(name) { + return Ok(volume.clone()); + } + let mount_point = self.root.join(name); + std::fs::create_dir_all(&mount_point).map_err(|error| { + RuntimeVolumeError::Unavailable(format!("create test volume: {error}")) + })?; + let volume = RuntimeVolume { + name: name.to_string(), + mount_point, + in_use_by: Vec::new(), + }; + volumes.insert(name.to_string(), volume.clone()); + Ok(volume) + } + + async fn get(&self, name: &str) -> RuntimeVolumeResult> { + Ok(self.volumes.lock().unwrap().get(name).cloned()) + } + + async fn remove(&self, name: &str) -> RuntimeVolumeResult { + let removed = { + let mut volumes = self.volumes.lock().unwrap(); + if volumes + .get(name) + .is_some_and(|volume| !volume.in_use_by.is_empty()) + { + return Err(RuntimeVolumeError::InUse); + } + volumes.remove(name) + }; + let path = self.root.join(name); + if path.exists() { + std::fs::remove_dir_all(&path).map_err(|error| { + RuntimeVolumeError::Unavailable(format!("remove test volume: {error}")) + })?; + } + Ok(if removed.is_some() { + RuntimeVolumeRemoveResult::Removed + } else { + RuntimeVolumeRemoveResult::NotFound + }) + } +} + +pub struct ServiceHarness { + pub service: VolumeService, + pub repository: Arc, + pub runtime: Arc, + _directory: TempDir, +} + +impl ServiceHarness { + pub fn new() -> Self { + let directory = tempdir().unwrap(); + let repository = Arc::new(MemoryVolumeRepository::default()); + let runtime = Arc::new(TestRuntime::new(directory.path().join("volumes"))); + let tokens = Arc::new(TestTokens::default()); + let service = VolumeService::new(VolumeServiceDependencies { + repository: repository.clone(), + runtime: runtime.clone(), + clock: Arc::new(FixedClock), + token_issuer: tokens.clone(), + token_resolver: tokens.clone(), + token_verifier: tokens, + filesystem: Arc::new(VolumeFilesystem::new(Arc::new( + IdentityVolumeIdMapper::current(), + ))), + }); + Self { + service, + repository, + runtime, + _directory: directory, + } + } +} diff --git a/src/core/Cargo.toml b/src/core/Cargo.toml index 43e71298..2a6d342e 100644 --- a/src/core/Cargo.toml +++ b/src/core/Cargo.toml @@ -8,6 +8,7 @@ repository.workspace = true description = "Core types, config, and error handling for A3S Box MicroVM runtime" [dependencies] +a3s-acl = { workspace = true } a3s-transport = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } @@ -19,6 +20,7 @@ tracing = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } flate2 = "1.0" +base64 = { workspace = true } [lib] name = "a3s_box_core" diff --git a/src/core/src/compose.rs b/src/core/src/compose.rs index d1a1a7b9..435f4291 100644 --- a/src/core/src/compose.rs +++ b/src/core/src/compose.rs @@ -1,11 +1,17 @@ //! Compose file types for multi-container orchestration. //! -//! Defines a docker-compose-compatible YAML schema for declaring +//! Defines A3S ACL and Docker Compose-compatible YAML schemas for declaring //! multi-service workloads. Each service maps to a single MicroVM. use serde::{Deserialize, Serialize}; use std::collections::HashMap; +mod acl; +mod interpolation; + +pub use acl::ComposeAclError; +pub use interpolation::{interpolate_compose_yaml, ComposeInterpolationError}; + /// Top-level compose file configuration. /// /// Compatible with a subset of docker-compose v3 syntax: @@ -354,6 +360,21 @@ impl DnsConfig { } impl ComposeConfig { + /// Parse an A3S Compose ACL document using the process environment for + /// `env("NAME")` calls. + pub fn from_acl_str(source: &str) -> Result { + let environment = std::env::vars().collect(); + Self::from_acl_str_with_environment(source, &environment) + } + + /// Parse an A3S Compose ACL document using an explicit environment. + pub fn from_acl_str_with_environment( + source: &str, + environment: &HashMap, + ) -> Result { + acl::parse_compose_acl(source, environment) + } + /// Parse a compose config from YAML bytes. pub fn from_yaml(yaml: &[u8]) -> Result { serde_yaml::from_slice(yaml) diff --git a/src/core/src/compose/acl.rs b/src/core/src/compose/acl.rs new file mode 100644 index 00000000..71862dbb --- /dev/null +++ b/src/core/src/compose/acl.rs @@ -0,0 +1,917 @@ +//! Closed-schema A3S ACL parser for Compose applications. + +use std::collections::HashMap; + +use a3s_acl::{Block, Document, Lexer, Token, Value}; +use thiserror::Error; + +use super::interpolation::interpolate_compose_scalar; +use super::{ + ComposeConfig, DependsOn, DependsOnCondition, DnsConfig, EnvVars, HealthcheckConfig, Labels, + NetworkDeclaration, ServiceConfig, ServiceNetworkConfig, ServiceNetworks, StringOrList, + VolumeDeclaration, +}; + +const SERVICE_ATTRIBUTES: &[&str] = &[ + "image", + "entrypoint", + "command", + "environment", + "env_file", + "ports", + "volumes", + "depends_on", + "networks", + "cpus", + "mem_limit", + "restart", + "dns", + "tmpfs", + "cap_add", + "cap_drop", + "privileged", + "labels", + "healthcheck", + "working_dir", + "hostname", + "extra_hosts", +]; + +const HEALTHCHECK_ATTRIBUTES: &[&str] = &[ + "test", + "disable", + "interval", + "timeout", + "retries", + "start_period", +]; + +/// An invalid A3S Compose ACL document. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("{message}")] +pub struct ComposeAclError { + message: String, +} + +impl ComposeAclError { + fn invalid(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +pub(super) fn parse_compose_acl( + source: &str, + environment: &HashMap, +) -> Result { + validate_balanced_braces(source)?; + let mut document = a3s_acl::parse(source) + .map_err(|error| ComposeAclError::invalid(format!("invalid A3S ACL: {error}")))?; + interpolate_document_values(&mut document, environment)?; + resolve_environment_calls(&mut document, environment)?; + convert_document(document) +} + +fn validate_balanced_braces(source: &str) -> Result<(), ComposeAclError> { + let mut depth = 0usize; + for token in Lexer::new(source).tokenize() { + match token.token { + Token::LeftBrace => depth += 1, + Token::RightBrace if depth == 0 => { + return Err(ComposeAclError::invalid( + "compose ACL contains an unmatched closing brace", + )); + } + Token::RightBrace => depth -= 1, + _ => {} + } + } + if depth != 0 { + return Err(ComposeAclError::invalid( + "compose ACL contains an unclosed block or object", + )); + } + Ok(()) +} + +fn interpolate_document_values( + document: &mut Document, + environment: &HashMap, +) -> Result<(), ComposeAclError> { + for block in &mut document.blocks { + interpolate_block_values(block, environment)?; + } + Ok(()) +} + +fn interpolate_block_values( + block: &mut Block, + environment: &HashMap, +) -> Result<(), ComposeAclError> { + for value in block.attributes.values_mut() { + interpolate_value(value, environment)?; + } + for nested in &mut block.blocks { + interpolate_block_values(nested, environment)?; + } + Ok(()) +} + +fn interpolate_value( + value: &mut Value, + environment: &HashMap, +) -> Result<(), ComposeAclError> { + match value { + Value::String(text) => { + *text = interpolate_compose_scalar(text, environment).map_err(|error| { + ComposeAclError::invalid(format!("invalid Compose interpolation: {error}")) + })?; + } + Value::List(values) | Value::Call(_, values) => { + for value in values { + interpolate_value(value, environment)?; + } + } + Value::Object(entries) => { + for (_, value) in entries { + interpolate_value(value, environment)?; + } + } + Value::Number(_) | Value::Bool(_) | Value::Null => {} + } + Ok(()) +} + +fn resolve_environment_calls( + document: &mut Document, + environment: &HashMap, +) -> Result<(), ComposeAclError> { + for block in &mut document.blocks { + resolve_block_environment(block, environment)?; + } + Ok(()) +} + +fn resolve_block_environment( + block: &mut Block, + environment: &HashMap, +) -> Result<(), ComposeAclError> { + for value in block.attributes.values_mut() { + resolve_value_environment(value, environment)?; + } + for nested in &mut block.blocks { + resolve_block_environment(nested, environment)?; + } + Ok(()) +} + +fn resolve_value_environment( + value: &mut Value, + environment: &HashMap, +) -> Result<(), ComposeAclError> { + match value { + Value::Call(name, arguments) => { + if name != "env" { + return Err(ComposeAclError::invalid(format!( + "unsupported ACL function {name:?}; only env(\"NAME\") is supported" + ))); + } + let [Value::String(variable)] = arguments.as_slice() else { + return Err(ComposeAclError::invalid( + "env() must receive exactly one string environment variable name", + )); + }; + let resolved = environment.get(variable).cloned().ok_or_else(|| { + ComposeAclError::invalid(format!( + "environment variable {variable:?} referenced by env() is not set" + )) + })?; + *value = Value::String(resolved); + } + Value::List(values) => { + for value in values { + resolve_value_environment(value, environment)?; + } + } + Value::Object(entries) => { + for (_, value) in entries { + resolve_value_environment(value, environment)?; + } + } + Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => {} + } + Ok(()) +} + +fn convert_document(document: Document) -> Result { + let mut services = HashMap::new(); + let mut volumes = HashMap::new(); + let mut networks = HashMap::new(); + + for block in document.blocks { + match block.name.as_str() { + "service" => { + let name = named_block_label(&block, "service")?; + validate_compose_name("service", &name)?; + let config = parse_service(&block, &name)?; + if services.insert(name.clone(), config).is_some() { + return Err(ComposeAclError::invalid(format!( + "duplicate service block {name:?}" + ))); + } + } + "volume" => { + let name = named_block_label(&block, "volume")?; + validate_compose_name("volume", &name)?; + validate_plain_block(&block, &["driver"], &format!("volume {name:?}"))?; + let declaration = VolumeDeclaration { + driver: optional_string(&block, "driver", &format!("volume {name:?}"))?, + }; + if volumes.insert(name.clone(), Some(declaration)).is_some() { + return Err(ComposeAclError::invalid(format!( + "duplicate volume block {name:?}" + ))); + } + } + "network" => { + let name = named_block_label(&block, "network")?; + validate_compose_name("network", &name)?; + validate_plain_block(&block, &["driver"], &format!("network {name:?}"))?; + let declaration = NetworkDeclaration { + driver: optional_string(&block, "driver", &format!("network {name:?}"))?, + }; + if networks.insert(name.clone(), Some(declaration)).is_some() { + return Err(ComposeAclError::invalid(format!( + "duplicate network block {name:?}" + ))); + } + } + name => { + return Err(ComposeAclError::invalid(format!( + "unsupported root block {name:?}; expected service, volume, or network" + ))); + } + } + } + + if services.is_empty() { + return Err(ComposeAclError::invalid( + "compose.acl must contain at least one service block", + )); + } + + Ok(ComposeConfig { + version: None, + services, + volumes, + networks, + }) +} + +fn parse_service(block: &Block, name: &str) -> Result { + let path = format!("service {name:?}"); + validate_attributes(block, SERVICE_ATTRIBUTES, &path)?; + let healthcheck = parse_service_healthcheck(block, &path)?; + + Ok(ServiceConfig { + image: optional_string(block, "image", &path)?, + entrypoint: optional_string_or_list(block, "entrypoint", &path)?, + command: optional_string_or_list(block, "command", &path)?, + environment: optional_env_vars(block, "environment", &path)?, + env_file: optional_string_or_list(block, "env_file", &path)?.unwrap_or_default(), + ports: optional_string_list(block, "ports", &path)?.unwrap_or_default(), + volumes: optional_string_list(block, "volumes", &path)?.unwrap_or_default(), + depends_on: optional_depends_on(block, "depends_on", &path)?, + networks: optional_service_networks(block, "networks", &path)?, + cpus: optional_integer(block, "cpus", &path)?, + mem_limit: optional_string(block, "mem_limit", &path)?, + restart: optional_string(block, "restart", &path)?, + dns: optional_dns(block, "dns", &path)?, + tmpfs: optional_string_or_list(block, "tmpfs", &path)?.unwrap_or_default(), + cap_add: optional_string_list(block, "cap_add", &path)?.unwrap_or_default(), + cap_drop: optional_string_list(block, "cap_drop", &path)?.unwrap_or_default(), + privileged: optional_bool(block, "privileged", &path)?.unwrap_or(false), + labels: optional_labels(block, "labels", &path)?, + healthcheck, + working_dir: optional_string(block, "working_dir", &path)?, + hostname: optional_string(block, "hostname", &path)?, + extra_hosts: optional_string_or_list(block, "extra_hosts", &path)?.unwrap_or_default(), + }) +} + +fn parse_service_healthcheck( + service: &Block, + service_path: &str, +) -> Result, ComposeAclError> { + let mut nested_healthcheck = None; + for nested in &service.blocks { + if nested.name != "healthcheck" { + return Err(ComposeAclError::invalid(format!( + "{service_path} contains unsupported nested block {:?}", + nested.name + ))); + } + if nested_healthcheck.replace(nested).is_some() { + return Err(ComposeAclError::invalid(format!( + "{service_path} contains more than one healthcheck block" + ))); + } + } + + let attribute_healthcheck = service.attributes.get("healthcheck"); + if attribute_healthcheck.is_some() && nested_healthcheck.is_some() { + return Err(ComposeAclError::invalid(format!( + "{service_path} declares healthcheck both as an attribute and a block" + ))); + } + + if let Some(value) = attribute_healthcheck { + return parse_healthcheck(value, service_path).map(Some); + } + if let Some(block) = nested_healthcheck { + return parse_healthcheck_block(block, service_path).map(Some); + } + Ok(None) +} + +fn parse_healthcheck( + value: &Value, + service_path: &str, +) -> Result { + let path = format!("{service_path}.healthcheck"); + let Value::Object(entries) = value else { + return Err(ComposeAclError::invalid(format!( + "{path} must be an object or a healthcheck block" + ))); + }; + let fields = object_fields(entries, HEALTHCHECK_ATTRIBUTES, &path)?; + parse_healthcheck_fields(&fields, &path) +} + +fn parse_healthcheck_block( + block: &Block, + service_path: &str, +) -> Result { + let path = format!("{service_path}.healthcheck"); + if !block.labels.is_empty() { + return Err(ComposeAclError::invalid(format!( + "{path} block cannot have labels" + ))); + } + validate_plain_block(block, HEALTHCHECK_ATTRIBUTES, &path)?; + let fields = block + .attributes + .iter() + .map(|(name, value)| (name.as_str(), value)) + .collect::>(); + parse_healthcheck_fields(&fields, &path) +} + +fn parse_healthcheck_fields( + fields: &HashMap<&str, &Value>, + path: &str, +) -> Result { + Ok(HealthcheckConfig { + test: fields + .get("test") + .map(|value| string_or_list_value(value, &format!("{path}.test"))) + .transpose()? + .unwrap_or_default(), + disable: fields + .get("disable") + .map(|value| bool_value(value, &format!("{path}.disable"))) + .transpose()? + .unwrap_or(false), + interval: fields + .get("interval") + .map(|value| string_value(value, &format!("{path}.interval"))) + .transpose()?, + timeout: fields + .get("timeout") + .map(|value| string_value(value, &format!("{path}.timeout"))) + .transpose()?, + retries: fields + .get("retries") + .map(|value| integer_value(value, &format!("{path}.retries"))) + .transpose()?, + start_period: fields + .get("start_period") + .map(|value| string_value(value, &format!("{path}.start_period"))) + .transpose()?, + }) +} + +fn named_block_label(block: &Block, kind: &str) -> Result { + let [name] = block.labels.as_slice() else { + return Err(ComposeAclError::invalid(format!( + "{kind} blocks require exactly one string label" + ))); + }; + Ok(name.clone()) +} + +fn validate_compose_name(kind: &str, name: &str) -> Result<(), ComposeAclError> { + let mut bytes = name.bytes(); + let valid = bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); + if !valid { + return Err(ComposeAclError::invalid(format!( + "{kind} name {name:?} must start with an ASCII letter or digit and contain only letters, digits, '.', '_', or '-'" + ))); + } + Ok(()) +} + +fn validate_plain_block( + block: &Block, + attributes: &[&str], + path: &str, +) -> Result<(), ComposeAclError> { + if !block.blocks.is_empty() { + return Err(ComposeAclError::invalid(format!( + "{path} cannot contain nested blocks" + ))); + } + validate_attributes(block, attributes, path) +} + +fn validate_attributes(block: &Block, allowed: &[&str], path: &str) -> Result<(), ComposeAclError> { + let mut unknown = block + .attributes + .keys() + .filter(|field| !allowed.contains(&field.as_str())) + .cloned() + .collect::>(); + unknown.sort(); + if !unknown.is_empty() { + return Err(ComposeAclError::invalid(format!( + "{path} contains unsupported attribute(s): {}", + unknown.join(", ") + ))); + } + Ok(()) +} + +fn optional_string( + block: &Block, + field: &str, + path: &str, +) -> Result, ComposeAclError> { + block + .attributes + .get(field) + .map(|value| string_value(value, &format!("{path}.{field}"))) + .transpose() +} + +fn string_value(value: &Value, path: &str) -> Result { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| ComposeAclError::invalid(format!("{path} must be a string"))) +} + +fn optional_string_list( + block: &Block, + field: &str, + path: &str, +) -> Result>, ComposeAclError> { + block + .attributes + .get(field) + .map(|value| string_list_value(value, &format!("{path}.{field}"))) + .transpose() +} + +fn string_list_value(value: &Value, path: &str) -> Result, ComposeAclError> { + let Value::List(values) = value else { + return Err(ComposeAclError::invalid(format!( + "{path} must be a list of strings" + ))); + }; + values + .iter() + .map(|value| string_value(value, path)) + .collect() +} + +fn optional_string_or_list( + block: &Block, + field: &str, + path: &str, +) -> Result, ComposeAclError> { + block + .attributes + .get(field) + .map(|value| string_or_list_value(value, &format!("{path}.{field}"))) + .transpose() +} + +fn string_or_list_value(value: &Value, path: &str) -> Result { + match value { + Value::String(value) => Ok(StringOrList::Single(value.clone())), + Value::List(_) => string_list_value(value, path).map(StringOrList::List), + _ => Err(ComposeAclError::invalid(format!( + "{path} must be a string or a list of strings" + ))), + } +} + +fn optional_integer(block: &Block, field: &str, path: &str) -> Result, ComposeAclError> +where + T: TryFrom, +{ + block + .attributes + .get(field) + .map(|value| integer_value(value, &format!("{path}.{field}"))) + .transpose() +} + +fn integer_value(value: &Value, path: &str) -> Result +where + T: TryFrom, +{ + let Value::Number(number) = value else { + return Err(ComposeAclError::invalid(format!( + "{path} must be a nonnegative integer" + ))); + }; + if !number.is_finite() || *number < 0.0 || number.fract() != 0.0 || *number > u64::MAX as f64 { + return Err(ComposeAclError::invalid(format!( + "{path} must be a nonnegative integer" + ))); + } + T::try_from(*number as u64) + .map_err(|_| ComposeAclError::invalid(format!("{path} is out of range"))) +} + +fn optional_bool(block: &Block, field: &str, path: &str) -> Result, ComposeAclError> { + block + .attributes + .get(field) + .map(|value| bool_value(value, &format!("{path}.{field}"))) + .transpose() +} + +fn bool_value(value: &Value, path: &str) -> Result { + value + .as_bool() + .ok_or_else(|| ComposeAclError::invalid(format!("{path} must be a boolean"))) +} + +fn optional_env_vars(block: &Block, field: &str, path: &str) -> Result { + let Some(value) = block.attributes.get(field) else { + return Ok(EnvVars::Empty); + }; + match value { + Value::List(_) => string_list_value(value, &format!("{path}.{field}")).map(EnvVars::List), + Value::Object(entries) => { + string_map_value(entries, &format!("{path}.{field}")).map(EnvVars::Map) + } + _ => Err(ComposeAclError::invalid(format!( + "{path}.{field} must be an object or a list of KEY=value strings" + ))), + } +} + +fn optional_labels(block: &Block, field: &str, path: &str) -> Result { + let Some(value) = block.attributes.get(field) else { + return Ok(Labels::Empty); + }; + match value { + Value::List(_) => string_list_value(value, &format!("{path}.{field}")).map(Labels::List), + Value::Object(entries) => { + string_map_value(entries, &format!("{path}.{field}")).map(Labels::Map) + } + _ => Err(ComposeAclError::invalid(format!( + "{path}.{field} must be an object or a list of label strings" + ))), + } +} + +fn string_map_value( + entries: &[(String, Value)], + path: &str, +) -> Result, ComposeAclError> { + let mut output = HashMap::new(); + for (key, value) in entries { + let value = string_value(value, &format!("{path}.{key}"))?; + if output.insert(key.clone(), value).is_some() { + return Err(ComposeAclError::invalid(format!( + "{path} contains duplicate key {key:?}" + ))); + } + } + Ok(output) +} + +fn optional_dns(block: &Block, field: &str, path: &str) -> Result { + let Some(value) = block.attributes.get(field) else { + return Ok(DnsConfig::Empty); + }; + match value { + Value::String(value) => Ok(DnsConfig::Single(value.clone())), + Value::List(_) => string_list_value(value, &format!("{path}.{field}")).map(DnsConfig::List), + _ => Err(ComposeAclError::invalid(format!( + "{path}.{field} must be a string or a list of strings" + ))), + } +} + +fn optional_depends_on( + block: &Block, + field: &str, + path: &str, +) -> Result { + let Some(value) = block.attributes.get(field) else { + return Ok(DependsOn::Empty); + }; + let field_path = format!("{path}.{field}"); + match value { + Value::List(_) => string_list_value(value, &field_path).map(DependsOn::List), + Value::Object(entries) => { + let mut dependencies = HashMap::new(); + for (name, value) in entries { + validate_compose_name("dependency service", name)?; + let condition = match value { + Value::Null => "service_started".to_string(), + Value::Object(fields) => { + let fields = + object_fields(fields, &["condition"], &format!("{field_path}.{name}"))?; + fields + .get("condition") + .map(|value| { + string_value(value, &format!("{field_path}.{name}.condition")) + }) + .transpose()? + .unwrap_or_else(|| "service_started".to_string()) + } + _ => { + return Err(ComposeAclError::invalid(format!( + "{field_path}.{name} must be an object or null" + ))); + } + }; + if !matches!( + condition.as_str(), + "service_started" | "service_healthy" | "service_completed_successfully" + ) { + return Err(ComposeAclError::invalid(format!( + "{field_path}.{name}.condition has unsupported value {condition:?}" + ))); + } + if dependencies + .insert(name.clone(), DependsOnCondition { condition }) + .is_some() + { + return Err(ComposeAclError::invalid(format!( + "{field_path} contains duplicate service {name:?}" + ))); + } + } + Ok(DependsOn::Map(dependencies)) + } + _ => Err(ComposeAclError::invalid(format!( + "{field_path} must be a list of service names or an object" + ))), + } +} + +fn optional_service_networks( + block: &Block, + field: &str, + path: &str, +) -> Result { + let Some(value) = block.attributes.get(field) else { + return Ok(ServiceNetworks::Empty); + }; + let field_path = format!("{path}.{field}"); + match value { + Value::List(_) => string_list_value(value, &field_path).map(ServiceNetworks::List), + Value::Object(entries) => { + let mut networks = HashMap::new(); + for (name, value) in entries { + validate_compose_name("network", name)?; + let config = match value { + Value::Null => None, + Value::Object(fields) => { + let fields = + object_fields(fields, &["aliases"], &format!("{field_path}.{name}"))?; + let aliases = fields + .get("aliases") + .map(|value| { + string_list_value(value, &format!("{field_path}.{name}.aliases")) + }) + .transpose()? + .unwrap_or_default(); + Some(ServiceNetworkConfig { aliases }) + } + _ => { + return Err(ComposeAclError::invalid(format!( + "{field_path}.{name} must be an object or null" + ))); + } + }; + if networks.insert(name.clone(), config).is_some() { + return Err(ComposeAclError::invalid(format!( + "{field_path} contains duplicate network {name:?}" + ))); + } + } + Ok(ServiceNetworks::Map(networks)) + } + _ => Err(ComposeAclError::invalid(format!( + "{field_path} must be a list of network names or an object" + ))), + } +} + +fn object_fields<'a>( + entries: &'a [(String, Value)], + allowed: &[&str], + path: &str, +) -> Result, ComposeAclError> { + let mut output = HashMap::new(); + for (key, value) in entries { + if !allowed.contains(&key.as_str()) { + return Err(ComposeAclError::invalid(format!( + "{path} contains unsupported attribute {key:?}" + ))); + } + if output.insert(key.as_str(), value).is_some() { + return Err(ComposeAclError::invalid(format!( + "{path} contains duplicate attribute {key:?}" + ))); + } + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + const COMPLETE: &str = r#" +service "api" { + image = "ghcr.io/a3s/api:latest" + entrypoint = ["/bin/api"] + command = ["serve", "--port", "8080"] + environment = { + PORT = "8080" + TOKEN = env("API_TOKEN") + } + env_file = ["base.env", "local.env"] + ports = ["8080:8080"] + volumes = ["data:/data"] + depends_on = { + db = { condition = "service_healthy" } + } + networks = { + backend = { aliases = ["service-api"] } + } + cpus = 2 + mem_limit = "1g" + restart = "unless-stopped" + dns = ["1.1.1.1"] + tmpfs = "/tmp" + cap_add = ["NET_ADMIN"] + cap_drop = ["SYS_ADMIN"] + privileged = false + labels = { tier = "api" } + working_dir = "/app" + hostname = "api" + extra_hosts = ["host.internal:10.0.0.1"] + + healthcheck { + test = ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval = "10s" + timeout = "3s" + retries = 3 + start_period = "5s" + } +} + +service "db" { + image = "postgres:17" +} + +volume "data" { + driver = "local" +} + +network "backend" { + driver = "bridge" +} +"#; + + #[test] + fn parses_complete_closed_acl_schema() { + let environment = HashMap::from([("API_TOKEN".to_string(), "secret".to_string())]); + let config = parse_compose_acl(COMPLETE, &environment).expect("valid compose ACL"); + + assert_eq!(config.services.len(), 2); + let api = &config.services["api"]; + assert_eq!(api.image.as_deref(), Some("ghcr.io/a3s/api:latest")); + assert_eq!( + api.command.as_ref().unwrap().to_vec(), + ["serve", "--port", "8080"] + ); + assert_eq!(api.environment.to_pairs().len(), 2); + assert!(api + .environment + .to_pairs() + .contains(&("TOKEN".to_string(), "secret".to_string()))); + assert_eq!(api.depends_on.services(), ["db"]); + assert_eq!(api.networks.names(), ["backend"]); + assert_eq!(api.cpus, Some(2)); + assert_eq!(api.healthcheck.as_ref().unwrap().retries, Some(3)); + assert_eq!( + config.volumes["data"].as_ref().unwrap().driver.as_deref(), + Some("local") + ); + assert_eq!( + config.networks["backend"] + .as_ref() + .unwrap() + .driver + .as_deref(), + Some("bridge") + ); + } + + #[test] + fn rejects_unknown_blocks_attributes_and_nested_fields() { + for source in [ + "database \"db\" {}", + "service \"api\" { image = \"api\" typo = true }", + "service \"api\" { image = \"api\" deploy {} }", + "service \"api\" { image = \"api\" healthcheck { typo = 1 } }", + "service \"api\" { image = \"api\"", + "service \"api\" { image = \"api\" } }", + ] { + assert!( + parse_compose_acl(source, &HashMap::new()).is_err(), + "source should fail: {source}" + ); + } + } + + #[test] + fn rejects_invalid_labels_types_numbers_and_functions() { + for source in [ + "service {}", + "service \"bad/name\" { image = \"api\" }", + "service \"api\" { ports = \"8080:80\" }", + "service \"api\" { cpus = -1 }", + "service \"api\" { privileged = \"true\" }", + "service \"api\" { image = concat(\"a\", \"b\") }", + ] { + assert!( + parse_compose_acl(source, &HashMap::new()).is_err(), + "source should fail: {source}" + ); + } + } + + #[test] + fn reports_missing_environment_values() { + let error = parse_compose_acl( + "service \"api\" { environment = { TOKEN = env(\"MISSING\") } }", + &HashMap::new(), + ) + .unwrap_err(); + + assert!(error.to_string().contains("MISSING")); + assert!(error.to_string().contains("not set")); + } + + #[test] + fn parses_nested_healthcheck_after_multibyte_string() { + let source = r#" +service "api" { + image = "api:latest" + labels = { description = "服务" } + + healthcheck { + test = ["CMD", "true"] + } +} +"#; + + let config = parse_compose_acl(source, &HashMap::new()).expect("valid Unicode ACL"); + + assert_eq!( + config.services["api"] + .healthcheck + .as_ref() + .unwrap() + .test + .to_vec(), + ["CMD", "true"] + ); + } +} diff --git a/src/core/src/compose/interpolation.rs b/src/core/src/compose/interpolation.rs new file mode 100644 index 00000000..94c1263a --- /dev/null +++ b/src/core/src/compose/interpolation.rs @@ -0,0 +1,363 @@ +//! Docker Compose-style variable interpolation for YAML scalar values. + +use std::collections::HashMap; + +use thiserror::Error; + +const MAX_INTERPOLATION_DEPTH: usize = 32; + +/// Failure while parsing or expanding Compose variable expressions. +#[derive(Debug, Error)] +pub enum ComposeInterpolationError { + /// The Compose YAML could not be parsed or serialized. + #[error("invalid Compose YAML: {0}")] + Yaml(#[from] serde_yaml::Error), + + /// A variable expression is malformed or uses an unsupported operator. + #[error("invalid Compose variable expression at byte {offset}: {message}")] + InvalidExpression { + /// Byte offset of the opening `$` in its scalar value. + offset: usize, + /// Human-readable reason. + message: String, + }, + + /// A required variable is unset or empty. + #[error("required Compose variable '{name}' is unavailable: {message}")] + RequiredVariable { + /// Variable name. + name: String, + /// Expression-provided diagnostic. + message: String, + }, +} + +/// Expand Compose variables in YAML scalar values while leaving mapping keys intact. +pub fn interpolate_compose_yaml( + input: &str, + environment: &HashMap, +) -> Result { + let mut yaml: serde_yaml::Value = serde_yaml::from_str(input)?; + interpolate_value(&mut yaml, environment)?; + serde_yaml::to_string(&yaml).map_err(ComposeInterpolationError::from) +} + +pub(super) fn interpolate_compose_scalar( + input: &str, + environment: &HashMap, +) -> Result { + interpolate_scalar(input, environment, 0) +} + +fn interpolate_value( + value: &mut serde_yaml::Value, + environment: &HashMap, +) -> Result<(), ComposeInterpolationError> { + match value { + serde_yaml::Value::String(scalar) => { + *scalar = interpolate_scalar(scalar, environment, 0)?; + } + serde_yaml::Value::Sequence(sequence) => { + for item in sequence { + interpolate_value(item, environment)?; + } + } + serde_yaml::Value::Mapping(mapping) => { + // Compose interpolation applies to YAML values, not mapping keys. + for item in mapping.values_mut() { + interpolate_value(item, environment)?; + } + } + serde_yaml::Value::Tagged(tagged) => { + interpolate_value(&mut tagged.value, environment)?; + } + serde_yaml::Value::Null | serde_yaml::Value::Bool(_) | serde_yaml::Value::Number(_) => {} + } + Ok(()) +} + +fn interpolate_scalar( + input: &str, + environment: &HashMap, + depth: usize, +) -> Result { + if depth > MAX_INTERPOLATION_DEPTH { + return Err(invalid_expression( + 0, + format!("nesting exceeds {MAX_INTERPOLATION_DEPTH} levels"), + )); + } + + let mut output = String::with_capacity(input.len()); + let mut offset = 0; + + while offset < input.len() { + let rest = &input[offset..]; + let Some(character) = rest.chars().next() else { + break; + }; + + if character != '$' { + output.push(character); + offset += character.len_utf8(); + continue; + } + + if rest.starts_with("$$") { + output.push('$'); + offset += 2; + continue; + } + + if rest.starts_with("${") { + let closing = find_closing_brace(input, offset)?; + let expression = &input[offset + 2..closing]; + output.push_str(&expand_expression(expression, environment, depth, offset)?); + offset = closing + 1; + continue; + } + + let name_start = offset + 1; + let name_end = scan_variable_name(input, name_start); + if name_end == name_start { + output.push('$'); + offset += 1; + continue; + } + + let name = &input[name_start..name_end]; + if let Some(value) = environment.get(name) { + output.push_str(value); + } + offset = name_end; + } + + Ok(output) +} + +fn find_closing_brace(input: &str, opening: usize) -> Result { + let mut depth = 1usize; + let mut offset = opening + 2; + + while offset < input.len() { + let rest = &input[offset..]; + if rest.starts_with("${") { + depth += 1; + offset += 2; + continue; + } + + let Some(character) = rest.chars().next() else { + break; + }; + if character == '}' { + depth -= 1; + if depth == 0 { + return Ok(offset); + } + } + offset += character.len_utf8(); + } + + Err(invalid_expression(opening, "unterminated `${...}`")) +} + +fn scan_variable_name(input: &str, start: usize) -> usize { + let mut end = start; + for (relative, character) in input[start..].char_indices() { + let valid = if relative == 0 { + character == '_' || character.is_ascii_alphabetic() + } else { + character == '_' || character.is_ascii_alphanumeric() + }; + if !valid { + break; + } + end = start + relative + character.len_utf8(); + } + end +} + +fn expand_expression( + expression: &str, + environment: &HashMap, + depth: usize, + offset: usize, +) -> Result { + let name_end = scan_variable_name(expression, 0); + if name_end == 0 { + return Err(invalid_expression(offset, "variable name is missing")); + } + + let name = &expression[..name_end]; + let remainder = &expression[name_end..]; + let value = environment.get(name); + let is_set = value.is_some(); + let is_nonempty = value.is_some_and(|value| !value.is_empty()); + + if remainder.is_empty() { + return Ok(value.cloned().unwrap_or_default()); + } + + let (operator, word) = [":-", ":+", ":?", "-", "+", "?"] + .into_iter() + .find_map(|operator| { + remainder + .strip_prefix(operator) + .map(|word| (operator, word)) + }) + .ok_or_else(|| { + invalid_expression( + offset, + format!("unsupported operator in `${{{expression}}}`"), + ) + })?; + + let expand_word = || interpolate_scalar(word, environment, depth + 1); + match operator { + "-" if !is_set => expand_word(), + ":-" if !is_nonempty => expand_word(), + "+" if is_set => expand_word(), + ":+" if is_nonempty => expand_word(), + "?" if !is_set => required_variable(name, word, environment, depth), + ":?" if !is_nonempty => required_variable(name, word, environment, depth), + "-" | ":-" => Ok(value.cloned().unwrap_or_default()), + "+" | ":+" => Ok(String::new()), + "?" | ":?" => Ok(value.cloned().unwrap_or_default()), + _ => unreachable!("operator matched the closed list above"), + } +} + +fn required_variable( + name: &str, + word: &str, + environment: &HashMap, + depth: usize, +) -> Result { + let message = if word.is_empty() { + "variable is required".to_string() + } else { + interpolate_scalar(word, environment, depth + 1)? + }; + Err(ComposeInterpolationError::RequiredVariable { + name: name.to_string(), + message, + }) +} + +fn invalid_expression(offset: usize, message: impl Into) -> ComposeInterpolationError { + ComposeInterpolationError::InvalidExpression { + offset, + message: message.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn environment(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + } + + fn interpolated_value(yaml: &str, env: &[(&str, &str)]) -> serde_yaml::Value { + let output = interpolate_compose_yaml(yaml, &environment(env)).unwrap(); + serde_yaml::from_str(&output).unwrap() + } + + #[test] + fn expands_unset_and_empty_default_operators() { + let value = interpolated_value( + r#" +dash_unset: ${UNSET-default} +dash_empty: ${EMPTY-default} +colon_dash_unset: ${UNSET:-default} +colon_dash_empty: ${EMPTY:-default} +"#, + &[("EMPTY", "")], + ); + + assert_eq!(value["dash_unset"].as_str(), Some("default")); + assert_eq!(value["dash_empty"].as_str(), Some("")); + assert_eq!(value["colon_dash_unset"].as_str(), Some("default")); + assert_eq!(value["colon_dash_empty"].as_str(), Some("default")); + } + + #[test] + fn expands_set_and_nonempty_replacement_operators() { + let value = interpolated_value( + r#" +plus_unset: ${UNSET+replacement} +plus_empty: ${EMPTY+replacement} +plus_value: ${VALUE+replacement} +colon_plus_unset: ${UNSET:+replacement} +colon_plus_empty: ${EMPTY:+replacement} +colon_plus_value: ${VALUE:+replacement} +"#, + &[("EMPTY", ""), ("VALUE", "present")], + ); + + assert_eq!(value["plus_unset"].as_str(), Some("")); + assert_eq!(value["plus_empty"].as_str(), Some("replacement")); + assert_eq!(value["plus_value"].as_str(), Some("replacement")); + assert_eq!(value["colon_plus_unset"].as_str(), Some("")); + assert_eq!(value["colon_plus_empty"].as_str(), Some("")); + assert_eq!(value["colon_plus_value"].as_str(), Some("replacement")); + } + + #[test] + fn expands_bare_braced_nested_and_escaped_dollars() { + let value = interpolated_value( + r#" +bare: $VALUE +braced: ${VALUE} +nested: ${UNSET:-${FALLBACK:-final}} +escaped: $$VALUE and $${VALUE} +"#, + &[("VALUE", "resolved")], + ); + + assert_eq!(value["bare"].as_str(), Some("resolved")); + assert_eq!(value["braced"].as_str(), Some("resolved")); + assert_eq!(value["nested"].as_str(), Some("final")); + assert_eq!(value["escaped"].as_str(), Some("$VALUE and ${VALUE}")); + } + + #[test] + fn interpolates_values_but_not_mapping_keys() { + let value = interpolated_value( + r#" +${KEY}: unchanged-key +environment: + VALUE: ${VALUE:-fallback} +ports: + - "${PORT:-6379}:6379" +"#, + &[ + ("KEY", "expanded-key"), + ("VALUE", "shell"), + ("PORT", "16379"), + ], + ); + + assert_eq!(value["${KEY}"].as_str(), Some("unchanged-key")); + assert!(value.get("expanded-key").is_none()); + assert_eq!(value["environment"]["VALUE"].as_str(), Some("shell")); + assert_eq!(value["ports"][0].as_str(), Some("16379:6379")); + } + + #[test] + fn reports_required_and_malformed_expressions() { + let required = + interpolate_compose_yaml("value: ${MISSING:?set MISSING}\n", &HashMap::new()) + .unwrap_err(); + assert!(required.to_string().contains("set MISSING")); + + let malformed = + interpolate_compose_yaml("value: ${MISSING\n", &HashMap::new()).unwrap_err(); + assert!(malformed.to_string().contains("unterminated")); + } +} diff --git a/src/core/src/config.rs b/src/core/src/config.rs index a58bbfa9..28c12f7d 100644 --- a/src/core/src/config.rs +++ b/src/core/src/config.rs @@ -2,6 +2,27 @@ use crate::network::NetworkMode; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +/// Execution isolation selected for a box. +/// +/// MicroVM remains the implicit default. Host sandbox execution must always be +/// selected explicitly by the caller and never acts as an automatic fallback. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExecutionIsolation { + /// Hardware-backed MicroVM isolation. + #[default] + Microvm, + /// Shared-kernel OCI sandbox isolation. + Sandbox, +} + +impl ExecutionIsolation { + /// Whether this request selects the shared-kernel sandbox backend. + pub fn is_sandbox(self) -> bool { + matches!(self, Self::Sandbox) + } +} + /// TEE (Trusted Execution Environment) configuration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -260,6 +281,10 @@ pub struct ResourceLimits { /// Box configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BoxConfig { + /// Execution isolation. MicroVM is the backwards-compatible default. + #[serde(default)] + pub isolation: ExecutionIsolation, + /// OCI image reference (e.g., "nginx:alpine", "ghcr.io/org/app:latest") #[serde(default)] pub image: String, @@ -284,6 +309,12 @@ pub struct BoxConfig { #[serde(default)] pub cmd: Vec, + /// Keep stdin open for the initial container process. + /// + /// Defaults to false so non-interactive runs do not block forever on prompts. + #[serde(default)] + pub stdin_open: bool, + /// Entrypoint override (replaces OCI ENTRYPOINT when set) #[serde(default)] pub entrypoint_override: Option>, @@ -306,6 +337,11 @@ pub struct BoxConfig { #[serde(default)] pub volumes: Vec, + /// virtio-fs cache mode for host directory volumes (`none`, `auto`, + /// `always`, or `default`). `None` uses the host environment/default. + #[serde(default)] + pub virtiofs_cache: Option, + /// Extra environment variables for the entrypoint #[serde(default)] pub extra_env: Vec<(String, String)>, @@ -428,6 +464,7 @@ pub struct BoxConfig { impl Default for BoxConfig { fn default() -> Self { Self { + isolation: ExecutionIsolation::default(), image: String::new(), // Empty path signals the runtime to create a per-box workspace // under ~/.a3s/boxes//workspace/ at boot time. @@ -437,11 +474,13 @@ impl Default for BoxConfig { debug_grpc: false, tee: TeeConfig::default(), cmd: vec![], + stdin_open: false, entrypoint_override: None, user: None, workdir: None, hostname: None, volumes: vec![], + virtiofs_cache: None, extra_env: vec![], cache: CacheConfig::default(), pool: PoolConfig::default(), diff --git a/src/core/src/execution.rs b/src/core/src/execution.rs new file mode 100644 index 00000000..bc334c53 --- /dev/null +++ b/src/core/src/execution.rs @@ -0,0 +1,265 @@ +//! Backend-neutral execution isolation resolution. + +use serde::{Deserialize, Serialize}; + +use crate::config::{BoxConfig, ExecutionIsolation, TeeConfig}; +use crate::error::{BoxError, Result}; +use crate::network::NetworkMode; + +/// Concrete backend selected for an execution request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExecutionBackend { + /// libkrun-backed MicroVM execution. + Krun, + /// OCI execution through the certified crun runtime. + Crun, +} + +/// Security boundary provided by the resolved backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum IsolationClass { + /// A hardware-backed virtual-machine boundary. + HardwareVm, + /// Linux namespaces and controls sharing the host kernel. + SharedKernel, +} + +/// Deterministic result of resolving one execution request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedExecutionPlan { + /// Isolation requested by the caller or selected by the implicit default. + pub requested_isolation: ExecutionIsolation, + /// Concrete runtime backend. + pub backend: ExecutionBackend, + /// Effective security-boundary class. + pub isolation_class: IsolationClass, + /// Controls that the selected backend must prove before launch. + pub required_controls: Vec, +} + +const SANDBOX_REQUIRED_CONTROLS: &[&str] = &[ + "user-namespace", + "mount-namespace", + "pid-namespace", + "ipc-namespace", + "uts-namespace", + "network-namespace", + "seccomp", + "capability-bounding-set", + "no-new-privileges", + "cgroup-v2", +]; + +const SANDBOX_ALLOWED_ADDED_CAPABILITIES: &[&str] = &[ + "AUDIT_WRITE", + "CHOWN", + "DAC_OVERRIDE", + "FOWNER", + "FSETID", + "KILL", + "MKNOD", + "NET_BIND_SERVICE", + "SETFCAP", + "SETGID", + "SETPCAP", + "SETUID", + "SYS_CHROOT", +]; + +/// Resolve a box configuration without probing or mutating the host. +/// +/// Host capabilities are checked separately immediately before preparation. +/// Keeping this function pure makes unsupported feature combinations fail +/// before image pulls, rootfs mounts, state changes, or runtime processes. +pub fn resolve_execution(config: &BoxConfig) -> Result { + match config.isolation { + ExecutionIsolation::Microvm => Ok(ResolvedExecutionPlan { + requested_isolation: ExecutionIsolation::Microvm, + backend: ExecutionBackend::Krun, + isolation_class: IsolationClass::HardwareVm, + required_controls: Vec::new(), + }), + ExecutionIsolation::Sandbox => { + validate_sandbox_compatibility(config)?; + Ok(ResolvedExecutionPlan { + requested_isolation: ExecutionIsolation::Sandbox, + backend: ExecutionBackend::Crun, + isolation_class: IsolationClass::SharedKernel, + required_controls: SANDBOX_REQUIRED_CONTROLS + .iter() + .map(|control| (*control).to_string()) + .collect(), + }) + } + } +} + +/// Validate features that cannot be represented safely by the sandbox MVP. +pub fn validate_sandbox_compatibility(config: &BoxConfig) -> Result<()> { + if !config.isolation.is_sandbox() { + return Ok(()); + } + + let mut unsupported = Vec::new(); + + if !matches!(config.tee, TeeConfig::None) { + unsupported.push("TEE and attestation"); + } + if config.pool.enabled || config.pool.snapshot_fork { + unsupported.push("warm pools and snapshot-fork"); + } + if config.deferred_main { + unsupported.push("deferred main execution"); + } + if config.ksm { + unsupported.push("KSM"); + } + if config.snapshot_mem_file.is_some() + || config.snapshot_sock.is_some() + || config.restore_from.is_some() + { + unsupported.push("VM snapshots and restore"); + } + if config.privileged { + unsupported.push("privileged mode"); + } + if config.sidecar.is_some() { + unsupported.push("vsock sidecars"); + } + if !config.port_map.is_empty() { + unsupported.push("published ports"); + } + if matches!(config.network, NetworkMode::Bridge { .. }) { + unsupported.push("named bridge networking"); + } + if !config.sysctls.is_empty() { + unsupported.push("custom sysctls"); + } + if config + .security_opt + .iter() + .any(|option| option.trim().eq_ignore_ascii_case("seccomp=unconfined")) + { + unsupported.push("unconfined seccomp"); + } + + let disallowed_capabilities: Vec = config + .cap_add + .iter() + .map(|capability| normalize_capability(capability)) + .filter(|capability| !SANDBOX_ALLOWED_ADDED_CAPABILITIES.contains(&capability.as_str())) + .collect(); + if !disallowed_capabilities.is_empty() { + return Err(BoxError::ConfigError(format!( + "sandbox isolation rejects added capabilities outside its allowlist: {}", + disallowed_capabilities.join(", ") + ))); + } + + if unsupported.is_empty() { + Ok(()) + } else { + Err(BoxError::ConfigError(format!( + "sandbox isolation does not support: {}", + unsupported.join(", ") + ))) + } +} + +fn normalize_capability(capability: &str) -> String { + let normalized = capability.trim().to_ascii_uppercase(); + normalized + .strip_prefix("CAP_") + .unwrap_or(&normalized) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{PoolConfig, SidecarConfig}; + + fn sandbox_config() -> BoxConfig { + BoxConfig { + isolation: ExecutionIsolation::Sandbox, + ..Default::default() + } + } + + #[test] + fn default_resolves_only_to_krun_hardware_vm() { + let plan = resolve_execution(&BoxConfig::default()).unwrap(); + assert_eq!(plan.backend, ExecutionBackend::Krun); + assert_eq!(plan.isolation_class, IsolationClass::HardwareVm); + assert!(plan.required_controls.is_empty()); + } + + #[test] + fn sandbox_resolves_to_crun_shared_kernel_with_mandatory_controls() { + let plan = resolve_execution(&sandbox_config()).unwrap(); + assert_eq!(plan.backend, ExecutionBackend::Crun); + assert_eq!(plan.isolation_class, IsolationClass::SharedKernel); + for required in SANDBOX_REQUIRED_CONTROLS { + assert!(plan.required_controls.iter().any(|value| value == required)); + } + } + + #[test] + fn sandbox_rejects_vm_only_features_together() { + let config = BoxConfig { + isolation: ExecutionIsolation::Sandbox, + tee: TeeConfig::Tdx { + workload_id: "test".to_string(), + simulate: true, + }, + pool: PoolConfig { + enabled: true, + ..Default::default() + }, + sidecar: Some(SidecarConfig::default()), + port_map: vec!["8080:80".to_string()], + privileged: true, + ..Default::default() + }; + + let error = resolve_execution(&config).unwrap_err().to_string(); + assert!(error.contains("TEE and attestation")); + assert!(error.contains("warm pools")); + assert!(error.contains("vsock sidecars")); + assert!(error.contains("published ports")); + assert!(error.contains("privileged mode")); + } + + #[test] + fn sandbox_rejects_unconfined_seccomp() { + let config = BoxConfig { + security_opt: vec!["seccomp=unconfined".to_string()], + ..sandbox_config() + }; + assert!(resolve_execution(&config) + .unwrap_err() + .to_string() + .contains("unconfined seccomp")); + } + + #[test] + fn sandbox_normalizes_and_allows_baseline_capabilities() { + let config = BoxConfig { + cap_add: vec!["cap_chown".to_string(), "NET_BIND_SERVICE".to_string()], + ..sandbox_config() + }; + assert!(resolve_execution(&config).is_ok()); + } + + #[test] + fn sandbox_rejects_powerful_added_capability() { + let config = BoxConfig { + cap_add: vec!["CAP_SYS_ADMIN".to_string()], + ..sandbox_config() + }; + let error = resolve_execution(&config).unwrap_err().to_string(); + assert!(error.contains("SYS_ADMIN")); + } +} diff --git a/src/core/src/lib.rs b/src/core/src/lib.rs index 4d049a59..578b7089 100644 --- a/src/core/src/lib.rs +++ b/src/core/src/lib.rs @@ -11,13 +11,16 @@ pub mod env; pub mod error; pub mod event; pub mod exec; +pub mod execution; pub mod fs_atomic; +pub mod lifecycle_profile; pub mod log; pub mod network; pub mod operator; pub mod platform; pub mod port; pub mod pty; +pub mod rootfs_metadata; pub mod scale; pub mod security; pub mod snapshot; @@ -30,13 +33,17 @@ pub mod workload; // Re-export commonly used types pub use audit::{AuditAction, AuditConfig, AuditEvent, AuditOutcome}; pub use compose::ComposeConfig; -pub use config::{BoxConfig, ResourceConfig, ResourceLimits}; +pub use config::{BoxConfig, ExecutionIsolation, ResourceConfig, ResourceLimits}; pub use error::{BoxError, Result}; pub use event::{BoxEvent, EventEmitter}; pub use exec::{ExecChunk, ExecEvent, ExecExit, ExecMetrics, StreamType}; pub use exec::{ExecOutput, ExecRequest}; pub use exec::{FileOp, FileRequest, FileResponse}; pub use exec::{EXEC_VSOCK_PORT, PORT_FWD_VSOCK_PORT}; +pub use execution::{ + resolve_execution, validate_sandbox_compatibility, ExecutionBackend, IsolationClass, + ResolvedExecutionPlan, +}; pub use network::{IsolationMode, NetworkConfig, NetworkEndpoint, NetworkMode, NetworkPolicy}; pub use operator::{BoxAutoscaler, BoxAutoscalerSpec, BoxAutoscalerStatus, MetricType}; pub use platform::{ @@ -49,13 +56,21 @@ pub use scale::{ InstanceState, ScaleConfig, ScaleRequest, ScaleResponse, }; pub use security::{SeccompMode, SecurityConfig}; -pub use snapshot::{SnapshotConfig, SnapshotMetadata}; +pub use snapshot::{ + SnapshotConfig, SnapshotImageConfig, SnapshotImageHealthCheck, SnapshotMetadata, +}; pub use tee::ATTEST_VSOCK_PORT; pub use tee::{detect_tee, is_tee_available, TeeCapability, TeeType}; pub use traits::{ - AuditSink, CacheBackend, CacheEntry, CacheStats, CredentialProvider, EventBus, ImageRegistry, - ImageStoreBackend, MetricsCollector, NetworkStoreBackend, NoopMetrics, PulledImage, - SnapshotStoreBackend, StoredImage, VolumeStoreBackend, + AuditSink, CacheBackend, CacheEntry, CacheStats, CreateExecutionRequest, CredentialProvider, + EventBus, ExecutionGeneration, ExecutionHealthCheck, ExecutionId, ExecutionLease, + ExecutionManager, ExecutionManagerError, ExecutionManagerResult, ExecutionPortConnector, + ExecutionPortIo, ExecutionPortStream, ExecutionProcess, ExecutionProcessInput, + ExecutionProcessStream, ExecutionRecordPolicy, ExecutionReservation, ExecutionRestartPolicy, + ExecutionSessionManager, ExecutionSnapshot, ExecutionSnapshotId, ExecutionState, + ExecutionStatus, ImageRegistry, ImageStoreBackend, KillOutcome, MetricsCollector, + NetworkStoreBackend, NoopMetrics, OperationId, PulledImage, ReconcileOutcome, + RestartExecutionOptions, SnapshotStoreBackend, StoredImage, VolumeStoreBackend, }; pub use vmm::{ Entrypoint, FsMount, InstanceSpec, NetworkInstanceConfig, TeeInstanceConfig, VmHandler, diff --git a/src/core/src/lifecycle_profile.rs b/src/core/src/lifecycle_profile.rs new file mode 100644 index 00000000..e75c1c5e --- /dev/null +++ b/src/core/src/lifecycle_profile.rs @@ -0,0 +1,82 @@ +//! Opt-in machine-readable lifecycle profiling. +//! +//! The benchmark harness enables this through +//! `A3S_BOX_LIFECYCLE_PROFILE=1`. Normal CLI output and production behavior are +//! unchanged when the variable is absent. Events deliberately contain only a +//! stable phase name, elapsed time, and process identity; workload arguments, +//! paths, credentials, and other caller data are never emitted. + +use std::time::Duration; + +use serde::Serialize; + +/// Environment variable that enables lifecycle JSONL events on stderr. +pub const LIFECYCLE_PROFILE_ENV: &str = "A3S_BOX_LIFECYCLE_PROFILE"; + +/// Prefix that makes profile events unambiguous in mixed CLI stderr. +pub const LIFECYCLE_PROFILE_PREFIX: &str = "A3S_BOX_LIFECYCLE "; + +const LIFECYCLE_PROFILE_SCHEMA: &str = "a3s.box.lifecycle-profile.v1"; + +#[derive(Serialize)] +struct LifecycleProfileEvent<'a> { + schema: &'static str, + phase: &'a str, + duration_ns: u64, + pid: u32, +} + +/// Emit one best-effort JSONL phase event when lifecycle profiling is enabled. +/// +/// Profiling must never change lifecycle success or failure. Serialization and +/// stderr write failures are therefore intentionally ignored. +pub fn record_lifecycle_phase(phase: &str, duration: Duration) { + if !lifecycle_profile_enabled(std::env::var_os(LIFECYCLE_PROFILE_ENV).as_deref()) { + return; + } + if let Some(line) = lifecycle_profile_line(phase, duration, std::process::id()) { + eprintln!("{LIFECYCLE_PROFILE_PREFIX}{line}"); + } +} + +fn lifecycle_profile_enabled(value: Option<&std::ffi::OsStr>) -> bool { + value.is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) +} + +fn lifecycle_profile_line(phase: &str, duration: Duration, pid: u32) -> Option { + let duration_ns = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX); + serde_json::to_string(&LifecycleProfileEvent { + schema: LIFECYCLE_PROFILE_SCHEMA, + phase, + duration_ns, + pid, + }) + .ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_gate_accepts_only_explicit_true_values() { + assert!(!lifecycle_profile_enabled(None)); + assert!(!lifecycle_profile_enabled(Some(std::ffi::OsStr::new("0")))); + assert!(lifecycle_profile_enabled(Some(std::ffi::OsStr::new("1")))); + assert!(lifecycle_profile_enabled(Some(std::ffi::OsStr::new( + "TRUE" + )))); + } + + #[test] + fn profile_event_is_stable_json_without_caller_data() { + let line = lifecycle_profile_line("sandbox.layout", Duration::from_micros(1250), 42) + .expect("profile event should serialize"); + let event: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(event["schema"], LIFECYCLE_PROFILE_SCHEMA); + assert_eq!(event["phase"], "sandbox.layout"); + assert_eq!(event["duration_ns"], 1_250_000); + assert_eq!(event["pid"], 42); + assert_eq!(event.as_object().unwrap().len(), 4); + } +} diff --git a/src/core/src/log.rs b/src/core/src/log.rs index 2273abea..196c0a42 100644 --- a/src/core/src/log.rs +++ b/src/core/src/log.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Logging driver type. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum LogDriver { /// Docker-compatible JSON lines format (default). @@ -48,7 +48,7 @@ impl std::str::FromStr for LogDriver { } /// Logging configuration for a box. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LogConfig { pub driver: LogDriver, #[serde(default)] @@ -108,7 +108,7 @@ impl LogConfig { } /// A single structured log entry (Docker-compatible JSON format). -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LogEntry { /// The log message (including trailing newline). pub log: String, @@ -118,6 +118,27 @@ pub struct LogEntry { pub time: String, } +/// Schema used to hand one Sandbox log worker its immutable generation data. +pub const SANDBOX_LOG_WORKER_SCHEMA: &str = "a3s.box.sandbox-log-worker.v1"; + +/// Configuration for the host process that projects Sandbox stdout/stderr into +/// the configured logging driver after the launching client has detached. +/// +/// The worker watches the exact `crun run` wrapper PID identity. Once that +/// process exits, both inherited output descriptors are closed and EOF is +/// authoritative, so the worker can drain the final bytes without a fixed +/// late-write delay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxLogWorkerSpec { + pub schema: String, + pub box_id: String, + pub console_log: PathBuf, + pub log_config: LogConfig, + pub watched_pid: u32, + pub watched_pid_start_time: u64, + pub ready_file: PathBuf, +} + /// Parse a human-readable size string (e.g., "10m", "1g", "4096") into bytes. fn parse_size(s: &str) -> std::result::Result { let s = s.trim().to_lowercase(); @@ -159,6 +180,18 @@ use std::io::{BufRead, BufReader, Seek, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; +/// Whether the producer may still publish bytes after its apparent exit. +/// +/// libkrun can return before its console backend's final host write becomes +/// visible, whereas a reaped `crun run` wrapper has already closed stdout and +/// stderr. Keeping the distinction explicit avoids imposing the MicroVM's +/// half-second settle window on every short Sandbox execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsoleEofPolicy { + MayReceiveLateWrites, + WriterClosed, +} + /// Truncate `path` to empty if it has grown past `cap` bytes; returns whether it /// truncated. /// @@ -209,11 +242,26 @@ fn tail_next_line( buf: &mut String, stop: &AtomicBool, on_eof: Option<&dyn Fn() -> bool>, + eof_policy: ConsoleEofPolicy, ) -> Option { + // `krun_start_enter()` can return a few scheduler ticks before the + // virtio-console backend's final host write becomes visible. Treat the + // first stopped EOFs as provisional; otherwise a very short detached + // command can leave bytes in console.log after the processor has exited. + const STOPPED_EOF_SETTLE_MILLIS: u64 = 20; + let stopped_eof_settle_polls = stopped_eof_settle_polls(eof_policy); + let mut stopped_eof_polls = 0u8; loop { match reader.read_line(buf) { Ok(0) | Err(_) => { if stop.load(Ordering::Relaxed) { + stopped_eof_polls = stopped_eof_polls.saturating_add(1); + if stopped_eof_polls < stopped_eof_settle_polls { + std::thread::sleep(std::time::Duration::from_millis( + STOPPED_EOF_SETTLE_MILLIS, + )); + continue; + } // VM exited and we are at EOF: flush a trailing partial line // (no newline) once, then signal completion. if buf.is_empty() { @@ -235,7 +283,7 @@ fn tail_next_line( std::thread::sleep(std::time::Duration::from_millis(100)); continue; } - Ok(_) => {} + Ok(_) => stopped_eof_polls = 0, } if !buf.ends_with('\n') { // Partial line at EOF — keep it buffered and wait for the rest. @@ -246,6 +294,14 @@ fn tail_next_line( } } +fn stopped_eof_settle_polls(eof_policy: ConsoleEofPolicy) -> u8 { + const LATE_WRITE_POLLS: u8 = 25; + match eof_policy { + ConsoleEofPolicy::MayReceiveLateWrites => LATE_WRITE_POLLS, + ConsoleEofPolicy::WriterClosed => 1, + } +} + /// Run the log processor for a box, blocking until `stop` is set and the console /// is drained. Intended to run on a dedicated thread for the VM's lifetime; set /// `stop` after the VM exits, then join, to guarantee the final lines are @@ -255,6 +311,42 @@ pub fn run_log_processor( log_dir: &Path, config: &LogConfig, stop: &AtomicBool, +) { + run_log_processor_with_ready(console_log, log_dir, config, stop, None); +} + +/// Run the processor and optionally count each console reader once it has +/// opened its file. A VM launcher can wait for two ready readers before start, +/// preventing a short guest from exiting before the tail threads are alive. +pub fn run_log_processor_with_ready( + console_log: &Path, + log_dir: &Path, + config: &LogConfig, + stop: &AtomicBool, + ready: Option<&std::sync::atomic::AtomicUsize>, +) { + run_log_processor_with_ready_and_eof_policy( + console_log, + log_dir, + config, + stop, + ready, + ConsoleEofPolicy::MayReceiveLateWrites, + ); +} + +/// Run the log processor with an explicit final-EOF policy. +/// +/// Sandbox workers use [`ConsoleEofPolicy::WriterClosed`] only after the exact +/// `crun run` wrapper has exited. Other callers should retain the conservative +/// default exposed by [`run_log_processor_with_ready`]. +pub fn run_log_processor_with_ready_and_eof_policy( + console_log: &Path, + log_dir: &Path, + config: &LogConfig, + stop: &AtomicBool, + ready: Option<&std::sync::atomic::AtomicUsize>, + eof_policy: ConsoleEofPolicy, ) { match config.driver { // `none` produces no structured output, but libkrun still writes the raw @@ -265,6 +357,8 @@ pub fn run_log_processor( console_log, Some(console_cap(config.max_size(), config.max_file())), stop, + ready, + eof_policy, ), LogDriver::JsonFile => run_json_file_processor( console_log, @@ -272,15 +366,10 @@ pub fn run_log_processor( config.max_size(), config.max_file(), stop, + ready, + eof_policy, ), - LogDriver::Syslog => run_syslog_processor( - console_log, - config.syslog_address(), - config.syslog_facility(), - config.tag().unwrap_or("a3s-box"), - Some(console_cap(config.max_size(), config.max_file())), - stop, - ), + LogDriver::Syslog => run_syslog_processor(console_log, config, stop, ready, eof_policy), } } @@ -309,29 +398,41 @@ pub fn stderr_console_path(console_log: &Path) -> PathBuf { /// Tail one console file, emitting each container line via `emit(line, stream)`. /// `filter_noise` drops libkrun's `init.krun:` preamble (only on the stdout /// console). Blocks until `stop` is set and the file is drained. -fn run_tagged_tail( - file: &Path, - stream: &str, +#[derive(Clone, Copy)] +struct TaggedTailOptions<'a> { + stream: &'static str, filter_noise: bool, bound: Option, + ready: Option<&'a std::sync::atomic::AtomicUsize>, + eof_policy: ConsoleEofPolicy, +} + +fn run_tagged_tail( + file: &Path, stop: &AtomicBool, emit: &(dyn Fn(&str, &str) + Sync), + options: TaggedTailOptions<'_>, ) { let f = match open_console(file, stop) { Some(f) => f, None => return, }; + if let Some(ready) = options.ready { + ready.fetch_add(1, Ordering::Release); + } let mut reader = BufReader::new(f); let mut buf = String::new(); // Bound the raw console file's growth at clean line boundaries (see // console_truncate_if_over). None = unbounded (used by tests). - let truncate = bound.map(|cap| move || console_truncate_if_over(file, cap)); + let truncate = options + .bound + .map(|cap| move || console_truncate_if_over(file, cap)); let on_eof: Option<&dyn Fn() -> bool> = truncate.as_ref().map(|t| t as &dyn Fn() -> bool); - while let Some(line) = tail_next_line(&mut reader, &mut buf, stop, on_eof) { - if filter_noise && is_runtime_console_noise(&line) { + while let Some(line) = tail_next_line(&mut reader, &mut buf, stop, on_eof, options.eof_policy) { + if options.filter_noise && is_runtime_console_noise(&line) { continue; } - emit(&line, stream); + emit(&line, options.stream); } } @@ -346,13 +447,45 @@ fn console_cap(max_size: u64, max_file: u32) -> u64 { /// (advancing to clean line boundaries) and truncate when over `cap`, emitting /// nothing. Without this, `--log-driver none` would leave libkrun's raw /// `console.log`/`console.err.log` to grow without limit. -fn run_discard_processor(console_log: &Path, cap: Option, stop: &AtomicBool) { +fn run_discard_processor( + console_log: &Path, + cap: Option, + stop: &AtomicBool, + ready: Option<&std::sync::atomic::AtomicUsize>, + eof_policy: ConsoleEofPolicy, +) { let err_log = stderr_console_path(console_log); let discard = |_line: &str, _stream: &str| {}; let discard: &(dyn Fn(&str, &str) + Sync) = &discard; std::thread::scope(|s| { - s.spawn(|| run_tagged_tail(console_log, "stdout", false, cap, stop, discard)); - s.spawn(|| run_tagged_tail(&err_log, "stderr", false, cap, stop, discard)); + s.spawn(|| { + run_tagged_tail( + console_log, + stop, + discard, + TaggedTailOptions { + stream: "stdout", + filter_noise: false, + bound: cap, + ready, + eof_policy, + }, + ) + }); + s.spawn(|| { + run_tagged_tail( + &err_log, + stop, + discard, + TaggedTailOptions { + stream: "stderr", + filter_noise: false, + bound: cap, + ready, + eof_policy, + }, + ) + }); }); } @@ -362,6 +495,8 @@ fn run_json_file_processor( max_size: u64, max_file: u32, stop: &AtomicBool, + ready: Option<&std::sync::atomic::AtomicUsize>, + eof_policy: ConsoleEofPolicy, ) { let json_path = json_log_path(log_dir); let writer = std::sync::Mutex::new(match RotatingWriter::new(&json_path, max_size, max_file) { @@ -388,24 +523,53 @@ fn run_json_file_processor( let cap = Some(console_cap(max_size, max_file)); std::thread::scope(|s| { - s.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit)); + s.spawn(|| { + run_tagged_tail( + console_log, + stop, + emit, + TaggedTailOptions { + stream: "stdout", + filter_noise: true, + bound: cap, + ready, + eof_policy, + }, + ) + }); // libkrun's `init.krun:` preamble can land on EITHER stream, so filter // the noise on stderr too. - s.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit)); + s.spawn(|| { + run_tagged_tail( + &err_log, + stop, + emit, + TaggedTailOptions { + stream: "stderr", + filter_noise: true, + bound: cap, + ready, + eof_policy, + }, + ) + }); }); } /// Forward both console streams (stdout + stderr) to a syslog endpoint. fn run_syslog_processor( console_log: &Path, - address: &str, - _facility: &str, - tag: &str, - cap: Option, + config: &LogConfig, stop: &AtomicBool, + ready: Option<&std::sync::atomic::AtomicUsize>, + eof_policy: ConsoleEofPolicy, ) { use std::net::UdpSocket; + let address = config.syslog_address(); + let _facility = config.syslog_facility(); + let tag = config.tag().unwrap_or("a3s-box"); + let cap = Some(console_cap(config.max_size(), config.max_file())); let (proto, addr) = if let Some(rest) = address.strip_prefix("udp://") { ("udp", rest) } else if let Some(rest) = address.strip_prefix("tcp://") { @@ -428,8 +592,34 @@ fn run_syslog_processor( }; let emit: &(dyn Fn(&str, &str) + Sync) = &emit; std::thread::scope(|s| { - s.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit)); - s.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit)); + s.spawn(|| { + run_tagged_tail( + console_log, + stop, + emit, + TaggedTailOptions { + stream: "stdout", + filter_noise: true, + bound: cap, + ready, + eof_policy, + }, + ) + }); + s.spawn(|| { + run_tagged_tail( + &err_log, + stop, + emit, + TaggedTailOptions { + stream: "stderr", + filter_noise: true, + bound: cap, + ready, + eof_policy, + }, + ) + }); }); } "tcp" => { @@ -450,8 +640,34 @@ fn run_syslog_processor( }; let emit: &(dyn Fn(&str, &str) + Sync) = &emit; std::thread::scope(|sc| { - sc.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit)); - sc.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit)); + sc.spawn(|| { + run_tagged_tail( + console_log, + stop, + emit, + TaggedTailOptions { + stream: "stdout", + filter_noise: true, + bound: cap, + ready, + eof_policy, + }, + ) + }); + sc.spawn(|| { + run_tagged_tail( + &err_log, + stop, + emit, + TaggedTailOptions { + stream: "stderr", + filter_noise: true, + bound: cap, + ready, + eof_policy, + }, + ) + }); }); } _ => {} @@ -603,6 +819,33 @@ mod tests { assert!(json.contains("\"stream\":\"stdout\"")); } + #[test] + fn sandbox_log_worker_spec_round_trips_generation_identity() { + let spec = SandboxLogWorkerSpec { + schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(), + box_id: "sandbox-id".to_string(), + console_log: PathBuf::from("/tmp/sandbox-id/logs/console.log"), + log_config: LogConfig::default(), + watched_pid: 123, + watched_pid_start_time: 456, + ready_file: PathBuf::from("/tmp/sandbox-id/sandbox/log-worker.ready"), + }; + + let encoded = serde_json::to_vec(&spec).unwrap(); + let decoded: SandboxLogWorkerSpec = serde_json::from_slice(&encoded).unwrap(); + + assert_eq!(decoded, spec); + } + + #[test] + fn writer_closed_eof_skips_the_late_console_settle_window() { + assert_eq!( + stopped_eof_settle_polls(ConsoleEofPolicy::MayReceiveLateWrites), + 25 + ); + assert_eq!(stopped_eof_settle_polls(ConsoleEofPolicy::WriterClosed), 1); + } + #[test] fn test_syslog_config_defaults() { let config = LogConfig { @@ -657,14 +900,35 @@ mod tests { let mut buf = String::new(); let stop = AtomicBool::new(true); assert_eq!( - tail_next_line(&mut reader, &mut buf, &stop, None), + tail_next_line( + &mut reader, + &mut buf, + &stop, + None, + ConsoleEofPolicy::MayReceiveLateWrites, + ), Some("alpha".to_string()) ); assert_eq!( - tail_next_line(&mut reader, &mut buf, &stop, None), + tail_next_line( + &mut reader, + &mut buf, + &stop, + None, + ConsoleEofPolicy::MayReceiveLateWrites, + ), Some("beta".to_string()) ); - assert_eq!(tail_next_line(&mut reader, &mut buf, &stop, None), None); + assert_eq!( + tail_next_line( + &mut reader, + &mut buf, + &stop, + None, + ConsoleEofPolicy::MayReceiveLateWrites, + ), + None + ); assert!(buf.is_empty()); } @@ -677,10 +941,25 @@ mod tests { let mut buf = String::new(); let stop = AtomicBool::new(true); assert_eq!( - tail_next_line(&mut reader, &mut buf, &stop, None), + tail_next_line( + &mut reader, + &mut buf, + &stop, + None, + ConsoleEofPolicy::MayReceiveLateWrites, + ), Some("only-partial".to_string()) ); - assert_eq!(tail_next_line(&mut reader, &mut buf, &stop, None), None); + assert_eq!( + tail_next_line( + &mut reader, + &mut buf, + &stop, + None, + ConsoleEofPolicy::MayReceiveLateWrites, + ), + None + ); } #[test] @@ -716,7 +995,18 @@ mod tests { let handle = std::thread::spawn(move || { let emit = move |line: &str, _stream: &str| c2.lock().unwrap().push(line.to_string()); let emit: &(dyn Fn(&str, &str) + Sync) = &emit; - run_tagged_tail(&p2, "stdout", false, Some(cap), &s2, emit); + run_tagged_tail( + &p2, + &s2, + emit, + TaggedTailOptions { + stream: "stdout", + filter_noise: false, + bound: Some(cap), + ready: None, + eof_policy: ConsoleEofPolicy::MayReceiveLateWrites, + }, + ); }); // Let the tail drain l1..l3, hit EOF, and truncate (9 bytes > cap 4). @@ -747,6 +1037,51 @@ mod tests { ); } + #[test] + fn test_stopped_tail_waits_for_delayed_final_console_write() { + use std::io::Write as _; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("console.log"); + std::fs::write(&path, b"").unwrap(); + let writer_path = path.clone(); + let writer = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(30)); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(writer_path) + .unwrap(); + file.write_all(b"late-final-line\n").unwrap(); + file.flush().unwrap(); + }); + + let file = std::fs::File::open(&path).unwrap(); + let mut reader = BufReader::new(file); + let mut buffer = String::new(); + let stop = AtomicBool::new(true); + assert_eq!( + tail_next_line( + &mut reader, + &mut buffer, + &stop, + None, + ConsoleEofPolicy::MayReceiveLateWrites, + ), + Some("late-final-line".to_string()) + ); + assert_eq!( + tail_next_line( + &mut reader, + &mut buffer, + &stop, + None, + ConsoleEofPolicy::MayReceiveLateWrites, + ), + None + ); + writer.join().unwrap(); + } + #[test] fn test_none_driver_still_bounds_console() { use std::sync::Arc; @@ -799,7 +1134,15 @@ mod tests { let console = dir.path().join("console.log"); std::fs::write(&console, "AAA\ninit.krun: noise\nBBB\n").unwrap(); let stop = AtomicBool::new(true); - run_json_file_processor(&console, dir.path(), 10 * 1024 * 1024, 3, &stop); + run_json_file_processor( + &console, + dir.path(), + 10 * 1024 * 1024, + 3, + &stop, + None, + ConsoleEofPolicy::MayReceiveLateWrites, + ); let json = std::fs::read_to_string(json_log_path(dir.path())).unwrap(); assert!(json.contains("\"log\":\"AAA\\n\""), "AAA missing: {json}"); assert!( diff --git a/src/core/src/network.rs b/src/core/src/network.rs index b6fff7bb..5608437a 100644 --- a/src/core/src/network.rs +++ b/src/core/src/network.rs @@ -443,6 +443,19 @@ impl NetworkConfig { }) } + /// Validate the driver and policy that the runtime can enforce today. + pub fn validate_runtime(&self) -> Result<(), String> { + if self.driver != "bridge" { + return Err(format!( + "Unsupported network driver '{}'. Only 'bridge' is currently supported", + self.driver + )); + } + self.policy + .validate() + .map_err(|error| format!("Unsupported network isolation mode: {error}")) + } + /// Allocate an IP and register a new endpoint for a box. pub fn connect(&mut self, box_id: &str, box_name: &str) -> Result { self.connect_with_aliases(box_id, box_name, &[]) @@ -1180,6 +1193,23 @@ mod tests { assert!(err.contains("not yet enforced")); } + #[test] + fn test_network_config_runtime_validation_accepts_supported_configuration() { + let network = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap(); + + assert!(network.validate_runtime().is_ok()); + } + + #[test] + fn test_network_config_runtime_validation_rejects_unsupported_driver() { + let mut network = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap(); + network.driver = "overlay".to_string(); + + let error = network.validate_runtime().unwrap_err(); + + assert!(error.contains("Unsupported network driver")); + } + // --- NetworkConfig::set_policy tests --- #[test] diff --git a/src/core/src/platform.rs b/src/core/src/platform.rs index e09379db..eed2bc73 100644 --- a/src/core/src/platform.rs +++ b/src/core/src/platform.rs @@ -229,6 +229,9 @@ impl PlatformCapabilities { named_pipes: cfg!(windows), netproxy: cfg!(target_os = "macos"), bridge_network_backend: current_bridge_network_backend(), + // Linux passt provides full outbound NAT. The macOS netproxy + // deliberately exposes only DNS and TCP host proxying, so it must + // not advertise full NAT (which would also imply UDP and ICMP). bridge_outbound_nat: cfg!(target_os = "linux"), published_ports: cfg!(unix) || cfg!(windows), tee_attestation: cfg!(unix), @@ -259,7 +262,7 @@ impl PlatformCapabilities { "passt (peer networking and outbound NAT supported)".to_string() } BridgeNetworkBackend::Netproxy => { - "netproxy (peer networking supported; outbound NAT unsupported)".to_string() + "netproxy (peer networking and outbound TCP proxying supported)".to_string() } BridgeNetworkBackend::Unsupported => "unsupported".to_string(), } @@ -488,7 +491,7 @@ mod tests { } #[test] - fn test_bridge_networking_summary_documents_nat_boundary() { + fn test_bridge_networking_summary_documents_outbound_support() { let mut capabilities = PlatformCapabilities::current(); capabilities.bridge_network_backend = BridgeNetworkBackend::Netproxy; capabilities.bridge_outbound_nat = false; @@ -496,6 +499,7 @@ mod tests { let summary = capabilities.bridge_networking_summary(); assert!(summary.contains("netproxy")); - assert!(summary.contains("outbound NAT unsupported")); + assert!(summary.contains("outbound TCP proxying supported")); + assert!(!capabilities.bridge_outbound_nat); } } diff --git a/src/core/src/rootfs_metadata.rs b/src/core/src/rootfs_metadata.rs new file mode 100644 index 00000000..c1fd9237 --- /dev/null +++ b/src/core/src/rootfs_metadata.rs @@ -0,0 +1,99 @@ +//! Guest-captured filesystem metadata used by stopped-box commit. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +/// Location written inside a persistent rootfs before guest shutdown. +pub const ROOTFS_METADATA_PATH: &str = "/.a3s_rootfs_metadata_v1.json"; +/// Location used to carry OCI header ownership across a rootless host extraction. +pub const IMAGE_ROOTFS_METADATA_PATH: &str = "/.a3s_image_metadata_v1.json"; +/// Runtime-staged container environment consumed by guest-init before exec. +pub const RUNTIME_ENV_PATH: &str = "/.a3s-box-env"; +/// Stable manifest schema identifier. +pub const ROOTFS_METADATA_SCHEMA: &str = "a3s.box.rootfs-metadata.v1"; + +/// Return the canonical mode for rootfs files generated by the runtime. +/// +/// OCI image and terminal manifests describe the image or previous container +/// generation. The runtime rewrites these files for every launch, so replaying +/// older manifest metadata after that write would either reject the refreshed +/// guest init or make active resolver and hostname configuration inaccessible +/// to non-root image users. +pub fn runtime_managed_rootfs_mode(path: &Path) -> Option { + match path.to_str() { + Some("etc/hostname" | "etc/hosts" | "etc/resolv.conf") => Some(0o644), + Some("sbin/init" | "usr/sbin/init") => Some(0o755), + Some(".a3s-box-env") => Some(0o600), + _ => None, + } +} + +/// Metadata kind supported by OCI rootfs archives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RootfsEntryKind { + Directory, + Regular, + Symlink, +} + +/// One guest-visible filesystem entry. Paths are base64-encoded raw Unix bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RootfsMetadataEntry { + pub path_base64: String, + pub kind: RootfsEntryKind, + pub mode: u32, + pub uid: u64, + pub gid: u64, + pub mtime: u64, + pub size: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub link_target_base64: Option, +} + +/// Complete terminal metadata snapshot for one rootfs generation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RootfsMetadataManifest { + pub schema: String, + pub entries: Vec, +} + +impl RootfsMetadataManifest { + pub fn new(entries: Vec) -> Self { + Self { + schema: ROOTFS_METADATA_SCHEMA.to_string(), + entries, + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema != ROOTFS_METADATA_SCHEMA { + return Err(format!( + "unsupported rootfs metadata schema: {}", + self.schema + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_managed_rootfs_files_have_canonical_modes() { + for path in ["etc/hostname", "etc/hosts", "etc/resolv.conf"] { + assert_eq!(runtime_managed_rootfs_mode(Path::new(path)), Some(0o644)); + } + for path in ["sbin/init", "usr/sbin/init"] { + assert_eq!(runtime_managed_rootfs_mode(Path::new(path)), Some(0o755)); + } + assert_eq!( + runtime_managed_rootfs_mode(Path::new(".a3s-box-env")), + Some(0o600) + ); + assert_eq!(runtime_managed_rootfs_mode(Path::new("etc/passwd")), None); + } +} diff --git a/src/core/src/snapshot.rs b/src/core/src/snapshot.rs index 331bc030..ec549e3f 100644 --- a/src/core/src/snapshot.rs +++ b/src/core/src/snapshot.rs @@ -4,11 +4,74 @@ //! can be reconstructed from the saved spec. Combined with rootfs caching, //! restore achieves sub-500ms cold start. +use crate::error::{BoxError, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +/// Resolved OCI image defaults required to reproduce a captured rootfs. +/// +/// These values are distinct from [`SnapshotMetadata`] command, environment, +/// and working-directory fields: those fields are user overrides, while this +/// structure records the immutable defaults resolved from the source image. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SnapshotImageConfig { + /// Image entrypoint. + #[serde(default)] + pub entrypoint: Option>, + /// Image command arguments. + #[serde(default)] + pub cmd: Option>, + /// Image environment in declaration order. + #[serde(default)] + pub env: Vec<(String, String)>, + /// Image working directory. + #[serde(default)] + pub working_dir: Option, + /// Image user. + #[serde(default)] + pub user: Option, + /// Ports declared by the image. + #[serde(default)] + pub exposed_ports: Vec, + /// Image labels. + #[serde(default)] + pub labels: HashMap, + /// Volumes declared by the image. + #[serde(default)] + pub volumes: Vec, + /// Image stop signal. + #[serde(default)] + pub stop_signal: Option, + /// Image health check. + #[serde(default)] + pub health_check: Option, + /// Image ONBUILD triggers. + #[serde(default)] + pub onbuild: Vec, +} + +/// Health-check defaults embedded in a resolved OCI image configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SnapshotImageHealthCheck { + /// Health-check command. + #[serde(default)] + pub test: Vec, + /// Interval in seconds. + #[serde(default)] + pub interval: Option, + /// Timeout in seconds. + #[serde(default)] + pub timeout: Option, + /// Retry count. + #[serde(default)] + pub retries: Option, + /// Start period in seconds. + #[serde(default)] + pub start_period: Option, +} + /// Metadata for a saved VM snapshot. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapshotMetadata { @@ -36,6 +99,9 @@ pub struct SnapshotMetadata { /// Working directory inside the box #[serde(default)] pub workdir: Option, + /// Resolved defaults from the source OCI image. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_config: Option, /// Port mappings #[serde(default)] pub port_map: Vec, @@ -82,6 +148,7 @@ impl SnapshotMetadata { cmd: Vec::new(), entrypoint: None, workdir: None, + image_config: None, port_map: Vec::new(), labels: HashMap::new(), network_mode: None, @@ -104,6 +171,20 @@ impl SnapshotMetadata { self.memory_mb = memory_mb; self } + + /// Return the captured OCI image defaults required for a safe restore. + /// + /// Snapshot records created before this field existed remain readable so + /// operators can inspect and delete them, but restoring one would lose + /// image entrypoint, environment, user, and working-directory semantics. + pub fn require_image_config(&self) -> Result<&SnapshotImageConfig> { + self.image_config.as_ref().ok_or_else(|| { + BoxError::ConfigError(format!( + "Snapshot '{}' does not contain resolved OCI image configuration and cannot be restored safely; recreate it with the current A3S Box version", + self.id + )) + }) + } } /// Configuration for snapshot operations. @@ -151,6 +232,7 @@ mod tests { assert!(meta.volumes.is_empty()); assert!(meta.env.is_empty()); assert!(meta.description.is_empty()); + assert!(meta.image_config.is_none()); } #[test] @@ -201,6 +283,7 @@ mod tests { assert_eq!(parsed.id, "snap-old"); assert_eq!(parsed.size_bytes, 0); assert_eq!(parsed.created_at, DateTime::::UNIX_EPOCH); + assert!(parsed.require_image_config().is_err()); } #[test] @@ -218,6 +301,28 @@ mod tests { meta.cmd = vec!["nginx".to_string(), "-g".to_string()]; meta.entrypoint = Some(vec!["/docker-entrypoint.sh".to_string()]); meta.workdir = Some("/app".to_string()); + meta.image_config = Some(SnapshotImageConfig { + entrypoint: Some(vec!["/usr/bin/envd".to_string()]), + cmd: Some(vec!["--listen".to_string(), "0.0.0.0:49983".to_string()]), + env: vec![ + ("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string()), + ("HOME".to_string(), "/home/user".to_string()), + ], + working_dir: Some("/home/user".to_string()), + user: Some("1000:1000".to_string()), + exposed_ports: vec!["49983/tcp".to_string()], + labels: HashMap::from([("runtime".to_string(), "envd".to_string())]), + volumes: vec!["/home/user".to_string()], + stop_signal: Some("SIGTERM".to_string()), + health_check: Some(SnapshotImageHealthCheck { + test: vec!["CMD".to_string(), "envd-health".to_string()], + interval: Some(10), + timeout: Some(2), + retries: Some(3), + start_period: Some(5), + }), + onbuild: vec!["RUN prepare-runtime".to_string()], + }); meta.port_map = vec!["8080:80".to_string()]; meta.labels.insert("env".to_string(), "prod".to_string()); meta.network_mode = Some("bridge".to_string()); @@ -242,6 +347,7 @@ mod tests { Some(vec!["/docker-entrypoint.sh".to_string()]) ); assert_eq!(parsed.workdir, Some("/app".to_string())); + assert_eq!(parsed.image_config, meta.image_config); assert_eq!(parsed.port_map, vec!["8080:80"]); assert_eq!(parsed.labels.get("env").unwrap(), "prod"); assert_eq!(parsed.network_mode, Some("bridge".to_string())); @@ -270,6 +376,7 @@ mod tests { assert_eq!(meta.id, "snap-min"); assert!(meta.entrypoint.is_none()); assert!(meta.workdir.is_none()); + assert!(meta.image_config.is_none()); assert!(meta.port_map.is_empty()); assert!(meta.labels.is_empty()); assert!(meta.network_mode.is_none()); diff --git a/src/core/src/traits/execution.rs b/src/core/src/traits/execution.rs new file mode 100644 index 00000000..5b044b79 --- /dev/null +++ b/src/core/src/traits/execution.rs @@ -0,0 +1,694 @@ +//! Backend-neutral lifecycle interface for managed A3S executions. + +use std::collections::BTreeMap; +use std::num::NonZeroU16; +use std::pin::Pin; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::config::{BoxConfig, ResourceConfig}; +use crate::execution::ResolvedExecutionPlan; +use crate::log::{LogConfig, LogEntry}; + +/// Stable identifier assigned to one runtime execution. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ExecutionId(String); + +impl ExecutionId { + pub fn new(value: impl Into) -> ExecutionManagerResult { + let value = value.into(); + if value.trim().is_empty() { + return Err(ExecutionManagerError::InvalidRequest( + "execution ID cannot be empty".to_string(), + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ExecutionId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl TryFrom for ExecutionId { + type Error = ExecutionManagerError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: ExecutionId) -> Self { + value.0 + } +} + +/// Opaque identifier for one runtime-managed filesystem snapshot. +/// +/// Snapshot identifiers are used as directory names below the runtime's +/// managed snapshot root. Keeping the lexical contract here prevents callers +/// from turning a protocol template reference into an arbitrary host path. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ExecutionSnapshotId(String); + +impl ExecutionSnapshotId { + pub fn new(value: impl Into) -> ExecutionManagerResult { + let value = value.into(); + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(ExecutionManagerError::InvalidRequest( + "execution snapshot ID must match [A-Za-z0-9_-]{1,128}".to_string(), + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ExecutionSnapshotId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl TryFrom for ExecutionSnapshotId { + type Error = ExecutionManagerError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: ExecutionSnapshotId) -> Self { + value.0 + } +} + +/// Idempotency identity for a lifecycle operation. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct OperationId(String); + +impl OperationId { + pub fn new(value: impl Into) -> ExecutionManagerResult { + let value = value.into(); + if value.trim().is_empty() { + return Err(ExecutionManagerError::InvalidRequest( + "operation ID cannot be empty".to_string(), + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for OperationId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl TryFrom for OperationId { + type Error = ExecutionManagerError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: OperationId) -> Self { + value.0 + } +} + +/// Runtime generation used to reject stale lifecycle operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "u64", into = "u64")] +pub struct ExecutionGeneration(u64); + +impl ExecutionGeneration { + pub const INITIAL: Self = Self(1); + + pub fn new(value: u64) -> ExecutionManagerResult { + if value == 0 { + return Err(ExecutionManagerError::InvalidRequest( + "execution generation must be greater than zero".to_string(), + )); + } + Ok(Self(value)) + } + + pub const fn get(self) -> u64 { + self.0 + } +} + +impl TryFrom for ExecutionGeneration { + type Error = ExecutionManagerError; + + fn try_from(value: u64) -> Result { + Self::new(value) + } +} + +impl From for u64 { + fn from(value: ExecutionGeneration) -> Self { + value.0 + } +} + +/// Restart behavior persisted with a local execution. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExecutionRestartPolicy { + /// Never restart automatically. + #[default] + No, + /// Restart after every exit. + Always, + /// Restart only after an unsuccessful exit. + OnFailure, + /// Restart unless a user explicitly stopped the execution. + UnlessStopped, +} + +impl ExecutionRestartPolicy { + /// Canonical value stored in the backwards-compatible local record. + pub const fn as_str(self) -> &'static str { + match self { + Self::No => "no", + Self::Always => "always", + Self::OnFailure => "on-failure", + Self::UnlessStopped => "unless-stopped", + } + } +} + +/// Health-check behavior persisted with a local execution. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionHealthCheck { + /// Command executed by the health check. + pub cmd: Vec, + /// Interval between checks in seconds. + #[serde(default = "default_health_interval")] + pub interval_secs: u64, + /// Per-check timeout in seconds. + #[serde(default = "default_health_timeout")] + pub timeout_secs: u64, + /// Consecutive failures before the execution is unhealthy. + #[serde(default = "default_health_retries")] + pub retries: u32, + /// Grace period after startup in seconds. + #[serde(default)] + pub start_period_secs: u64, +} + +/// Caller-owned policy projected into the canonical local execution record. +/// +/// The complete value is persisted with the creation request so retries cannot +/// silently reuse an execution with different lifecycle or local resource +/// policy. Runtime launch requirements remain in [`BoxConfig`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExecutionRecordPolicy { + /// User-visible local name. `None` lets the runtime assign a safe name. + #[serde(default)] + pub name: Option, + /// Remove the execution automatically after it stops. + #[serde(default)] + pub auto_remove: bool, + /// Automatic restart behavior. + #[serde(default)] + pub restart_policy: ExecutionRestartPolicy, + /// Maximum automatic restart count, where zero means unlimited. + #[serde(default)] + pub max_restart_count: u32, + /// Effective caller or cached-image health check. + #[serde(default)] + pub health_check: Option, + /// Prevent a later image-defined health check from being enabled. + #[serde(default)] + pub healthcheck_disabled: bool, + /// Runtime log driver policy. + #[serde(default)] + pub log_config: LogConfig, + /// Named volumes represented by the resolved mounts in [`BoxConfig`]. + #[serde(default)] + pub volume_names: Vec, + /// Requested OCI platform retained for inspection and image selection. + #[serde(default)] + pub platform: Option, + /// Whether the caller requested an init process. + #[serde(default)] + pub init: bool, + /// Requested host device mappings. + #[serde(default)] + pub devices: Vec, + /// Requested GPU selection. + #[serde(default)] + pub gpus: Option, + /// Shared-memory size in bytes. + #[serde(default)] + pub shm_size: Option, + /// Signal used for graceful stop. + #[serde(default)] + pub stop_signal: Option, + /// Graceful stop timeout in seconds. + #[serde(default)] + pub stop_timeout: Option, + /// Whether the caller requested OOM-killer suppression. + #[serde(default)] + pub oom_kill_disable: bool, + /// Requested host OOM score adjustment. + #[serde(default)] + pub oom_score_adj: Option, +} + +impl Default for ExecutionRecordPolicy { + fn default() -> Self { + Self { + name: None, + auto_remove: false, + restart_policy: ExecutionRestartPolicy::No, + max_restart_count: 0, + health_check: None, + healthcheck_disabled: false, + log_config: LogConfig::default(), + volume_names: Vec::new(), + platform: None, + init: false, + devices: Vec::new(), + gpus: None, + shm_size: None, + stop_signal: None, + stop_timeout: None, + oom_kill_disable: false, + oom_score_adj: None, + } + } +} + +fn default_health_interval() -> u64 { + 30 +} + +fn default_health_timeout() -> u64 { + 5 +} + +fn default_health_retries() -> u32 { + 3 +} + +/// A fully resolved request submitted to the runtime lifecycle facade. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateExecutionRequest { + /// Public identity used only as an untrusted diagnostic label. + pub external_sandbox_id: String, + /// Backend-neutral runtime configuration resolved from template policy. + pub config: BoxConfig, + /// Labels persisted with the internal execution. + pub labels: BTreeMap, + /// Caller-owned lifecycle and local record policy. + #[serde(default)] + pub policy: ExecutionRecordPolicy, + /// Runtime-managed filesystem snapshot used as this execution's immutable + /// rootfs lower. The runtime derives the host path from this validated ID; + /// callers never supply a host path. + #[serde(default)] + pub rootfs_snapshot_id: Option, +} + +/// Durable evidence returned after an execution is created but not started. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionReservation { + pub execution_id: ExecutionId, + pub generation: ExecutionGeneration, + pub plan: ResolvedExecutionPlan, + pub resources: ResourceConfig, + pub created_at: DateTime, +} + +/// Evidence returned when a runtime execution is ready. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionLease { + pub execution_id: ExecutionId, + pub generation: ExecutionGeneration, + pub plan: ResolvedExecutionPlan, + pub resources: ResourceConfig, + pub started_at: DateTime, +} + +/// Result of atomically capturing one execution filesystem. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionSnapshot { + pub snapshot_id: ExecutionSnapshotId, + pub size_bytes: u64, + /// Stable state restored after the temporary snapshot pause. + pub state: ExecutionState, + /// Generation-fenced runtime evidence after snapshot completion. + pub lease: ExecutionLease, +} + +/// Runtime state visible through the backend-neutral lifecycle facade. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionState { + Created, + Creating, + Running, + Paused, + Stopped, + Failed, +} + +/// Current state and generation of one execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionStatus { + pub execution_id: ExecutionId, + pub generation: ExecutionGeneration, + pub state: ExecutionState, + pub plan: ResolvedExecutionPlan, +} + +/// Result of an idempotent runtime kill request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KillOutcome { + Killed, + AlreadyStopped, +} + +/// Per-operation controls persisted with an idempotent restart. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RestartExecutionOptions { + /// Graceful stop deadline for the old runtime. `None` uses persisted + /// execution policy or the backend default. + #[serde(default)] + pub stop_timeout_secs: Option, +} + +/// Runtime evidence recovered after a service restart. +#[derive(Debug, Clone)] +pub enum ReconcileOutcome { + Absent, + Created(ExecutionReservation), + Creating, + Ready(ExecutionLease), + Failed, +} + +/// Errors returned by the lifecycle facade without exposing backend internals. +#[derive(Debug, Error)] +pub enum ExecutionManagerError { + #[error("invalid execution request: {0}")] + InvalidRequest(String), + #[error("execution not found: {0}")] + NotFound(ExecutionId), + #[error("execution conflict for {execution_id}: {message}")] + Conflict { + execution_id: ExecutionId, + message: String, + }, + #[error("execution backend unavailable: {0}")] + Unavailable(String), + #[error("execution lifecycle failed: {0}")] + Internal(String), +} + +pub type ExecutionManagerResult = std::result::Result; + +/// Bidirectional byte stream connected to one generation-fenced workload port. +pub trait ExecutionPortIo: AsyncRead + AsyncWrite + Send + Unpin {} + +impl ExecutionPortIo for T where T: AsyncRead + AsyncWrite + Send + Unpin {} + +pub type ExecutionPortStream = Pin>; + +/// Backend-neutral connector used by data-plane gateways. +/// +/// Implementations must validate the execution generation atomically with +/// selecting the live runtime. A connector must never fall back to another +/// execution or generation when the requested runtime is unavailable. +#[async_trait] +pub trait ExecutionPortConnector: Send + Sync { + async fn connect_port( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + port: NonZeroU16, + timeout: Duration, + ) -> ExecutionManagerResult; +} + +/// Backend-neutral lifecycle facade shared by the CLI, SDK, and remote service. +#[async_trait] +pub trait ExecutionManager: Send + Sync { + /// Persist exactly one unstarted execution reservation for `operation_id`. + async fn create( + &self, + _request: CreateExecutionRequest, + _operation_id: &OperationId, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::Unavailable( + "this execution manager does not support staged create".to_string(), + )) + } + + /// Start one created execution after fencing stale callers by generation. + async fn start( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::Unavailable( + "this execution manager does not support staged start".to_string(), + )) + } + + /// Create and start exactly one execution for `operation_id`. + /// + /// Retrying after a crash reuses the durable reservation and continues its + /// start instead of allocating a second execution. + async fn create_and_start( + &self, + request: CreateExecutionRequest, + operation_id: &OperationId, + ) -> ExecutionManagerResult { + let reservation = self.create(request, operation_id).await?; + self.start(&reservation.execution_id, reservation.generation) + .await + } + + async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult; + + /// Read structured stdout/stderr entries after fencing the runtime generation. + async fn read_logs( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + ) -> ExecutionManagerResult> { + Err(ExecutionManagerError::Unavailable( + "this execution manager does not expose structured logs".to_string(), + )) + } + + /// Temporarily quiesce the execution, atomically capture its rootfs in the + /// runtime-managed snapshot store, and restore its prior stable state. + async fn create_filesystem_snapshot( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::Unavailable( + "this execution manager does not support filesystem snapshots".to_string(), + )) + } + + /// Return the size of a fully published runtime-managed snapshot, or + /// `None` when it does not exist. + async fn filesystem_snapshot_size( + &self, + _snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult> { + Err(ExecutionManagerError::Unavailable( + "this execution manager does not expose filesystem snapshots".to_string(), + )) + } + + /// Delete a runtime-managed snapshot, refusing while an active execution + /// still uses it as a copy-on-write lower. + async fn delete_filesystem_snapshot( + &self, + _snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::Unavailable( + "this execution manager does not support filesystem snapshot deletion".to_string(), + )) + } + + /// Pause one execution and return the generation-fenced paused lease. + async fn pause( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + keep_memory: bool, + ) -> ExecutionManagerResult; + + async fn resume( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult; + + /// Terminate the current runtime, advance its generation exactly once, + /// and start it again under an idempotent operation identity. + async fn restart( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + operation_id: &OperationId, + ) -> ExecutionManagerResult { + self.restart_with_options( + execution_id, + generation, + operation_id, + RestartExecutionOptions::default(), + ) + .await + } + + /// Restart with controls that become part of the durable operation intent. + async fn restart_with_options( + &self, + _execution_id: &ExecutionId, + _generation: ExecutionGeneration, + _operation_id: &OperationId, + _options: RestartExecutionOptions, + ) -> ExecutionManagerResult { + Err(ExecutionManagerError::Unavailable( + "this execution manager does not support restart".to_string(), + )) + } + + async fn kill( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult; + + async fn reconcile( + &self, + operation_id: &OperationId, + ) -> ExecutionManagerResult; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifiers_reject_empty_values() { + assert!(matches!( + ExecutionId::new(" "), + Err(ExecutionManagerError::InvalidRequest(_)) + )); + assert!(matches!( + OperationId::new(""), + Err(ExecutionManagerError::InvalidRequest(_)) + )); + } + + #[test] + fn generation_rejects_zero() { + assert!(matches!( + ExecutionGeneration::new(0), + Err(ExecutionManagerError::InvalidRequest(_)) + )); + assert_eq!(ExecutionGeneration::INITIAL.get(), 1); + assert!(serde_json::from_str::("0").is_err()); + } + + #[test] + fn identifier_deserialization_preserves_invariants() { + assert!(serde_json::from_str::("\"\"").is_err()); + assert!(serde_json::from_str::("\" \"").is_err()); + } + + #[test] + fn snapshot_identifiers_are_safe_managed_directory_names() { + for valid in ["snapshot-1", "SNAPSHOT_2", "a"] { + assert_eq!(ExecutionSnapshotId::new(valid).unwrap().as_str(), valid); + } + for invalid in [ + "", + ".", + "..", + "../snapshot", + "snapshot/path", + "snapshot:tag", + "snapshot id", + ] { + assert!(matches!( + ExecutionSnapshotId::new(invalid), + Err(ExecutionManagerError::InvalidRequest(_)) + )); + } + assert!(ExecutionSnapshotId::new("x".repeat(129)).is_err()); + assert!(serde_json::from_str::("\"../snapshot\"").is_err()); + } + + #[test] + fn legacy_creation_requests_default_record_policy() { + let request: CreateExecutionRequest = serde_json::from_value(serde_json::json!({ + "external_sandbox_id": "sandbox-1", + "config": BoxConfig::default(), + "labels": {"purpose": "compatibility"} + })) + .unwrap(); + + assert_eq!(request.policy, ExecutionRecordPolicy::default()); + assert_eq!(request.policy.restart_policy, ExecutionRestartPolicy::No); + assert!(request.rootfs_snapshot_id.is_none()); + } + + #[test] + fn restart_policy_has_stable_record_values() { + assert_eq!(ExecutionRestartPolicy::No.as_str(), "no"); + assert_eq!(ExecutionRestartPolicy::Always.as_str(), "always"); + assert_eq!(ExecutionRestartPolicy::OnFailure.as_str(), "on-failure"); + assert_eq!( + ExecutionRestartPolicy::UnlessStopped.as_str(), + "unless-stopped" + ); + assert_eq!( + serde_json::to_value(ExecutionRestartPolicy::OnFailure).unwrap(), + "on-failure" + ); + } +} diff --git a/src/core/src/traits/mod.rs b/src/core/src/traits/mod.rs index 54e530d8..357e84c3 100644 --- a/src/core/src/traits/mod.rs +++ b/src/core/src/traits/mod.rs @@ -8,16 +8,28 @@ pub mod audit; pub mod cache; pub mod credential; pub mod event; +pub mod execution; pub mod metrics; pub mod registry; +pub mod session; pub mod store; pub use audit::AuditSink; pub use cache::{CacheBackend, CacheEntry, CacheStats}; pub use credential::CredentialProvider; pub use event::EventBus; +pub use execution::{ + CreateExecutionRequest, ExecutionGeneration, ExecutionHealthCheck, ExecutionId, ExecutionLease, + ExecutionManager, ExecutionManagerError, ExecutionManagerResult, ExecutionPortConnector, + ExecutionPortIo, ExecutionPortStream, ExecutionRecordPolicy, ExecutionReservation, + ExecutionRestartPolicy, ExecutionSnapshot, ExecutionSnapshotId, ExecutionState, + ExecutionStatus, KillOutcome, OperationId, ReconcileOutcome, RestartExecutionOptions, +}; pub use metrics::{MetricsCollector, NoopMetrics}; pub use registry::{ImageRegistry, PulledImage}; +pub use session::{ + ExecutionProcess, ExecutionProcessInput, ExecutionProcessStream, ExecutionSessionManager, +}; pub use store::{ ImageStoreBackend, NetworkStoreBackend, SnapshotStoreBackend, StoredImage, VolumeStoreBackend, }; diff --git a/src/core/src/traits/session.rs b/src/core/src/traits/session.rs new file mode 100644 index 00000000..36a3a263 --- /dev/null +++ b/src/core/src/traits/session.rs @@ -0,0 +1,76 @@ +//! Backend-neutral command, PTY, and file access for managed executions. + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::exec::{ExecEvent, ExecOutput, ExecRequest, FileRequest, FileResponse}; +use crate::pty::PtyRequest; + +use super::execution::{ + ExecutionGeneration, ExecutionId, ExecutionManagerError, ExecutionManagerResult, +}; + +/// Cloneable input/control side of one running execution process. +#[async_trait] +pub trait ExecutionProcessInput: Send + Sync { + async fn write_stdin(&self, data: &[u8]) -> ExecutionManagerResult<()>; + + async fn close_stdin(&self) -> ExecutionManagerResult<()>; + + async fn cancel(&self) -> ExecutionManagerResult<()>; + + async fn resize_pty(&self, cols: u16, rows: u16) -> ExecutionManagerResult<()> { + let _ = (cols, rows); + Err(ExecutionManagerError::InvalidRequest( + "process does not have a PTY".to_string(), + )) + } +} + +/// Event side of one running execution process. +#[async_trait] +pub trait ExecutionProcessStream: Send { + fn input(&self) -> Arc; + + async fn next_event(&mut self) -> ExecutionManagerResult>; +} + +pub type ExecutionProcess = Box; + +/// Generation-fenced process and filesystem access shared by compatibility +/// services and native SDK adapters. +/// +/// Implementations must bind the underlying runtime endpoint before their +/// final generation check. A generation change may fail an operation, but it +/// must never redirect the operation to the replacement runtime. +#[async_trait] +pub trait ExecutionSessionManager: Send + Sync { + async fn execute( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + request: ExecRequest, + ) -> ExecutionManagerResult; + + async fn start_process( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + request: ExecRequest, + ) -> ExecutionManagerResult; + + async fn start_pty( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + request: PtyRequest, + ) -> ExecutionManagerResult; + + async fn transfer_file( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + request: FileRequest, + ) -> ExecutionManagerResult; +} diff --git a/src/core/src/vmm.rs b/src/core/src/vmm.rs index 6ef681a5..e5cd9fd4 100644 --- a/src/core/src/vmm.rs +++ b/src/core/src/vmm.rs @@ -73,6 +73,11 @@ pub struct NetworkInstanceConfig { #[serde(default)] pub net_proxy_fd: Option, + /// Shared Unix-datagram Ethernet switch directory for this bridge network. + #[cfg(target_os = "macos")] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bridge_socket_dir: Option, + /// Assigned IPv4 address for this VM. pub ip_address: Ipv4Addr, @@ -507,6 +512,8 @@ mod tests { net_socket_fd: Some(42), #[cfg(target_os = "macos")] net_proxy_fd: Some(43), + #[cfg(target_os = "macos")] + bridge_socket_dir: Some(PathBuf::from("/tmp/a3s-switch")), ip_address: "10.0.0.2".parse().unwrap(), gateway: "10.0.0.1".parse().unwrap(), prefix_len: 24, diff --git a/src/deps/libkrun-sys/Cargo.toml b/src/deps/libkrun-sys/Cargo.toml index 986209f8..1720e0b3 100644 --- a/src/deps/libkrun-sys/Cargo.toml +++ b/src/deps/libkrun-sys/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" authors = ["A3S Lab Team"] description = "FFI bindings to libkrun with Windows WHPX backend support" license = "MIT" -repository = "https://github.com/AI45Lab/Box" +repository = "https://github.com/A3S-Lab/Box" keywords = ["libkrun", "microvm", "whpx", "virtualization", "windows"] categories = ["api-bindings", "os::windows-apis", "virtualization"] readme = "README.md" @@ -27,4 +27,3 @@ windows-sys = { version = "0.59", features = ["Win32_Foundation"] } [features] default = [] - diff --git a/src/deps/libkrun-sys/README.md b/src/deps/libkrun-sys/README.md index 700f09f1..ec49ee8b 100644 --- a/src/deps/libkrun-sys/README.md +++ b/src/deps/libkrun-sys/README.md @@ -163,11 +163,21 @@ cargo run --example nginx_test --target x86_64-pc-windows-msvc ## Building from Source +### Linux and macOS + +The build script compiles the vendored libkrun automatically when a compatible +system or cached library is unavailable. Because libkrun invokes Cargo from +inside the outer Cargo build, the nested process uses a target-local Cargo home +next to the build outputs. That location is deliberately not configurable: +Cargo does not reliably expose the outer `CARGO_HOME` to build scripts, so an +override could accidentally reuse the package-cache lock and restore the +deadlock this isolation prevents. + ### Windows ```powershell # Clone with submodules -git clone --recursive https://github.com/AI45Lab/Box.git +git clone --recursive https://github.com/A3S-Lab/Box.git cd Box/src/deps/libkrun-sys # Build libkrun diff --git a/src/deps/libkrun-sys/build.rs b/src/deps/libkrun-sys/build.rs index 846c0bd8..06cf2636 100644 --- a/src/deps/libkrun-sys/build.rs +++ b/src/deps/libkrun-sys/build.rs @@ -1,6 +1,8 @@ // Allow unused code - these are used conditionally based on platform and build mode #![allow(dead_code)] +mod build_support; + use std::collections::HashMap; use std::env; use std::fs; @@ -16,23 +18,23 @@ use std::process::{Command, Stdio}; // macOS: Download prebuilt kernel.c, compile locally to .dylib #[cfg(all(target_os = "macos", target_arch = "aarch64"))] const LIBKRUNFW_PREBUILT_URL: &str = - "https://github.com/boxlite-ai/libkrunfw/releases/download/v5.1.0/libkrunfw-prebuilt-aarch64.tgz"; + "https://github.com/boxlite-ai/libkrunfw/releases/download/v5.3.0/libkrunfw-prebuilt-aarch64.tgz"; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] -const LIBKRUNFW_SHA256: &str = "2b2801d2e414140d8d0a30d7e30a011077b7586eabbbecdca42aea804b59de8b"; +const LIBKRUNFW_SHA256: &str = "12b9401d7735d1682450e4d025273c5016ec2237dcbfb76b2f0a152be6e606d6"; // Linux x86_64: Download pre-compiled .so directly (no build needed) #[cfg(all(target_os = "linux", target_arch = "x86_64"))] const LIBKRUNFW_SO_URL: &str = - "https://github.com/boxlite-ai/libkrunfw/releases/download/v5.1.0/libkrunfw-x86_64.tgz"; + "https://github.com/boxlite-ai/libkrunfw/releases/download/v5.3.0/libkrunfw-x86_64.tgz"; #[cfg(all(target_os = "linux", target_arch = "x86_64"))] -const LIBKRUNFW_SHA256: &str = "faca64a3581ce281498b8ae7eccc6bd0da99b167984f9ee39c47754531d4b37d"; +const LIBKRUNFW_SHA256: &str = "0a7bb64a35a273b8501801dd69b75736a8c676aa21aa62fb5642842cda9dc91d"; // Linux aarch64: Download pre-compiled .so directly (no build needed) #[cfg(all(target_os = "linux", target_arch = "aarch64"))] const LIBKRUNFW_SO_URL: &str = - "https://github.com/boxlite-ai/libkrunfw/releases/download/v5.1.0/libkrunfw-aarch64.tgz"; + "https://github.com/boxlite-ai/libkrunfw/releases/download/v5.3.0/libkrunfw-aarch64.tgz"; #[cfg(all(target_os = "linux", target_arch = "aarch64"))] -const LIBKRUNFW_SHA256: &str = "e254bc3fb07b32e26a258d9958967b2f22eb6c3136cfedf358c332308b6d35ea"; +const LIBKRUNFW_SHA256: &str = "8b5b9211da5445d9301dafb2201431f4392ab96455512bce63a5cfbd33c49839"; // libkrun build features (NET=1 BLK=1 enables network and block device support) // Note: TEE support (krun_set_tee_config_file) is loaded via dlsym at runtime @@ -54,8 +56,11 @@ const LIB_DIR: &str = "lib"; fn main() { // Rebuild if vendored sources change println!("cargo:rerun-if-changed=vendor/libkrun"); + println!("cargo:rerun-if-env-changed=A3S_DEPS_STUB"); // Re-evaluate the system-vs-vendored decision when the toggle changes. println!("cargo:rerun-if-env-changed=A3S_BUILD_LIBKRUN"); + println!("cargo:rerun-if-env-changed=A3S_USE_SYSTEM_LIBKRUN"); + println!("cargo:rerun-if-env-changed=A3S_LIBKRUNFW_DYLIB"); // Check for stub mode (for CI linting without building) // Set A3S_DEPS_STUB=1 to skip building and emit stub link directives @@ -74,8 +79,18 @@ fn main() { #[cfg(not(target_os = "windows"))] { + // The vendored macOS libkrun carries required TSI flow-control and + // reverse-proxy fixes newer than the 1.17.0 library shipped by older + // A3S Box formulae. Silently preferring that system dylib produces TCP + // listeners that accept connections but never move application data. + // Keep system linking as an explicit developer escape hatch only. + #[cfg(target_os = "macos")] + let force_vendored = env::var("A3S_USE_SYSTEM_LIBKRUN").is_err(); + #[cfg(not(target_os = "macos"))] + let force_vendored = false; + // Try to find system-installed libkrun first (unless A3S_BUILD_LIBKRUN is set) - if env::var("A3S_BUILD_LIBKRUN").is_err() { + if env::var("A3S_BUILD_LIBKRUN").is_err() && !force_vendored { if let Ok(lib_dir) = find_system_libkrun() { println!( "cargo:warning=Using system-installed libkrun from {}", @@ -363,11 +378,37 @@ fn make_command( install_dir: &Path, extra_env: &HashMap, ) -> Command { + let cargo_home = build_support::nested_cargo_home(install_dir); + fs::create_dir_all(&cargo_home).unwrap_or_else(|error| { + panic!( + "Failed to create nested libkrun Cargo home {}: {}", + cargo_home.display(), + error + ) + }); + let mut cmd = Command::new("make"); cmd.stdout(Stdio::inherit()); cmd.stderr(Stdio::inherit()); cmd.args(["-j", &num_cpus::get().to_string()]) .arg("MAKEFLAGS=") + // libkrun's Makefile invokes a nested Cargo build. Do not leak the + // outer workspace's clippy wrapper or lint flags into that independent + // vendored workspace: `cargo clippy -- -D warnings` must lint A3S code, + // not turn upstream warnings into a build-script failure. + .env_remove("RUSTC_WRAPPER") + .env_remove("RUSTC_WORKSPACE_WRAPPER") + .env_remove("RUSTFLAGS") + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .env_remove("CLIPPY_ARGS") + // The outer Cargo holds a shared lock in its package cache throughout + // this build script. libkrun's Makefile launches Cargo again, and that + // process can wait forever when it tries to upgrade the same cache to + // an exclusive mutation lock. An isolated Cargo home gives the nested + // process an independent lock domain. Also keep an outer target-dir + // override from moving artifacts away from paths expected by Make. + .env("CARGO_HOME", cargo_home) + .env_remove("CARGO_TARGET_DIR") .env("PREFIX", install_dir) .current_dir(source_dir); @@ -409,12 +450,70 @@ fn configure_linking(libkrun_dir: &Path, libkrunfw_dir: &Path) { "cargo:rustc-link-arg=-Wl,-rpath,{}", libkrunfw_dir.display() ); + stage_macos_runtime_libraries(libkrun_dir, libkrunfw_dir); } println!("cargo:LIBKRUN_A3S_DEP={}", libkrun_dir.display()); println!("cargo:LIBKRUNFW_A3S_DEP={}", libkrunfw_dir.display()); } +#[cfg(target_os = "macos")] +fn stage_macos_runtime_libraries(libkrun_dir: &Path, libkrunfw_dir: &Path) { + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is required")); + let target_root = target_root_from_out_dir(&out_dir) + .expect("failed to derive Cargo target root from OUT_DIR"); + let runtime_dir = target_root.join("lib"); + fs::create_dir_all(&runtime_dir).unwrap_or_else(|error| { + panic!( + "failed to create runtime library directory {}: {error}", + runtime_dir.display() + ) + }); + + for source_dir in [libkrun_dir, libkrunfw_dir] { + for entry in fs::read_dir(source_dir) + .unwrap_or_else(|error| panic!("failed to inspect {}: {error}", source_dir.display())) + { + let entry = entry.expect("failed to inspect runtime library entry"); + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.ends_with(".dylib") + || !(name.starts_with("libkrun.") || name.starts_with("libkrunfw.")) + { + continue; + } + let resolved = path.canonicalize().unwrap_or_else(|error| { + panic!( + "failed to resolve runtime library {}: {error}", + path.display() + ) + }); + let destination = runtime_dir.join(name); + let temporary = runtime_dir.join(format!(".{name}.tmp-{}", std::process::id())); + fs::copy(&resolved, &temporary).unwrap_or_else(|error| { + panic!( + "failed to stage runtime library {} at {}: {error}", + resolved.display(), + temporary.display() + ) + }); + fs::rename(&temporary, &destination).unwrap_or_else(|error| { + panic!( + "failed to activate runtime library {} at {}: {error}", + temporary.display(), + destination.display() + ) + }); + } + } + println!( + "cargo:warning=Staged macOS runtime libraries in {}", + runtime_dir.display() + ); +} + /// Downloads a file from URL to the specified path. fn download_file(url: &str, dest: &Path) -> io::Result<()> { println!("cargo:warning=Downloading {}...", url); @@ -663,11 +762,17 @@ fn fix_macos_libs(lib_dir: &Path, lib_prefix: &str) -> Result<(), String> { /// Downloads and extracts the prebuilt libkrunfw tarball (macOS). #[cfg(target_os = "macos")] fn download_libkrunfw_prebuilt(out_dir: &Path) -> PathBuf { - let tarball_path = out_dir.join("libkrunfw-prebuilt.tar.gz"); + let tarball_path = out_dir.join(format!( + "libkrunfw-prebuilt-{}.tar.gz", + &LIBKRUNFW_SHA256[..12] + )); let extract_dir = out_dir.join("libkrunfw-src"); let src_dir = extract_dir.join("libkrunfw"); + let marker = extract_dir.join(".source-sha256"); - if src_dir.join("kernel.c").exists() { + if src_dir.join("kernel.c").exists() + && fs::read_to_string(&marker).is_ok_and(|value| value == LIBKRUNFW_SHA256) + { println!("cargo:warning=Using cached libkrunfw source"); return src_dir; } @@ -685,6 +790,8 @@ fn download_libkrunfw_prebuilt(out_dir: &Path) -> PathBuf { } extract_tarball(&tarball_path, &extract_dir) .unwrap_or_else(|e| panic!("Failed to extract libkrunfw: {}", e)); + fs::write(&marker, LIBKRUNFW_SHA256) + .unwrap_or_else(|e| panic!("Failed to record libkrunfw source digest: {}", e)); println!("cargo:warning=Extracted libkrunfw to {}", src_dir.display()); src_dir @@ -706,12 +813,23 @@ fn build() { let libkrun_install = out_dir.join("libkrun"); let libkrunfw_lib = libkrunfw_install.join(LIB_DIR); let libkrun_lib = libkrun_install.join(LIB_DIR); + let firmware_marker = out_dir.join("libkrunfw-src/.source-sha256"); + let firmware_current = + fs::read_to_string(&firmware_marker).is_ok_and(|value| value == LIBKRUNFW_SHA256); // Skip build if outputs already exist (incremental build optimization) - if has_library(&libkrunfw_lib, "libkrunfw") && has_library(&libkrun_lib, "libkrun") { + if env::var("A3S_BUILD_LIBKRUN").is_err() + && firmware_current + && has_library(&libkrunfw_lib, "libkrunfw") + && has_library(&libkrun_lib, "libkrun") + { configure_linking(&libkrun_lib, &libkrunfw_lib); return; } + if !firmware_current { + let _ = fs::remove_dir_all(&libkrunfw_install); + let _ = fs::remove_dir_all(&libkrun_install); + } println!("cargo:warning=Building libkrun-sys for macOS (using prebuilt libkrunfw)"); @@ -723,16 +841,34 @@ fn build() { // Setup LIBCLANG_PATH for bindgen if needed setup_libclang_path(); - // 1. Download and extract prebuilt libkrunfw - let libkrunfw_src = download_libkrunfw_prebuilt(&out_dir); - - // 2. Build libkrunfw from prebuilt source (fast, just compiles kernel.c) - build_with_make( - &libkrunfw_src, - &libkrunfw_install, - "libkrunfw", - HashMap::new(), - ); + // 1-2. Use an explicitly built patched firmware when supplied; otherwise + // build the checksum-verified upstream prebuilt source bundle. The override + // is path-only and opt-in so release builds cannot silently consume ambient + // firmware. `firmware/build-patched-darwin-arm64.sh` produces this artifact. + if let Ok(override_path) = env::var("A3S_LIBKRUNFW_DYLIB") { + let override_path = PathBuf::from(override_path); + if !override_path.is_file() { + panic!( + "A3S_LIBKRUNFW_DYLIB is not a file: {}", + override_path.display() + ); + } + fs::create_dir_all(&libkrunfw_lib).expect("create libkrunfw override directory"); + fs::copy(&override_path, libkrunfw_lib.join("libkrunfw.5.dylib")) + .unwrap_or_else(|error| panic!("Failed to stage patched libkrunfw: {error}")); + println!( + "cargo:warning=Using explicit patched libkrunfw from {}", + override_path.display() + ); + } else { + let libkrunfw_src = download_libkrunfw_prebuilt(&out_dir); + build_with_make( + &libkrunfw_src, + &libkrunfw_install, + "libkrunfw", + HashMap::new(), + ); + } // 3. Build libkrun from vendored source build_with_make( @@ -797,16 +933,24 @@ fn fix_linux_libs(lib_dir: &Path, lib_prefix: &str) -> Result<(), String> { #[cfg(target_os = "linux")] fn download_libkrunfw_so(install_dir: &Path) { let lib_dir = install_dir.join(LIB_DIR); + let marker = install_dir.join(".source-sha256"); - if has_library(&lib_dir, "libkrunfw") { + if has_library(&lib_dir, "libkrunfw") + && fs::read_to_string(&marker).is_ok_and(|value| value == LIBKRUNFW_SHA256) + { println!("cargo:warning=Using cached libkrunfw.so"); return; } + if lib_dir.exists() { + fs::remove_dir_all(&lib_dir) + .unwrap_or_else(|e| panic!("Failed to remove stale libkrunfw cache: {}", e)); + } + fs::create_dir_all(install_dir) .unwrap_or_else(|e| panic!("Failed to create install dir: {}", e)); - let tarball_path = install_dir.join("libkrunfw.tgz"); + let tarball_path = install_dir.join(format!("libkrunfw-{}.tgz", &LIBKRUNFW_SHA256[..12])); if !tarball_path.exists() { download_file(LIBKRUNFW_SO_URL, &tarball_path) @@ -818,6 +962,8 @@ fn download_libkrunfw_so(install_dir: &Path) { extract_tarball(&tarball_path, install_dir) .unwrap_or_else(|e| panic!("Failed to extract libkrunfw: {}", e)); + fs::write(&marker, LIBKRUNFW_SHA256) + .unwrap_or_else(|e| panic!("Failed to record libkrunfw source digest: {}", e)); println!( "cargo:warning=Extracted libkrunfw.so to {}", @@ -916,12 +1062,21 @@ fn build() { let libkrun_install = out_dir.join("libkrun"); let libkrunfw_lib_dir = libkrunfw_install.join(LIB_DIR); let libkrun_lib_dir = libkrun_install.join(LIB_DIR); + let firmware_current = fs::read_to_string(libkrunfw_install.join(".source-sha256")) + .is_ok_and(|value| value == LIBKRUNFW_SHA256); // Skip build if outputs already exist (incremental build optimization) - if has_library(&libkrunfw_lib_dir, "libkrunfw") && has_library(&libkrun_lib_dir, "libkrun") { + if env::var("A3S_BUILD_LIBKRUN").is_err() + && firmware_current + && has_library(&libkrunfw_lib_dir, "libkrunfw") + && has_library(&libkrun_lib_dir, "libkrun") + { configure_linking(&libkrun_lib_dir, &libkrunfw_lib_dir); return; } + if !firmware_current { + let _ = fs::remove_dir_all(&libkrun_install); + } println!("cargo:warning=Building libkrun-sys for Linux (using prebuilt libkrunfw)"); diff --git a/src/deps/libkrun-sys/build_support.rs b/src/deps/libkrun-sys/build_support.rs new file mode 100644 index 00000000..95d0f535 --- /dev/null +++ b/src/deps/libkrun-sys/build_support.rs @@ -0,0 +1,15 @@ +use std::path::{Path, PathBuf}; + +/// Resolve the Cargo home used by the Cargo process launched from libkrun's +/// Makefile. +/// +/// The outer Cargo keeps a shared package-cache lock while build scripts run. +/// Cargo does not reliably export its own `CARGO_HOME` to build scripts, so a +/// configurable override cannot be checked safely against the outer lock +/// domain. Always keep the nested cache next to this build script's outputs. +pub(crate) fn nested_cargo_home(install_dir: &Path) -> PathBuf { + install_dir + .parent() + .unwrap_or(install_dir) + .join("libkrun-cargo-home") +} diff --git a/src/deps/libkrun-sys/firmware/build-patched-darwin-arm64.sh b/src/deps/libkrun-sys/firmware/build-patched-darwin-arm64.sh new file mode 100755 index 00000000..47dac6e4 --- /dev/null +++ b/src/deps/libkrun-sys/firmware/build-patched-darwin-arm64.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +firmware_dir="$root/deps/libkrun-sys/firmware" +work_dir=${A3S_LIBKRUNFW_WORK_DIR:-"$root/target/libkrunfw-a3s"} +source_dir="$work_dir/source" +builder_image=${A3S_LIBKRUNFW_BUILDER_IMAGE:-fedora:42} +upstream=https://github.com/boxlite-ai/libkrunfw.git +revision=v5.3.0 + +mkdir -p "$work_dir" +if [ ! -d "$source_dir/.git" ]; then + git clone --depth 1 --branch "$revision" "$upstream" "$source_dir" +fi + +if [ ! -d "$source_dir/linux-6.12.76" ]; then + docker run --rm --platform linux/arm64 \ + -v "$source_dir:/src" -w /src "$builder_image" \ + /bin/bash -lc 'dnf install -y make patch curl xz && make linux-6.12.76' +fi + +patch_file="$firmware_dir/patches/0001-tsi-nonblocking-accept-probe-local-vsock-first.patch" +if patch --dry-run -N -p1 -d "$source_dir/linux-6.12.76" < "$patch_file" >/dev/null; then + patch -N -p1 -d "$source_dir/linux-6.12.76" < "$patch_file" +elif ! patch --dry-run -R -p1 -d "$source_dir/linux-6.12.76" < "$patch_file" >/dev/null; then + echo "firmware patch does not apply cleanly" >&2 + exit 1 +fi +rm -f "$source_dir/kernel.c" "$source_dir/linux-6.12.76/arch/arm64/boot/Image" + +docker run --rm --platform linux/arm64 \ + -v "$source_dir:/src" -w /src "$builder_image" \ + /bin/bash -lc 'dnf install -y make gcc bc bison flex elfutils-libelf-devel openssl-devel python3 python3-pyelftools cpio diffutils perl && make -j"$(nproc)" kernel.c' + +make -C "$source_dir" libkrunfw.5.dylib +cp "$source_dir/libkrunfw.5.dylib" "$work_dir/libkrunfw.5.dylib" +codesign --force --sign - "$work_dir/libkrunfw.5.dylib" +shasum -a 256 "$work_dir/libkrunfw.5.dylib" +printf '%s\n' "$work_dir/libkrunfw.5.dylib" diff --git a/src/deps/libkrun-sys/firmware/patches/0001-tsi-nonblocking-accept-probe-local-vsock-first.patch b/src/deps/libkrun-sys/firmware/patches/0001-tsi-nonblocking-accept-probe-local-vsock-first.patch new file mode 100644 index 00000000..eb1b4fb1 --- /dev/null +++ b/src/deps/libkrun-sys/firmware/patches/0001-tsi-nonblocking-accept-probe-local-vsock-first.patch @@ -0,0 +1,82 @@ +From: A3S Box maintainers +Subject: [PATCH] tsi: probe local vsock before nonblocking host accept + +TSI currently sends TSI_ACCEPT to the host before calling the underlying +vsock accept operation. For a nonblocking listener, the first accepted +connection consumes the host pending count, but the conventional second +accept used to drain an edge-triggered accept queue can block waiting for a +host control response. Event-driven servers such as Redis then never service +the already accepted client. + +Probe the local vsock accept queue first for O_NONBLOCK. Return EAGAIN without +contacting the host when it is empty. When a connection is present, retain the +host request to consume the corresponding pending-accept count. Blocking +accept keeps the existing ordering. + +--- a/net/tsi/af_tsi.c ++++ b/net/tsi/af_tsi.c +@@ -493,8 +493,29 @@ static int tsi_accept_vsock(struct tsi_sock *tsk, struct socket **newsock, + struct socket *nsock; + struct tsi_accept_req ta_req; + struct tsi_accept_rsp ta_rsp; ++ bool accepted_locally = false; + int err; + ++ nsock = sock_alloc(); ++ if (!nsock) ++ return -ENOMEM; ++ nsock->type = socket->type; ++ nsock->ops = socket->ops; ++ ++ /* ++ * Do not ask the host whether an O_NONBLOCK accept queue is empty. ++ * The local vsock queue already has the authoritative readiness state, ++ * and the host control round trip may block the guest event loop. ++ */ ++ if (arg->flags & O_NONBLOCK) { ++ err = socket->ops->accept(socket, nsock, arg); ++ if (err < 0) { ++ sock_release(nsock); ++ return err; ++ } ++ accepted_locally = true; ++ } ++ + ta_req.svm_port = tsk->svm_port; + ta_req.flags = arg->flags; +@@ -506,6 +527,7 @@ static int tsi_accept_vsock(struct tsi_sock *tsk, struct socket **newsock, + sizeof(struct tsi_accept_req)); + if (err < 0) { + pr_debug("%s: error sending accept request\n", __func__); ++ sock_release(nsock); + return err; + } + +@@ -514,6 +536,7 @@ static int tsi_accept_vsock(struct tsi_sock *tsk, struct socket **newsock, + sizeof(struct tsi_accept_rsp)); + if (err < 0) { + pr_debug("%s: error receiving accept response\n", __func__); ++ sock_release(nsock); + return err; + } +@@ -521,17 +544,12 @@ static int tsi_accept_vsock(struct tsi_sock *tsk, struct socket **newsock, + pr_debug("%s: response result: %d\n", __func__, ta_rsp.result); + + if (ta_rsp.result != 0) { ++ sock_release(nsock); + return ta_rsp.result; + } + +- nsock = sock_alloc(); +- if (!nsock) +- return -ENOMEM; +- +- nsock->type = socket->type; +- nsock->ops = socket->ops; +- +- err = socket->ops->accept(socket, nsock, arg); ++ if (!accepted_locally) ++ err = socket->ops->accept(socket, nsock, arg); + + if (err < 0) { + pr_debug("%s: vsock accept failed: %d\n", __func__, err); diff --git a/src/deps/libkrun-sys/tests/build_support.rs b/src/deps/libkrun-sys/tests/build_support.rs new file mode 100644 index 00000000..2e066b72 --- /dev/null +++ b/src/deps/libkrun-sys/tests/build_support.rs @@ -0,0 +1,27 @@ +#[path = "../build_support.rs"] +mod build_support; + +use std::path::{Path, PathBuf}; + +#[test] +fn nested_cargo_home_is_target_local() { + let install_dir = Path::new("/tmp/a3s-target/build/libkrun-sys/out/libkrun"); + + assert_eq!( + build_support::nested_cargo_home(install_dir), + PathBuf::from("/tmp/a3s-target/build/libkrun-sys/out/libkrun-cargo-home") + ); +} + +#[test] +fn libkrun_builds_share_only_the_isolated_nested_cache() { + let out_dir = Path::new("/tmp/a3s-target/build/libkrun-sys/out"); + let libkrun_home = build_support::nested_cargo_home(&out_dir.join("libkrun")); + let libkrunfw_home = build_support::nested_cargo_home(&out_dir.join("libkrunfw")); + + assert_eq!(libkrun_home, libkrunfw_home); + assert_eq!( + libkrun_home, + PathBuf::from("/tmp/a3s-target/build/libkrun-sys/out/libkrun-cargo-home") + ); +} diff --git a/src/deps/libkrun-sys/vendor/libkrun b/src/deps/libkrun-sys/vendor/libkrun index d2aa638d..af2a60d4 160000 --- a/src/deps/libkrun-sys/vendor/libkrun +++ b/src/deps/libkrun-sys/vendor/libkrun @@ -1 +1 @@ -Subproject commit d2aa638d525e10f285e899dbecaf50f098883e4a +Subproject commit af2a60d4dfcc6a7fce1ce3286d0a16f595f37f19 diff --git a/src/guest/init/Cargo.toml b/src/guest/init/Cargo.toml index 151db67f..845b849b 100644 --- a/src/guest/init/Cargo.toml +++ b/src/guest/init/Cargo.toml @@ -35,6 +35,7 @@ sha2 = { workspace = true } # Sealed storage ring = { workspace = true } base64 = { workspace = true } +tar = "0.4" [features] default = [] diff --git a/src/guest/init/src/attest_server/handlers.rs b/src/guest/init/src/attest_server/handlers.rs index 34bcef8d..9f7d7726 100644 --- a/src/guest/init/src/attest_server/handlers.rs +++ b/src/guest/init/src/attest_server/handlers.rs @@ -37,10 +37,8 @@ pub(super) fn handle_tls_connection( snp_report: std::sync::Arc>, ) -> Result<(), Box> { use a3s_box_core::tee::{AttestRequest, AttestRoute}; - use std::os::fd::{AsRawFd, FromRawFd}; - let raw_fd = fd.as_raw_fd(); - let tcp_stream = unsafe { std::net::TcpStream::from_raw_fd(raw_fd) }; + let tcp_stream = std::net::TcpStream::from(fd); let conn = rustls::ServerConnection::new(config) .map_err(|e| format!("TLS connection init failed: {}", e))?; @@ -92,8 +90,6 @@ pub(super) fn handle_tls_connection( } } - // Prevent double-close: OwnedFd and TcpStream both own the fd - std::mem::forget(fd); Ok(()) } diff --git a/src/guest/init/src/cgroup.rs b/src/guest/init/src/cgroup.rs index c4a548b3..b0ed41eb 100644 --- a/src/guest/init/src/cgroup.rs +++ b/src/guest/init/src/cgroup.rs @@ -29,7 +29,7 @@ static CGROUP_SEQ: AtomicU64 = AtomicU64::new(0); /// individual limit (memory.max / cpu.max / pids.max) is best-effort in /// `ContainerCgroup::create`, so a missing controller degrades to "that limit /// is not enforced" rather than failing the launch. -fn ensure_cgroup2_ready() -> bool { +pub fn ensure_cgroup2_ready() -> bool { let controllers_path = format!("{CGROUP_ROOT}/cgroup.controllers"); if std::fs::metadata(&controllers_path).is_err() { // Not mounted yet — mount the unified hierarchy. diff --git a/src/guest/init/src/exec_server.rs b/src/guest/init/src/exec_server.rs index 9061078d..7b837204 100644 --- a/src/guest/init/src/exec_server.rs +++ b/src/guest/init/src/exec_server.rs @@ -6,6 +6,8 @@ use std::io::Read; use std::io::Write; +#[cfg(target_os = "linux")] +use std::path::Path; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::mpsc; use std::time::Duration; @@ -59,6 +61,13 @@ const EXEC_CONTROL_SPAWN_MAIN: &[u8] = b"spawn-main:"; const EXEC_SPAWN_MAIN_ACK: &[u8] = b"spawn-main-ack"; #[cfg(target_os = "linux")] const EXEC_SPAWN_MAIN_NACK: &[u8] = b"spawn-main-nack:"; +/// Stream a guest-metadata-preserving tar of the root filesystem. +#[cfg(target_os = "linux")] +const EXEC_CONTROL_ARCHIVE_ROOTFS: &[u8] = b"archive-rootfs-v1"; +#[cfg(target_os = "linux")] +const EXEC_CONTROL_ARCHIVE_ROOTFS_PAUSE: &[u8] = b"archive-rootfs-v1:pause"; +#[cfg(target_os = "linux")] +const EXEC_ARCHIVE_ROOTFS_DONE: &[u8] = b"archive-rootfs-v1-done"; /// Deliver `sig` to the main container process (best-effort). #[cfg(target_os = "linux")] @@ -91,6 +100,8 @@ struct DeferredMainSpec { workdir: Option, #[serde(default)] user: Option, + #[serde(default)] + stdin_null: bool, } #[cfg(target_os = "linux")] @@ -124,6 +135,7 @@ pub fn set_deferred_main_spec( env: Vec<(String, String)>, workdir: Option, user: Option, + stdin_null: bool, ) { *DEFERRED_MAIN.lock().unwrap_or_else(|e| e.into_inner()) = Some(DeferredMainSpec { executable, @@ -131,6 +143,7 @@ pub fn set_deferred_main_spec( env, workdir, user, + stdin_null, }); } @@ -149,8 +162,8 @@ fn spawn_deferred_main(frame: Option) -> Result { // Use the command carried in the frame (the pool path — a pre-warmed VM gets // its per-request command here), else the one stashed at boot from BOX_EXEC_* // (the `run` path, where the command is known at boot). - let (executable, args, env, workdir, user) = match frame { - Some(s) => (s.executable, s.args, s.env, s.workdir, s.user), + let (executable, args, env, workdir, user, stdin_null) = match frame { + Some(s) => (s.executable, s.args, s.env, s.workdir, s.user, s.stdin_null), None => { let guard = DEFERRED_MAIN.lock().unwrap_or_else(|e| e.into_inner()); let spec = guard.as_ref().ok_or("no deferred-main command set")?; @@ -160,6 +173,7 @@ fn spawn_deferred_main(frame: Option) -> Result { spec.env.clone(), spec.workdir.clone(), spec.user.clone(), + spec.stdin_null, ) } }; @@ -205,8 +219,10 @@ fn spawn_deferred_main(frame: Option) -> Result { .map_err(|out| String::from_utf8_lossy(&out.stderr).into_owned())?; command .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()) - .stdin(std::process::Stdio::null()); + .stderr(std::process::Stdio::inherit()); + if stdin_null { + command.stdin(std::process::Stdio::null()); + } // Idempotency: claim the sentinel (-1 → -2 pending); a second spawn-main loses. if CONTAINER_PID @@ -260,6 +276,27 @@ pub struct ExecListener(std::os::fd::OwnedFd); #[cfg(not(target_os = "linux"))] pub struct ExecListener; +/// Adopt the host-side Unix listener passed through the OCI runtime. +/// +/// The descriptor must refer to an already-bound, listening AF_UNIX stream +/// socket. It is validated and marked `CLOEXEC` before the workload is forked. +pub fn adopt_inherited_exec_listener( + fd: std::os::fd::RawFd, +) -> Result> { + #[cfg(target_os = "linux")] + { + Ok(ExecListener(crate::listener::adopt_unix_listener( + fd, "exec", + )?)) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = fd; + Err("inherited exec listeners require Linux".into()) + } +} + /// Bind + listen the exec vsock socket (port 4089). Pure socket syscalls, safe /// to call on the main thread before the container fork. pub fn bind_exec_server() -> Result> { @@ -353,26 +390,23 @@ fn run_accept_loop(sock_fd: std::os::fd::OwnedFd) -> Result<(), Box Result<(), Box> { use a3s_box_core::exec::ExecRequest; - use std::os::fd::{AsRawFd, FromRawFd}; use tracing::debug; - let raw_fd = fd.as_raw_fd(); - let mut stream = unsafe { std::fs::File::from_raw_fd(raw_fd) }; + // Transfer ownership into File. Constructing a second owner with + // `File::from_raw_fd(fd.as_raw_fd())` aborts on any early error because both + // values then close the same descriptor under Rust's IO-safety checks. + let mut stream = std::fs::File::from(fd); // Read request frame let (frame_type, payload) = match read_frame(&mut stream)? { Some(f) => f, - None => { - std::mem::forget(fd); - return Ok(()); - } + None => return Ok(()), }; if frame_type != FrameType::Data as u8 { // Heartbeat: respond with Heartbeat frame (health check) if frame_type == FrameType::Heartbeat as u8 { write_frame(&mut stream, FrameType::Heartbeat as u8, &payload)?; - std::mem::forget(fd); return Ok(()); } // Graceful-stop control: deliver a signal to the container main process. @@ -384,7 +418,6 @@ fn handle_connection(fd: std::os::fd::OwnedFd) -> Result<(), Box Result<(), Box Result<(), Box req, Err(e) => { send_error_frame(&mut stream, &format!("Invalid JSON: {}", e))?; - std::mem::forget(fd); return Ok(()); } }; @@ -465,10 +506,129 @@ fn handle_connection(fd: std::os::fd::OwnedFd) -> Result<(), Box Result<(), Box> { + let _pause_guard = pause.then(PausedContainerTree::pause); + { + let mut writer = ArchiveFrameWriter::new(&mut *stream); + crate::rootfs_archive::write_rootfs_archive(Path::new("/"), &mut writer)?; + writer.finish()?; + } + write_frame(stream, FrameType::Control as u8, EXEC_ARCHIVE_ROOTFS_DONE)?; + Ok(()) +} + +#[cfg(target_os = "linux")] +struct PausedContainerTree { + pids: Vec, +} + +#[cfg(target_os = "linux")] +impl PausedContainerTree { + fn pause() -> Self { + let root = container_pid(); + let mut pids = Vec::new(); + if root > 0 { + collect_process_tree(root, &mut pids); + // Stop descendants before their parent so no parent can immediately + // create more work after its children are frozen. + for pid in pids.iter().rev() { + unsafe { + libc::kill(*pid, libc::SIGSTOP); + } + } + } + Self { pids } + } +} + +#[cfg(target_os = "linux")] +impl Drop for PausedContainerTree { + fn drop(&mut self) { + for pid in &self.pids { + unsafe { + libc::kill(*pid, libc::SIGCONT); + } + } + } +} + +#[cfg(target_os = "linux")] +fn collect_process_tree(pid: i32, output: &mut Vec) { + if output.contains(&pid) { + return; + } + output.push(pid); + let children = format!("/proc/{pid}/task/{pid}/children"); + let Ok(children) = std::fs::read_to_string(children) else { + return; + }; + for child in children + .split_whitespace() + .filter_map(|value| value.parse::().ok()) + { + collect_process_tree(child, output); + } +} + +#[cfg(target_os = "linux")] +struct ArchiveFrameWriter<'a, W: Write> { + stream: &'a mut W, + buffer: Vec, +} + +#[cfg(target_os = "linux")] +impl<'a, W: Write> ArchiveFrameWriter<'a, W> { + const CHUNK_BYTES: usize = 64 * 1024; + + fn new(stream: &'a mut W) -> Self { + Self { + stream, + buffer: Vec::with_capacity(Self::CHUNK_BYTES), + } + } + + fn flush_frame(&mut self) -> std::io::Result<()> { + if self.buffer.is_empty() { + return Ok(()); + } + write_frame(self.stream, FrameType::Data as u8, &self.buffer)?; + self.buffer.clear(); + Ok(()) + } + + fn finish(&mut self) -> std::io::Result<()> { + self.flush_frame() + } +} + +#[cfg(target_os = "linux")] +impl Write for ArchiveFrameWriter<'_, W> { + fn write(&mut self, mut bytes: &[u8]) -> std::io::Result { + let total = bytes.len(); + while !bytes.is_empty() { + let available = Self::CHUNK_BYTES - self.buffer.len(); + let copied = available.min(bytes.len()); + self.buffer.extend_from_slice(&bytes[..copied]); + bytes = &bytes[copied..]; + if self.buffer.len() == Self::CHUNK_BYTES { + self.flush_frame()?; + } + } + Ok(total) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.flush_frame() + } +} + /// Write a frame: [type:u8][length:u32 BE][payload]. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn write_frame(w: &mut impl Write, frame_type: u8, payload: &[u8]) -> std::io::Result<()> { @@ -705,6 +865,8 @@ fn build_command( process_user.gid = crate::user::primary_gid_for_uid(resolve_rootfs, process_user.uid); } } + let process_home = process_user + .and_then(|process_user| crate::user::home_dir_for_uid(resolve_rootfs, process_user.uid)); if let Some(rootfs) = spec.rootfs { if rootfs.is_empty() @@ -798,6 +960,15 @@ fn build_command( command.env(key, value); } } + if !spec + .env + .iter() + .any(|entry| entry.split_once('=').is_some_and(|(key, _)| key == "HOME")) + { + if let Some(home) = process_home { + command.env("HOME", home); + } + } // CRI SupplementalGroups arrive as A3S_SEC_SUPPLEMENTAL_GROUPS=gid,gid,... // and are applied (setgroups) before dropping to the target uid/gid. @@ -873,6 +1044,10 @@ fn build_command( .map(|name| name.trim().to_string()) .filter(|name| !name.is_empty()) .collect() + }) + .or_else(|| { + (std::env::var("A3S_BOOTSTRAP_MODE").as_deref() == Ok("host-sandbox")) + .then(crate::namespace::sandbox_workload_capability_keep_from_env) }); // CRI no_new_privs: A3S_SEC_NO_NEW_PRIVS=1 sets PR_SET_NO_NEW_PRIVS in the // child before exec, so a setuid/file-capability binary cannot raise privs. @@ -1848,6 +2023,23 @@ fn truncate_output(mut data: Vec) -> Vec { mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn truncated_exec_frame_returns_error_without_double_closing_fd() { + use std::io::Write; + use std::os::fd::OwnedFd; + use std::os::unix::net::UnixStream; + + let (server, mut client) = UnixStream::pair().unwrap(); + client + .write_all(&[FrameType::Heartbeat as u8, 0, 0, 0, 1]) + .unwrap(); + drop(client); + + let server = OwnedFd::from(server); + assert!(handle_connection(server).is_err()); + } + #[test] fn test_drain_exec_input_disconnected_requests_cancel() { use std::sync::mpsc; @@ -2044,6 +2236,50 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn test_build_command_uses_selected_users_home_unless_overridden() { + let rootfs = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(rootfs.path().join("etc")).unwrap(); + std::fs::write( + rootfs.path().join("etc/passwd"), + "tester:x:1000:1000:tester:/home/tester:/bin/sh\n", + ) + .unwrap(); + let rootfs = rootfs.path().to_str().unwrap(); + + let build = |env: &[String]| { + build_command( + ExecCommandSpec { + cmd: &["true".to_string()], + timeout_ns: 0, + env, + working_dir: None, + rootfs: Some(rootfs), + stdin_data: None, + stdin_streaming: false, + user: Some("tester"), + }, + None, + ) + .unwrap() + .0 + }; + + let command = build(&[]); + assert!(command.get_envs().any( + |(key, value)| key == "HOME" && value == Some(std::ffi::OsStr::new("/home/tester")) + )); + + let command = build(&["HOME=/workspace".to_string()]); + assert!( + command + .get_envs() + .any(|(key, value)| key == "HOME" + && value == Some(std::ffi::OsStr::new("/workspace"))) + ); + } + #[test] fn test_execute_command_rejects_relative_rootfs() { let output = execute_command( diff --git a/src/guest/init/src/lib.rs b/src/guest/init/src/lib.rs index f6a6f348..01790e8e 100644 --- a/src/guest/init/src/lib.rs +++ b/src/guest/init/src/lib.rs @@ -10,11 +10,14 @@ pub mod attest_server; pub mod cgroup; pub mod exec_server; pub mod host_config; +mod listener; pub mod namespace; pub mod network; pub mod port_forward; pub mod pty_server; pub mod reaper; +#[cfg(any(target_os = "linux", test))] +pub mod rootfs_archive; pub mod user; pub use namespace::{spawn_isolated, NamespaceConfig, NamespaceError}; diff --git a/src/guest/init/src/listener.rs b/src/guest/init/src/listener.rs new file mode 100644 index 00000000..90597ebe --- /dev/null +++ b/src/guest/init/src/listener.rs @@ -0,0 +1,122 @@ +//! Validation and ownership transfer for host-provided control listeners. + +#[cfg(target_os = "linux")] +use std::os::fd::{FromRawFd, OwnedFd, RawFd}; + +/// Adopt an inherited, already-bound Unix stream listener. +/// +/// Validation happens before ownership transfer so an invalid descriptor is +/// never accidentally closed by this function. The adopted descriptor is set +/// `CLOEXEC` before guest-init forks the workload. +#[cfg(target_os = "linux")] +pub(crate) fn adopt_unix_listener(fd: RawFd, label: &str) -> std::io::Result { + if fd < 3 { + return Err(invalid_listener(label, "descriptor must be at least 3")); + } + + let descriptor_flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if descriptor_flags < 0 { + return Err(std::io::Error::last_os_error()); + } + + let socket_type = get_socket_option(fd, libc::SO_TYPE)?; + if socket_type != libc::SOCK_STREAM { + return Err(invalid_listener(label, "descriptor is not a stream socket")); + } + let accepting = get_socket_option(fd, libc::SO_ACCEPTCONN)?; + if accepting != 1 { + return Err(invalid_listener(label, "socket is not listening")); + } + + let mut address: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + let mut length = std::mem::size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockname( + fd, + &mut address as *mut libc::sockaddr_storage as *mut libc::sockaddr, + &mut length, + ) + }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + if address.ss_family as i32 != libc::AF_UNIX { + return Err(invalid_listener(label, "listener is not an AF_UNIX socket")); + } + + if unsafe { libc::fcntl(fd, libc::F_SETFD, descriptor_flags | libc::FD_CLOEXEC) } != 0 { + return Err(std::io::Error::last_os_error()); + } + + // SAFETY: every check above succeeded and the caller transfers exclusive + // ownership of the inherited descriptor to this function. + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +#[cfg(target_os = "linux")] +fn get_socket_option(fd: RawFd, option: libc::c_int) -> std::io::Result { + let mut value = 0; + let mut length = std::mem::size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + option, + &mut value as *mut libc::c_int as *mut libc::c_void, + &mut length, + ) + }; + if result != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(value) +} + +#[cfg(target_os = "linux")] +fn invalid_listener(label: &str, reason: &str) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid inherited {label} listener: {reason}"), + ) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::os::fd::{AsRawFd, IntoRawFd}; + + #[test] + fn adopts_listening_unix_stream_and_sets_cloexec() { + let directory = tempfile::tempdir().unwrap(); + let listener = + std::os::unix::net::UnixListener::bind(directory.path().join("control.sock")).unwrap(); + let inherited_fd = listener.into_raw_fd(); + + let owned = adopt_unix_listener(inherited_fd, "test").unwrap(); + let flags = unsafe { libc::fcntl(owned.as_raw_fd(), libc::F_GETFD) }; + assert_ne!(flags & libc::FD_CLOEXEC, 0); + } + + #[test] + fn rejects_non_listening_socket_without_taking_ownership() { + let mut descriptors = [0; 2]; + assert_eq!( + unsafe { + libc::socketpair( + libc::AF_UNIX, + libc::SOCK_STREAM, + 0, + descriptors.as_mut_ptr(), + ) + }, + 0 + ); + let error = adopt_unix_listener(descriptors[0], "test").unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_ne!(unsafe { libc::fcntl(descriptors[0], libc::F_GETFD) }, -1); + unsafe { + libc::close(descriptors[0]); + libc::close(descriptors[1]); + } + } +} diff --git a/src/guest/init/src/main.rs b/src/guest/init/src/main.rs index f9ffd323..3966e1b6 100644 --- a/src/guest/init/src/main.rs +++ b/src/guest/init/src/main.rs @@ -18,6 +18,31 @@ use tracing::{error, info, warn}; /// Global flag set by the SIGTERM handler to request graceful shutdown. static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false); +/// Bootstrap environment selected by the host execution backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BootstrapMode { + Microvm, + HostSandbox, +} + +impl BootstrapMode { + fn from_value(value: Option<&str>) -> Result> { + match value.map(str::trim).filter(|value| !value.is_empty()) { + None | Some("microvm") => Ok(Self::Microvm), + Some("host-sandbox") => Ok(Self::HostSandbox), + Some(value) => Err(format!("unsupported A3S_BOOTSTRAP_MODE {value:?}").into()), + } + } + + fn from_env() -> Result> { + Self::from_value(std::env::var("A3S_BOOTSTRAP_MODE").ok().as_deref()) + } + + fn is_host_sandbox(self) -> bool { + matches!(self, Self::HostSandbox) + } +} + /// Relay threads forwarding the main process's stdout/stderr pipes to the console. /// Drained at container exit so the tail of the output reaches the console (and /// thus `logs` / the foreground terminal) before the VM halts. @@ -231,6 +256,8 @@ struct ExecConfig { /// Container user (`uid`, `uid:gid`, `root`, or a name resolved via the /// image `/etc/passwd`). Applied to the main process before exec. user: Option, + /// Whether stdin should be connected to `/dev/null`. + stdin_null: bool, } impl ExecConfig { @@ -249,22 +276,11 @@ impl ExecConfig { // not exist on Alpine and was the original cause of issue #3. // BOX_EXEC_* values are base64-encoded (URL-safe, no pad) by the runtime // when BOX_EXEC_B64=1, so arbitrary bytes (quotes, spaces, `$`, …) survive - // libkrun's env serialization. Decode them back; fall back to the raw value - // on any decode error or when the marker is absent (older runtime). - use base64::Engine; - let b64 = std::env::var("BOX_EXEC_B64") - .map(|v| v == "1") - .unwrap_or(false); - let decode = |s: String| -> String { - if !b64 { - return s; - } - base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(s.as_bytes()) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .unwrap_or(s) - }; + // libkrun's env serialization. Some libkrun init builds import BOX_EXEC_* + // values from /proc/cmdline but miss the marker; in that case, infer the + // encoded form from BOX_EXEC_EXEC so current runtimes still boot. + let b64 = should_decode_box_exec_values(); + let decode = |s: String| decode_box_exec_value(s, b64); let executable = std::env::var("BOX_EXEC_EXEC") .map(&decode) @@ -294,6 +310,9 @@ impl ExecConfig { .ok() .map(&decode) .filter(|u| !u.is_empty()); + let stdin_null = std::env::var("BOX_EXEC_STDIN") + .map(|value| value.eq_ignore_ascii_case("null")) + .unwrap_or(false); // Collect BOX_EXEC_ENV_* variables (values decoded as above). Skip // BOX_EXEC_ENV_FILE — it's the pointer to the staged env file, not a @@ -331,10 +350,54 @@ impl ExecConfig { env, workdir, user, + stdin_null, } } } +fn should_decode_box_exec_values() -> bool { + if std::env::var("BOX_EXEC_B64") + .map(|v| v == "1") + .unwrap_or(false) + { + return true; + } + + std::env::var("BOX_EXEC_EXEC") + .ok() + .and_then(|raw| decode_box_exec_value_if_valid(&raw)) + .as_deref() + .is_some_and(is_plausible_exec) +} + +fn decode_box_exec_value(value: String, decode: bool) -> String { + if decode { + decode_box_exec_value_if_valid(&value).unwrap_or(value) + } else { + value + } +} + +fn decode_box_exec_value_if_valid(value: &str) -> Option { + use base64::Engine; + + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value.as_bytes()) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|decoded| !decoded.is_empty() && !decoded.contains('\0')) +} + +fn is_plausible_exec(value: &str) -> bool { + !value.is_empty() + && (value.starts_with('/') + || value.starts_with("./") + || value.starts_with("../") + || value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '+' | ':'))) +} + /// Sidecar process configuration parsed from environment variables. struct SidecarConfig { /// Sidecar image name (informational only inside the VM — binary is already in rootfs) @@ -430,11 +493,13 @@ static KMSG_FD: std::sync::OnceLock> = std::syn /// `logs` must show only that, not runtime internals (init/exec/pty chatter). /// A `<7>` (debug) priority prefix keeps these lines below the guest kernel's /// console loglevel (4), so they never echo back to the console. Falls back to -/// stdout when `/dev/kmsg` is unavailable (e.g. non-Linux), preserving the old -/// behavior rather than dropping logs. +/// stderr when `/dev/kmsg` is unavailable. The OCI Sandbox controller keeps +/// runtime stderr separate from container stdout, so bootstrap diagnostics can +/// never contaminate command output returned to SDK clients. enum InitLogWriter { Kmsg(std::os::unix::io::RawFd), - Stdout(std::io::Stdout), + Inherited(std::os::unix::io::RawFd), + Stderr(std::io::Stderr), } impl std::io::Write for InitLogWriter { @@ -454,23 +519,65 @@ impl std::io::Write for InitLogWriter { } Ok(buf.len()) } - InitLogWriter::Stdout(out) => out.write(buf), + InitLogWriter::Inherited(fd) => write_inherited_log(*fd, buf), + InitLogWriter::Stderr(out) => out.write(buf), } } fn flush(&mut self) -> std::io::Result<()> { match self { - InitLogWriter::Kmsg(_) => Ok(()), - InitLogWriter::Stdout(out) => out.flush(), + InitLogWriter::Kmsg(_) | InitLogWriter::Inherited(_) => Ok(()), + InitLogWriter::Stderr(out) => out.flush(), } } } fn make_init_log_writer() -> InitLogWriter { + if let Some(fd) = inherited_init_log_fd() { + return InitLogWriter::Inherited(fd); + } match KMSG_FD.get().copied().flatten() { Some(fd) => InitLogWriter::Kmsg(fd), - None => InitLogWriter::Stdout(std::io::stdout()), + None => InitLogWriter::Stderr(std::io::stderr()), + } +} + +fn inherited_init_log_fd() -> Option { + let value = std::env::var("A3S_INIT_LOG_FD").ok()?; + let fd = value.parse::().ok()?; + if fd < 3 || unsafe { libc::fcntl(fd, libc::F_GETFD) } < 0 { + return None; + } + // The descriptor belongs to guest-init but must not leak into main, exec, + // or PTY workloads. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } != 0 { + return None; + } + Some(fd) +} + +fn write_inherited_log(fd: std::os::unix::io::RawFd, mut bytes: &[u8]) -> std::io::Result { + let original_len = bytes.len(); + while !bytes.is_empty() { + let written = + unsafe { libc::write(fd, bytes.as_ptr() as *const libc::c_void, bytes.len()) }; + if written < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error); + } + if written == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "inherited init log descriptor returned a zero-length write", + )); + } + bytes = &bytes[written as usize..]; } + Ok(original_len) } fn main() { @@ -501,6 +608,7 @@ fn main() { // Run init process if let Err(e) = run_init() { error!("Init process failed: {}", e); + eprintln!("a3s-box guest init failed: {e}"); process::exit(1); } @@ -508,18 +616,32 @@ fn main() { } fn run_init() -> Result<(), Box> { - // Step 1: Mount essential filesystems - mount_essential_filesystems()?; + let bootstrap_mode = BootstrapMode::from_env()?; + info!(?bootstrap_mode, "Selected guest-init bootstrap mode"); - // Step 2: Mount virtio-fs shares - mount_virtio_fs_shares()?; + // Restore Linux uid/gid/mode before mounting procfs, workspace, or user + // volumes so metadata replay can never mutate an attached host path. + #[cfg(target_os = "linux")] + if bootstrap_mode.is_host_sandbox() { + a3s_box_guest_init::rootfs_archive::restore_rootfs_metadata_around_mounts( + std::path::Path::new("/"), + )?; + } else { + a3s_box_guest_init::rootfs_archive::restore_rootfs_metadata(std::path::Path::new("/"))?; + } - // Step 2.25: Mount devpts after the final rootfs is active so PTY - // allocation inside exec/attach sessions can open /dev/ptmx. - mount_devpts()?; + if !bootstrap_mode.is_host_sandbox() { + // The MicroVM backend owns its in-guest filesystem setup. The OCI + // backend has already installed all of these mounts before PID 1 runs. + mount_essential_filesystems()?; + mount_virtio_fs_shares()?; + mount_devpts()?; + mount_tmpfs_volumes()?; - // Step 2.5: Mount tmpfs volumes - mount_tmpfs_volumes()?; + // Make the unified hierarchy visible for nested runtimes in a VM. + #[cfg(target_os = "linux")] + let _ = a3s_box_guest_init::cgroup::ensure_cgroup2_ready(); + } // Step 2.6: Bind the exec (vsock 4089) and PTY (vsock 4090) listening sockets // NOW, before the slower network bring-up and container spawn below. These are @@ -530,19 +652,41 @@ fn run_init() -> Result<(), Box> { // refused while network setup and the container spawn finish — closing the // exec/PTY startup race of issue #3. CLOEXEC on the fds keeps the forked // container from inheriting the listeners. - let exec_listener = exec_server::bind_exec_server()?; - let pty_listener = pty_server::bind_pty_server()?; + let (exec_listener, pty_listener) = if bootstrap_mode.is_host_sandbox() { + let exec_fd = inherited_listener_fd("A3S_EXEC_LISTENER_FD")?; + let pty_fd = inherited_listener_fd("A3S_PTY_LISTENER_FD")?; + if exec_fd == pty_fd { + return Err("Sandbox exec and PTY listeners must use distinct descriptors".into()); + } + ( + exec_server::adopt_inherited_exec_listener(exec_fd)?, + pty_server::adopt_inherited_pty_listener(pty_fd)?, + ) + } else { + ( + exec_server::bind_exec_server()?, + pty_server::bind_pty_server()?, + ) + }; // Step 3: Configure guest network (if passt mode is active). // Network setup may write /etc/resolv.conf — must run before read-only remount. - network::configure_guest_network()?; + if bootstrap_mode.is_host_sandbox() { + network::configure_sandbox_loopback()?; + } else { + network::configure_guest_network()?; + } // Step 3.25: Apply hostname while the rootfs is still writable. - host_config::apply_from_env()?; + if !bootstrap_mode.is_host_sandbox() { + host_config::apply_from_env()?; + } // Step 3.5: Remount rootfs read-only if BOX_READONLY=1. // All writes to / (mount point creation, resolv.conf) must complete first. - remount_rootfs_readonly()?; + if !bootstrap_mode.is_host_sandbox() { + remount_rootfs_readonly()?; + } // Step 4: Register SIGTERM handler before spawning any children register_sigterm_handler()?; @@ -574,13 +718,15 @@ fn run_init() -> Result<(), Box> { // The sidecar runs before the main container so it is ready to intercept // traffic when the agent starts. It is not waited on — it runs for the // lifetime of the VM and is reaped by the zombie-reaper loop. - if let Some(sidecar) = SidecarConfig::from_env() { - info!( - image = %sidecar.image, - vsock_port = sidecar.vsock_port, - "Launching sidecar process" - ); - launch_sidecar(&sidecar)?; + if !bootstrap_mode.is_host_sandbox() { + if let Some(sidecar) = SidecarConfig::from_env() { + info!( + image = %sidecar.image, + vsock_port = sidecar.vsock_port, + "Launching sidecar process" + ); + launch_sidecar(&sidecar)?; + } } // Step 7: Launch container entrypoint @@ -634,27 +780,31 @@ fn run_init() -> Result<(), Box> { // (--memory-reservation) / swap cap (--memory-swap) DO have to be applied // in-guest, mirrored from the same A3S_SEC_* env vars. #[cfg(target_os = "linux")] - let container_cgroup = a3s_box_guest_init::cgroup::ContainerCgroup::create( - None, - std::env::var("A3S_SEC_MEM_LOW") - .ok() - .and_then(|value| value.parse::().ok()), - std::env::var("A3S_SEC_MEM_SWAP") - .ok() - .and_then(|value| value.parse::().ok()), - std::env::var("A3S_SEC_CPU_QUOTA") - .ok() - .and_then(|value| value.parse::().ok()), - std::env::var("A3S_SEC_CPU_PERIOD") - .ok() - .and_then(|value| value.parse::().ok()), - std::env::var("A3S_SEC_CPU_SHARES") - .ok() - .and_then(|value| value.parse::().ok()), - std::env::var("A3S_SEC_PIDS_LIMIT") - .ok() - .and_then(|value| value.parse::().ok()), - ); + let container_cgroup = if bootstrap_mode.is_host_sandbox() { + None + } else { + a3s_box_guest_init::cgroup::ContainerCgroup::create( + None, + std::env::var("A3S_SEC_MEM_LOW") + .ok() + .and_then(|value| value.parse::().ok()), + std::env::var("A3S_SEC_MEM_SWAP") + .ok() + .and_then(|value| value.parse::().ok()), + std::env::var("A3S_SEC_CPU_QUOTA") + .ok() + .and_then(|value| value.parse::().ok()), + std::env::var("A3S_SEC_CPU_PERIOD") + .ok() + .and_then(|value| value.parse::().ok()), + std::env::var("A3S_SEC_CPU_SHARES") + .ok() + .and_then(|value| value.parse::().ok()), + std::env::var("A3S_SEC_PIDS_LIMIT") + .ok() + .and_then(|value| value.parse::().ok()), + ) + }; #[cfg(target_os = "linux")] let cgroup_procs = container_cgroup.as_ref().map(|cgroup| cgroup.procs_path()); #[cfg(not(target_os = "linux"))] @@ -682,6 +832,7 @@ fn run_init() -> Result<(), Box> { Some(exec_config.workdir.clone()) }, exec_config.user.clone(), + exec_config.stdin_null, ); // Stash the cgroup's procs path too, so the deferred main joins the // per-container cgroup when spawned (the non-deferred branch below @@ -707,6 +858,7 @@ fn run_init() -> Result<(), Box> { &env_refs, &exec_config.workdir, exec_config.user.as_deref(), + exec_config.stdin_null, main_stdio, cgroup_procs.as_deref(), )?; @@ -743,11 +895,13 @@ fn run_init() -> Result<(), Box> { }); // Step 8.25: Start Windows host-port forward control client when enabled. - std::thread::spawn(|| { - if let Err(e) = port_forward::run_port_forward_client() { - error!("Port-forward client failed: {}", e); - } - }); + if !bootstrap_mode.is_host_sandbox() { + std::thread::spawn(|| { + if let Err(e) = port_forward::run_port_forward_client() { + error!("Port-forward client failed: {}", e); + } + }); + } // Step 8.5: Start the PTY server accept loop on the socket bound in Step 2.6. std::thread::spawn(move || { @@ -758,7 +912,7 @@ fn run_init() -> Result<(), Box> { // Step 8.6: Start attestation server in background thread (TEE environments only) // Only start if TEE simulation is enabled or real SEV-SNP hardware is present. - if is_tee_environment() { + if !bootstrap_mode.is_host_sandbox() && is_tee_environment() { std::thread::spawn(|| { if let Err(e) = attest_server::run_attest_server() { error!("Attestation server failed: {}", e); @@ -767,7 +921,9 @@ fn run_init() -> Result<(), Box> { } // Step 9: Wait for agent process (reap zombies, handle SIGTERM) - wait_for_children(container_pid)?; + wait_for_children(container_pid, bootstrap_mode)?; + + persist_terminal_rootfs_metadata(); // Drain the stdio relays on the graceful-shutdown / no-children return paths // (the container-exit path flushes before its own process::exit). @@ -776,6 +932,17 @@ fn run_init() -> Result<(), Box> { Ok(()) } +fn inherited_listener_fd(name: &str) -> Result> { + let raw = std::env::var(name).map_err(|_| format!("missing required {name}"))?; + let fd = raw + .parse::() + .map_err(|_| format!("invalid {name} value {raw:?}"))?; + if !(3..=1024).contains(&fd) { + return Err(format!("{name} must be a descriptor between 3 and 1024").into()); + } + Ok(fd) +} + fn expose_container_env_to_exec(config: &ExecConfig) { for (key, value) in &config.env { if key.is_empty() || key.contains(['=', '\0']) || value.contains('\0') { @@ -1039,13 +1206,15 @@ fn mount_virtio_fs_shares() -> Result<(), Box> { dev_moved = true; } - // Change directory to new root - std::env::set_current_dir("/mnt/newroot")?; - - // Pivot root via chroot - use nix::unistd::{chdir, chroot}; - chroot("/mnt/newroot")?; - chdir("/")?; + if let Err(e) = pivot_to_rootfs("/mnt/newroot") { + warn!( + error = %e, + "Failed to pivot to root filesystem; falling back to chroot" + ); + use nix::unistd::{chdir, chroot}; + chroot("/mnt/newroot")?; + chdir("/")?; + } // Re-mount any filesystems that couldn't be moved (MS_MOVE failed). // This ensures /proc, /sys, /dev are available in the new rootfs. @@ -1097,13 +1266,7 @@ fn mount_virtio_fs_shares() -> Result<(), Box> { std::fs::create_dir_all("/workspace").ok(); // Mount workspace share - mount( - Some("workspace"), - "/workspace", - Some("virtiofs"), - MsFlags::empty(), - None::<&str>, - )?; + mount_virtiofs("workspace", "/workspace", MsFlags::empty())?; // Mount user-defined volumes from environment variables. // Format: BOX_VOL_=:[:ro] @@ -1118,6 +1281,101 @@ fn mount_virtio_fs_shares() -> Result<(), Box> { Ok(()) } +#[cfg(target_os = "linux")] +fn pivot_to_rootfs(new_root: &str) -> Result<(), Box> { + use nix::mount::{mount, umount2, MntFlags, MsFlags}; + use nix::unistd::chdir; + use std::ffi::CString; + + let put_old = format!("{new_root}/.a3s-old-root"); + std::fs::create_dir_all(&put_old)?; + + // Nested runtimes such as runc require a real pivot_root-capable mount + // namespace. Make the current tree private so the pivot and later unmount do + // not propagate back to shared mounts created by the host kernel. + mount( + Some(""), + "/", + None::<&str>, + MsFlags::MS_PRIVATE | MsFlags::MS_REC, + None::<&str>, + )?; + + let new_root_c = CString::new(new_root)?; + let put_old_c = CString::new(put_old.as_str())?; + // SAFETY: `new_root_c` and `put_old_c` are valid NUL-terminated paths for + // the duration of the syscall; pivot_root has no Rust wrapper in nix 0.29. + let rc = unsafe { + libc::syscall( + libc::SYS_pivot_root, + new_root_c.as_ptr(), + put_old_c.as_ptr(), + ) + }; + if rc != 0 { + let error = std::io::Error::last_os_error(); + let _ = std::fs::remove_dir(&put_old); + return Err(error.into()); + } + + chdir("/")?; + match umount2("/.a3s-old-root", MntFlags::MNT_DETACH) { + Ok(()) => {} + Err(error) => warn!(error = %error, "Failed to detach old root after pivot_root"), + } + if let Err(error) = std::fs::remove_dir("/.a3s-old-root") { + warn!(error = %error, "Failed to remove old root mount point after pivot_root"); + } + + Ok(()) +} + +fn virtiofs_mount_options_from_env_value(value: Option<&str>) -> Option { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some("default") => None, + Some(mode) => Some(format!("cache={mode}")), + None => Some("cache=none".to_string()), + } +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn virtiofs_mount_options() -> Option { + virtiofs_mount_options_from_env_value(std::env::var("A3S_VIRTIOFS_CACHE").ok().as_deref()) +} + +#[cfg(target_os = "linux")] +fn mount_virtiofs( + tag: &str, + target: &str, + flags: nix::mount::MsFlags, +) -> Result<(), Box> { + use nix::mount::mount; + + if let Some(options) = virtiofs_mount_options() { + match mount( + Some(tag), + target, + Some("virtiofs"), + flags, + Some(options.as_str()), + ) { + Ok(()) => return Ok(()), + Err(error) => { + warn!( + tag = tag, + target = target, + options = options, + error = %error, + "virtio-fs mount with explicit cache mode failed; retrying with the kernel default" + ); + } + } + } + + mount(Some(tag), target, Some("virtiofs"), flags, None::<&str>)?; + Ok(()) +} + /// Mount user-defined volumes passed via BOX_VOL_* environment variables. /// /// Each variable has the format: `:[:ro]` @@ -1159,13 +1417,7 @@ fn mount_user_volumes() -> Result<(), Box> { let file_name = guest_path.rsplit('/').next().unwrap_or(guest_path); let private_mp = format!("/run/.a3s-filemounts/{}", index); std::fs::create_dir_all(&private_mp)?; - mount( - Some(tag), - private_mp.as_str(), - Some("virtiofs"), - MsFlags::empty(), - None::<&str>, - )?; + mount_virtiofs(tag, private_mp.as_str(), MsFlags::empty())?; let src = format!("{}/{}", private_mp, file_name); if !std::path::Path::new(&src).exists() { @@ -1211,7 +1463,7 @@ fn mount_user_volumes() -> Result<(), Box> { } else { // Directory mount: mount the virtio-fs share directly at guest_path. std::fs::create_dir_all(guest_path)?; - mount(Some(tag), guest_path, Some("virtiofs"), flags, None::<&str>)?; + mount_virtiofs(tag, guest_path, flags)?; info!( tag = tag, guest_path = guest_path, @@ -1357,7 +1609,10 @@ fn remount_rootfs_readonly() -> Result<(), Box> { /// children for their handler. This propagates the container exit code AND fixes /// the zombie leak (orphans were previously never reaped until shutdown). #[cfg(target_os = "linux")] -fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box> { +fn wait_for_children( + container_pid: nix::unistd::Pid, + bootstrap_mode: BootstrapMode, +) -> Result<(), Box> { use a3s_box_guest_init::reaper; use nix::sys::wait::{waitid, waitpid, Id, WaitPidFlag, WaitStatus}; @@ -1415,10 +1670,20 @@ fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box Result<(), Box std::time::Duration { + if bootstrap_mode.is_host_sandbox() { + std::time::Duration::ZERO + } else { + std::time::Duration::from_millis(250) + } +} + +#[cfg(target_os = "linux")] +fn persist_terminal_rootfs_metadata() { + if std::env::var("BOX_PERSIST_ROOTFS_METADATA").as_deref() != Ok("1") { + return; + } + if let Err(error) = + a3s_box_guest_init::rootfs_archive::persist_rootfs_metadata(std::path::Path::new("/")) + { + warn!(%error, "Failed to persist terminal rootfs metadata"); + } +} + +#[cfg(not(target_os = "linux"))] +fn persist_terminal_rootfs_metadata() {} + /// Non-Linux development stub: just wait for the container process to exit. #[cfg(not(target_os = "linux"))] -fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box> { +fn wait_for_children( + container_pid: nix::unistd::Pid, + _bootstrap_mode: BootstrapMode, +) -> Result<(), Box> { use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; loop { @@ -1463,12 +1755,12 @@ fn wait_for_children(container_pid: nix::unistd::Pid) -> Result<(), Box/upper/.a3s_exit_code`, which is -/// this file surfaced through the overlay upperdir. Best-effort, with `sync_all` -/// so the write reaches the host before PID 1 exits and the VM halts. +/// shim process, so the host cannot rely on its process status for a detached +/// `run -d`; the runtime resolves this file through the active overlay, copy, or +/// APFS-backed rootfs layout. Best-effort, with `sync_all` so the write reaches +/// the host before PID 1 exits and the VM halts. fn persist_exit_code(code: i32) { use std::io::Write; if let Ok(mut file) = std::fs::File::create("/.a3s_exit_code") { @@ -1554,6 +1846,31 @@ fn graceful_shutdown(timeout_ms: u64) { mod tests { use super::*; + #[test] + fn bootstrap_mode_is_explicit_and_fail_closed() { + assert_eq!( + BootstrapMode::from_value(None).unwrap(), + BootstrapMode::Microvm + ); + assert_eq!( + BootstrapMode::from_value(Some("host-sandbox")).unwrap(), + BootstrapMode::HostSandbox + ); + assert!(BootstrapMode::from_value(Some("sandbox-ish")).is_err()); + } + + #[test] + fn host_sandbox_uses_owned_log_drain_instead_of_legacy_handoff() { + assert_eq!( + console_handoff_delay(BootstrapMode::HostSandbox), + std::time::Duration::ZERO + ); + assert_eq!( + console_handoff_delay(BootstrapMode::Microvm), + std::time::Duration::from_millis(250) + ); + } + fn set_sidecar_env(image: &str, vsock_port: u32, env: &[(&str, &str)]) { std::env::set_var("BOX_SIDECAR_IMAGE", image); std::env::set_var("BOX_SIDECAR_VSOCK_PORT", vsock_port.to_string()); @@ -1572,6 +1889,44 @@ mod tests { } } + #[test] + fn test_virtiofs_mount_options_default_to_stable_cache_mode() { + assert_eq!( + virtiofs_mount_options_from_env_value(None).as_deref(), + Some("cache=none") + ); + assert_eq!( + virtiofs_mount_options_from_env_value(Some("")).as_deref(), + Some("cache=none") + ); + assert_eq!( + virtiofs_mount_options_from_env_value(Some("auto")).as_deref(), + Some("cache=auto") + ); + assert_eq!(virtiofs_mount_options_from_env_value(Some("default")), None); + } + + #[test] + fn test_box_exec_auto_decode_accepts_runtime_encoded_exec() { + assert!(is_plausible_exec( + &decode_box_exec_value_if_valid("L2Jpbi9zaA").unwrap() + )); + assert_eq!( + decode_box_exec_value("YnVpbGRjdGwtZGFlbW9ubGVzcy5zaA".to_string(), true), + "buildctl-daemonless.sh" + ); + } + + #[test] + fn test_box_exec_auto_decode_preserves_raw_legacy_values() { + assert_eq!( + decode_box_exec_value("/bin/sh".to_string(), false), + "/bin/sh" + ); + assert!(decode_box_exec_value_if_valid("/bin/sh").is_none()); + assert!(!is_plausible_exec("")); + } + /// All sidecar env tests run sequentially in a single test to avoid /// env var race conditions (env vars are process-global). #[test] diff --git a/src/guest/init/src/namespace.rs b/src/guest/init/src/namespace.rs index 1d3dd1ca..cee23830 100644 --- a/src/guest/init/src/namespace.rs +++ b/src/guest/init/src/namespace.rs @@ -11,7 +11,7 @@ use std::os::fd::RawFd; #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt; use std::os::unix::process::CommandExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use thiserror::Error; @@ -165,6 +165,7 @@ pub fn spawn_isolated( env: &[(&str, &str)], workdir: &str, user: Option<&str>, + stdin_null: bool, main_stdio: Option<(RawFd, RawFd)>, cgroup_procs: Option<&str>, ) -> Result { @@ -187,6 +188,7 @@ pub fn spawn_isolated( env, workdir, user, + stdin_null, main_stdio, cgroup_procs, ) { @@ -214,12 +216,15 @@ fn child_process( env: &[(&str, &str)], workdir: &str, user: Option<&str>, + stdin_null: bool, main_stdio: Option<(RawFd, RawFd)>, cgroup_procs: Option<&str>, ) -> Result<(), NamespaceError> { // Create new namespaces let flags = config.to_clone_flags(); - unshare(flags).map_err(NamespaceError::UnshareFailed)?; + if !flags.is_empty() { + unshare(flags).map_err(NamespaceError::UnshareFailed)?; + } tracing::debug!("Namespaces created: {:?}", config); @@ -273,10 +278,13 @@ fn child_process( } } - // Execute the command. `Command::exec` uses PATH for bare command names, so - // the preflight check needs to mirror that instead of statting "sleep". - if let Some(command_path) = resolve_command_path(command, env) { - let metadata = std::fs::metadata(&command_path).ok(); + // Resolve bare OCI entrypoints through the container PATH. Rust's + // Command::new does not search PATH for the environment we are about to set, + // so doing it here is required for images such as node:*-alpine whose + // entrypoint is "docker-entrypoint.sh". + let resolved_command = resolve_command_path(command, env); + if let Some(command_path) = &resolved_command { + let metadata = std::fs::metadata(command_path).ok(); tracing::debug!( path = %command_path.display(), size = metadata.as_ref().map(|m| m.len()).unwrap_or(0), @@ -293,8 +301,15 @@ fn child_process( ); } - let mut cmd = Command::new(command); + let mut cmd = Command::new( + resolved_command + .as_deref() + .unwrap_or_else(|| Path::new(command)), + ); cmd.args(args).current_dir(workdir); + if stdin_null { + cmd.stdin(std::process::Stdio::null()); + } // Set environment variables for (key, value) in env { @@ -345,7 +360,10 @@ fn resolve_command_path(command: &str, env: &[(&str, &str)]) -> Option .iter() .rev() .find_map(|(key, value)| (*key == "PATH").then_some((*value).to_string())) - .or_else(|| std::env::var("PATH").ok())?; + .or_else(|| std::env::var("PATH").ok()) + .unwrap_or_else(|| { + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string() + }); path_env .split(':') @@ -430,6 +448,15 @@ fn apply_security_before_exec( } else { config.cap_drop.clone() }; + let cap_keep = + if !privileged && std::env::var("A3S_BOOTSTRAP_MODE").as_deref() == Ok("host-sandbox") { + Some(sandbox_workload_capability_keep( + &config.cap_add, + &config.cap_drop, + )) + } else { + None + }; // Build the seccomp BPF filter BEFORE fork. Building allocates, which is // not async-signal-safe in the post-fork child (malloc may deadlock on @@ -469,7 +496,9 @@ fn apply_security_before_exec( } // 3. Drop capabilities (while still root, before the uid switch). - if should_drop_caps(&cap_drop) { + if let Some(cap_keep) = &cap_keep { + restrict_capabilities_to_keep(cap_keep)?; + } else if should_drop_caps(&cap_drop) { drop_capabilities(&cap_drop)?; } @@ -510,6 +539,55 @@ fn apply_security_before_exec( Ok(()) } +/// Resolve the exact workload capability set used by HostSandbox main, exec, +/// and PTY processes. Bootstrap-only capabilities such as `NET_ADMIN` are not +/// inherited by user code. +pub(crate) fn sandbox_workload_capability_keep_from_env() -> Vec { + let config = a3s_box_core::security::SecurityConfig::from_env_vars(); + sandbox_workload_capability_keep(&config.cap_add, &config.cap_drop) +} + +fn sandbox_workload_capability_keep(cap_add: &[String], cap_drop: &[String]) -> Vec { + let mut capabilities: std::collections::BTreeSet = [ + "CHOWN", + "DAC_OVERRIDE", + "FOWNER", + "FSETID", + "KILL", + "NET_BIND_SERVICE", + "SETGID", + "SETPCAP", + "SETUID", + "SYS_CHROOT", + ] + .into_iter() + .map(ToString::to_string) + .collect(); + + for capability in cap_add { + capabilities.insert(normalize_capability_name(capability)); + } + if cap_drop + .iter() + .any(|capability| normalize_capability_name(capability) == "ALL") + { + capabilities.clear(); + } else { + for capability in cap_drop { + capabilities.remove(&normalize_capability_name(capability)); + } + } + capabilities.into_iter().collect() +} + +fn normalize_capability_name(value: &str) -> String { + let normalized = value.trim().to_ascii_uppercase(); + normalized + .strip_prefix("CAP_") + .unwrap_or(&normalized) + .to_string() +} + /// Check if we should drop capabilities. #[cfg(target_os = "linux")] fn should_drop_caps(cap_drop: &[String]) -> bool { @@ -1059,20 +1137,29 @@ fn child_process( env: &[(&str, &str)], workdir: &str, _user: Option<&str>, + stdin_null: bool, _main_stdio: Option<(RawFd, RawFd)>, _cgroup_procs: Option<&str>, ) -> Result<(), NamespaceError> { // On non-Linux, just exec without namespace isolation or security tracing::warn!("Namespace isolation and security enforcement not available on this platform"); - let mut cmd = Command::new(command); + let resolved_command = resolve_command_path(command, env); + let mut cmd = Command::new( + resolved_command + .as_deref() + .unwrap_or_else(|| Path::new(command)), + ); cmd.args(args).current_dir(workdir); + if stdin_null { + cmd.stdin(std::process::Stdio::null()); + } for (key, value) in env { cmd.env(key, value); } - if let Some(command_path) = resolve_command_path(command, env) { + if let Some(command_path) = &resolved_command { tracing::debug!(path = %command_path.display(), "Command file resolved"); } @@ -1132,6 +1219,18 @@ mod tests { assert_eq!(path, Some(PathBuf::from("/bin/sh"))); } + #[test] + fn test_resolve_command_path_relative_oci_entrypoint_from_container_path() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("usr/local/bin"); + std::fs::create_dir_all(&bin).unwrap(); + let entrypoint = bin.join("docker-entrypoint.sh"); + std::fs::write(&entrypoint, "#!/bin/sh\n").unwrap(); + + let path = resolve_command_path("docker-entrypoint.sh", &[("PATH", bin.to_str().unwrap())]); + assert_eq!(path, Some(entrypoint)); + } + #[test] fn test_resolve_command_path_missing() { let path = resolve_command_path("definitely-not-an-a3s-command", &[("PATH", "/bin")]); @@ -1208,6 +1307,25 @@ mod tests { assert!(should_drop_caps(&["NET_RAW".to_string()])); } + #[test] + fn sandbox_workload_capabilities_exclude_bootstrap_only_caps() { + let keep = sandbox_workload_capability_keep(&[], &[]); + assert!(keep.iter().any(|capability| capability == "CHOWN")); + assert!(!keep.iter().any(|capability| capability == "NET_ADMIN")); + assert!(!keep.iter().any(|capability| capability == "NET_RAW")); + } + + #[test] + fn sandbox_workload_capabilities_apply_add_and_drop_exactly() { + let keep = sandbox_workload_capability_keep( + &["cap_mknod".to_string()], + &["CAP_CHOWN".to_string()], + ); + assert!(keep.iter().any(|capability| capability == "MKNOD")); + assert!(!keep.iter().any(|capability| capability == "CHOWN")); + assert!(sandbox_workload_capability_keep(&[], &["ALL".to_string()]).is_empty()); + } + // --- BPF filter tests --- #[test] diff --git a/src/guest/init/src/network.rs b/src/guest/init/src/network.rs index 83aad485..929c8357 100644 --- a/src/guest/init/src/network.rs +++ b/src/guest/init/src/network.rs @@ -112,6 +112,25 @@ pub fn configure_guest_network() -> Result<(), Box> { Ok(()) } +/// Bring up only loopback for an OCI host Sandbox. +/// +/// The OCI runtime already created the isolated network namespace. Sandbox +/// mode deliberately ignores MicroVM passt environment variables and exposes +/// no egress interface in its first release. +#[cfg(target_os = "linux")] +pub fn configure_sandbox_loopback() -> Result<(), Box> { + info!("Bringing up Sandbox loopback interface"); + set_interface_up("lo")?; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn configure_sandbox_loopback() -> Result<(), Box> { + Err(Box::new(NetError::CommandFailed( + "Sandbox loopback setup requires Linux".to_string(), + ))) +} + /// Configure network interfaces using ip commands via /proc/sys/net. /// /// We use direct syscalls via libc/nix instead of shelling out to `ip`, diff --git a/src/guest/init/src/pty_server.rs b/src/guest/init/src/pty_server.rs index a806a2b4..ad55f0ec 100644 --- a/src/guest/init/src/pty_server.rs +++ b/src/guest/init/src/pty_server.rs @@ -13,7 +13,10 @@ use tracing::info; use tracing::{error, warn}; #[cfg(target_os = "linux")] -use crate::user::parse_process_user; +use crate::user::{ + home_dir_for_uid, parse_process_user, primary_gid_for_uid, resolve_image_groups, + resolve_named_user, +}; /// A bound, listening PTY-server socket — produced by [`bind_pty_server`] and /// consumed by [`serve_pty_server`]. Same early-bind rationale as @@ -25,6 +28,27 @@ pub struct PtyListener(std::os::fd::OwnedFd); #[cfg(not(target_os = "linux"))] pub struct PtyListener; +/// Adopt the host-side Unix listener passed through the OCI runtime. +/// +/// The descriptor must refer to an already-bound, listening AF_UNIX stream +/// socket. It is validated and marked `CLOEXEC` before the workload is forked. +pub fn adopt_inherited_pty_listener( + fd: std::os::fd::RawFd, +) -> Result> { + #[cfg(target_os = "linux")] + { + Ok(PtyListener(crate::listener::adopt_unix_listener( + fd, "PTY", + )?)) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = fd; + Err("inherited PTY listeners require Linux".into()) + } +} + /// Bind + listen the PTY vsock socket (port 4090). Pure socket syscalls, safe to /// call on the main thread before the container fork. pub fn bind_pty_server() -> Result> { @@ -154,13 +178,26 @@ fn handle_pty_connection(fd: std::os::fd::OwnedFd) -> Result<(), Box user, - Err(error) => { - write_error(&mut stream, &error)?; - return Ok(()); + let resolve_rootfs = request.rootfs.as_deref().unwrap_or("/"); + let resolved_user = request + .user + .as_deref() + .and_then(|user| resolve_named_user(user, resolve_rootfs)); + let mut process_user = + match parse_process_user(resolved_user.as_deref().or(request.user.as_deref())) { + Ok(user) => user, + Err(error) => { + write_error(&mut stream, &error)?; + return Ok(()); + } + }; + if let Some(process_user) = process_user.as_mut() { + if process_user.gid.is_none() { + process_user.gid = primary_gid_for_uid(resolve_rootfs, process_user.uid); } - }; + } + let process_home = + process_user.and_then(|process_user| home_dir_for_uid(resolve_rootfs, process_user.uid)); info!(cmd = ?request.cmd, "PTY session starting"); @@ -171,7 +208,7 @@ fn handle_pty_connection(fd: std::os::fd::OwnedFd) -> Result<(), Box = request + let mut sec_supplemental_groups: Vec = request .env .iter() .find_map(|entry| entry.strip_prefix("A3S_SEC_SUPPLEMENTAL_GROUPS=")) @@ -181,6 +218,16 @@ fn handle_pty_connection(fd: std::os::fd::OwnedFd) -> Result<(), Box = request .env .iter() @@ -201,6 +248,10 @@ fn handle_pty_connection(fd: std::os::fd::OwnedFd) -> Result<(), Box Result<(), Box Result<(), Box> { + restore_rootfs_metadata_excluding(root, &HashSet::new()) +} + +/// Replay rootfs metadata after an OCI runtime has installed procfs, tmpfs, +/// and user bind mounts. Entries at or below a live nested mount are skipped so +/// replay can never chmod/chown an attached host path. +#[cfg(target_os = "linux")] +pub fn restore_rootfs_metadata_around_mounts( + root: &Path, +) -> Result<(), Box> { + let excluded_mounts = nested_mount_points(root)?; + restore_rootfs_metadata_excluding(root, &excluded_mounts) +} + +#[cfg(target_os = "linux")] +fn restore_rootfs_metadata_excluding( + root: &Path, + excluded_mounts: &HashSet, +) -> Result<(), Box> { + // Runtime may update generated files such as resolv.conf after the image + // rootfs cache is composed, so image replay validates type and symlink + // identity but not regular-file size. The terminal snapshot was captured + // after all container writes and remains strict. + apply_metadata_manifest(root, IMAGE_ROOTFS_METADATA_PATH, false, excluded_mounts)?; + apply_metadata_manifest(root, ROOTFS_METADATA_PATH, true, excluded_mounts)?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn apply_metadata_manifest( + root: &Path, + manifest_path: &str, + strict_content: bool, + excluded_mounts: &HashSet, +) -> Result<(), Box> { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let source = root.join(manifest_path.trim_start_matches('/')); + let bytes = match std::fs::read(&source) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + let manifest: RootfsMetadataManifest = serde_json::from_slice(&bytes)?; + manifest.validate()?; + let mut decoded = Vec::with_capacity(manifest.entries.len()); + let mut unique = HashSet::with_capacity(manifest.entries.len()); + for entry in manifest.entries { + if entry.uid > u32::MAX as u64 || entry.gid > u32::MAX as u64 { + return Err("rootfs metadata uid/gid exceeds Linux range".into()); + } + let raw = base64::engine::general_purpose::STANDARD.decode(&entry.path_base64)?; + let relative = PathBuf::from(std::ffi::OsString::from_vec(raw)); + let relative = safe_relative_path(&relative)?; + if relative == Path::new(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')) + || relative == Path::new(ROOTFS_METADATA_PATH.trim_start_matches('/')) + || !unique.insert(relative.clone()) + { + return Err("duplicate or reserved rootfs metadata path".into()); + } + let unresolved_target = root.join(&relative); + if excluded_mounts + .iter() + .any(|mount| unresolved_target == *mount || unresolved_target.starts_with(mount)) + { + continue; + } + let target = resolve_without_symlink_parent(root, &relative)?; + let metadata = std::fs::symlink_metadata(&target)?; + let actual_kind = if metadata.file_type().is_dir() { + RootfsEntryKind::Directory + } else if metadata.file_type().is_file() { + RootfsEntryKind::Regular + } else if metadata.file_type().is_symlink() { + RootfsEntryKind::Symlink + } else { + return Err(format!("unsupported rootfs entry at {}", target.display()).into()); + }; + if actual_kind != entry.kind + || (strict_content + && runtime_managed_rootfs_mode(&relative).is_none() + && actual_kind == RootfsEntryKind::Regular + && metadata.size() != entry.size) + { + return Err(format!("rootfs metadata mismatch at {}", target.display()).into()); + } + if actual_kind == RootfsEntryKind::Symlink { + let expected = entry + .link_target_base64 + .as_ref() + .ok_or("symlink metadata is missing its target")?; + let expected = base64::engine::general_purpose::STANDARD.decode(expected)?; + if std::fs::read_link(&target)?.as_os_str().as_bytes() != expected { + return Err(format!("rootfs symlink mismatch at {}", target.display()).into()); + } + } + decoded.push(( + entry, + target, + metadata.uid() as u64, + metadata.gid() as u64, + metadata.mode(), + )); + } + + for (entry, target, current_uid, current_gid, current_mode) in &decoded { + if entry.uid == *current_uid && entry.gid == *current_gid { + continue; + } + if entry.kind != RootfsEntryKind::Symlink && current_mode & 0o200 == 0 { + std::fs::set_permissions( + target, + std::fs::Permissions::from_mode((current_mode & 0o7777) | 0o200), + ) + .map_err(|error| { + format!( + "failed to make {} writable for ownership replay: {error}", + target.display() + ) + })?; + } + let path = std::ffi::CString::new(target.as_os_str().as_bytes())?; + if unsafe { libc::lchown(path.as_ptr(), entry.uid as u32, entry.gid as u32) } != 0 { + return Err(format!( + "failed to restore ownership at {} from {}:{} to {}:{}: {}", + target.display(), + current_uid, + current_gid, + entry.uid, + entry.gid, + std::io::Error::last_os_error() + ) + .into()); + } + } + decoded.sort_by_key(|(_, path, _, _, _)| std::cmp::Reverse(path.components().count())); + for (entry, target, _, _, _) in &decoded { + if entry.kind != RootfsEntryKind::Symlink { + let current_mode = std::fs::symlink_metadata(target)?.mode() & 0o7777; + let relative = target.strip_prefix(root)?; + let desired_mode = runtime_managed_rootfs_mode(relative).unwrap_or(entry.mode & 0o7777); + if current_mode == desired_mode { + continue; + } + std::fs::set_permissions(target, std::fs::Permissions::from_mode(desired_mode)) + .map_err(|error| { + format!( + "failed to restore mode at {} to {:o}: {error}", + target.display(), + desired_mode + ) + })?; + } + } + match std::fs::remove_file(source) { + Ok(()) => {} + // A host-prepared read-only OCI rootfs has already passed every type, + // ownership, mode, size, and symlink check above. Keeping the internal + // manifest is safe when the mount itself prevents its removal. + Err(error) if error.raw_os_error() == Some(libc::EROFS) => {} + Err(error) => return Err(error.into()), + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn safe_relative_path(path: &Path) -> Result> { + use std::path::Component; + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(name) => result.push(name), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err("unsafe rootfs metadata path".into()) + } + } + } + Ok(result) +} + +#[cfg(target_os = "linux")] +fn resolve_without_symlink_parent( + root: &Path, + relative: &Path, +) -> Result> { + let mut current = root.to_path_buf(); + let components: Vec<_> = relative.components().collect(); + for (index, component) in components.iter().enumerate() { + let std::path::Component::Normal(name) = component else { + continue; + }; + current.push(name); + if index + 1 < components.len() + && std::fs::symlink_metadata(¤t)? + .file_type() + .is_symlink() + { + return Err(format!( + "symlink parent in rootfs metadata path: {}", + current.display() + ) + .into()); + } + } + Ok(current) +} + +fn append_tree( + builder: &mut tar::Builder, + root: &Path, + source: &Path, + archive_path: &Path, + excluded_mounts: &HashSet, +) -> Result<(), Box> { + if should_skip(root, source, excluded_mounts) { + return Ok(()); + } + + let metadata = std::fs::symlink_metadata(source)?; + let file_type = metadata.file_type(); + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + if file_type.is_socket() { + return Ok(()); + } + } + + if file_type.is_dir() { + builder.append_dir(archive_path, source)?; + let mut entries: Vec<_> = std::fs::read_dir(source)?.collect::>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + append_tree( + builder, + root, + &entry.path(), + &archive_path.join(entry.file_name()), + excluded_mounts, + )?; + } + } else { + builder.append_path_with_name(source, archive_path)?; + } + Ok(()) +} + +fn collect_metadata( + root: &Path, + source: &Path, + archive_path: &Path, + excluded_mounts: &HashSet, + entries: &mut Vec, +) -> Result<(), Box> { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + + if should_skip(root, source, excluded_mounts) { + return Ok(()); + } + let metadata = std::fs::symlink_metadata(source)?; + let file_type = metadata.file_type(); + let (kind, link_target_base64) = if file_type.is_dir() { + (RootfsEntryKind::Directory, None) + } else if file_type.is_file() { + (RootfsEntryKind::Regular, None) + } else if file_type.is_symlink() { + let target = std::fs::read_link(source)?; + ( + RootfsEntryKind::Symlink, + Some(base64::engine::general_purpose::STANDARD.encode(target.as_os_str().as_bytes())), + ) + } else { + return Ok(()); + }; + entries.push(RootfsMetadataEntry { + path_base64: base64::engine::general_purpose::STANDARD + .encode(archive_path.as_os_str().as_bytes()), + kind, + mode: metadata.mode(), + uid: metadata.uid() as u64, + gid: metadata.gid() as u64, + mtime: metadata.mtime().max(0) as u64, + size: metadata.size(), + link_target_base64, + }); + + if file_type.is_dir() { + let mut children: Vec<_> = std::fs::read_dir(source)?.collect::>()?; + children.sort_by_key(|entry| entry.file_name()); + for child in children { + collect_metadata( + root, + &child.path(), + &archive_path.join(child.file_name()), + excluded_mounts, + entries, + )?; + } + } + Ok(()) +} + +fn should_skip(root: &Path, source: &Path, excluded_mounts: &HashSet) -> bool { + if source != root && excluded_mounts.contains(source) { + return true; + } + let Ok(relative) = source.strip_prefix(root) else { + return true; + }; + matches!( + relative.to_str(), + Some(".a3s_rootfs_metadata_v1.json") + | Some(".a3s_rootfs_metadata_v1.json.tmp") + | Some(".a3s_image_metadata_v1.json") + | Some(".a3s_image_metadata_v1.json.tmp") + | Some(".a3s_exit_code") + // Written by libkrun's pre-PID1 init on every boot, before + // guest-init can replay terminal metadata. It is runtime + // diagnostics, not persistent container filesystem state. + | Some("init.trace.log") + ) +} + +fn nested_mount_points(root: &Path) -> Result, std::io::Error> { + #[cfg(target_os = "linux")] + { + let mountinfo = std::fs::read_to_string("/proc/self/mountinfo")?; + Ok(parse_nested_mount_points(root, &mountinfo)) + } + #[cfg(not(target_os = "linux"))] + { + let _ = root; + Ok(HashSet::new()) + } +} + +fn parse_nested_mount_points(root: &Path, mountinfo: &str) -> HashSet { + mountinfo + .lines() + .filter_map(|line| line.split_whitespace().nth(4)) + .map(decode_mountinfo_path) + .map(PathBuf::from) + .filter(|mount| mount != root && mount.starts_with(root)) + .collect() +} + +fn decode_mountinfo_path(path: &str) -> String { + path.replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn metadata_replay_normalizes_runtime_managed_files() { + use base64::Engine; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = tempfile::tempdir().unwrap(); + let etc = directory.path().join("etc"); + std::fs::create_dir(&etc).unwrap(); + let hosts = etc.join("hosts"); + let probe = etc.join("probe"); + let environment = directory.path().join(".a3s-box-env"); + let init = directory.path().join("usr/sbin/init"); + std::fs::create_dir_all(init.parent().unwrap()).unwrap(); + std::fs::write(&hosts, "127.0.0.1 localhost\n").unwrap(); + std::fs::write(&probe, "probe\n").unwrap(); + std::fs::write(&environment, "PATH=L3Vzci9iaW4=\nSMOKE=dHJ1ZQ\n").unwrap(); + for path in [&hosts, &probe, &environment] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap(); + } + std::fs::write(&init, "guest init\n").unwrap(); + std::fs::set_permissions(&init, std::fs::Permissions::from_mode(0o755)).unwrap(); + let metadata = std::fs::metadata(&hosts).unwrap(); + let entry = |path: &str, size: u64| RootfsMetadataEntry { + path_base64: base64::engine::general_purpose::STANDARD + .encode(Path::new(path).as_os_str().as_bytes()), + kind: RootfsEntryKind::Regular, + mode: 0o100600, + uid: metadata.uid() as u64, + gid: metadata.gid() as u64, + mtime: 0, + size, + link_target_base64: None, + }; + let manifest = RootfsMetadataManifest::new(vec![ + // Deliberately stale size and mode: both are runtime-owned. + entry("./etc/hosts", 1), + entry("./etc/probe", 6), + entry("./usr/sbin/init", 1), + entry("./.a3s-box-env", 1), + ]); + std::fs::write( + directory + .path() + .join(ROOTFS_METADATA_PATH.trim_start_matches('/')), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + apply_metadata_manifest( + directory.path(), + ROOTFS_METADATA_PATH, + true, + &HashSet::new(), + ) + .unwrap(); + + assert_eq!( + std::fs::metadata(hosts).unwrap().permissions().mode() & 0o7777, + 0o644 + ); + assert_eq!( + std::fs::metadata(probe).unwrap().permissions().mode() & 0o7777, + 0o600 + ); + assert_eq!( + std::fs::metadata(init).unwrap().permissions().mode() & 0o7777, + 0o755 + ); + assert_eq!( + std::fs::metadata(environment).unwrap().permissions().mode() & 0o7777, + 0o600 + ); + } + + #[test] + fn archive_preserves_guest_visible_mode_uid_gid_and_symlink() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = tempfile::TempDir::new().unwrap(); + let executable = directory.path().join("executable"); + std::fs::write(&executable, b"payload").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o751)).unwrap(); + std::os::unix::fs::symlink("executable", directory.path().join("link")).unwrap(); + let metadata = std::fs::metadata(&executable).unwrap(); + + let mut bytes = Vec::new(); + write_rootfs_archive(directory.path(), &mut bytes).unwrap(); + let mut archive = tar::Archive::new(bytes.as_slice()); + let mut saw_executable = false; + let mut saw_link = false; + for entry in archive.entries().unwrap() { + let entry = entry.unwrap(); + let path = entry.path().unwrap(); + let path = path.strip_prefix(".").unwrap_or(path.as_ref()); + match path.to_string_lossy().as_ref() { + "executable" => { + saw_executable = true; + assert_eq!(entry.header().mode().unwrap() & 0o7777, 0o751); + assert_eq!(entry.header().uid().unwrap(), metadata.uid() as u64); + assert_eq!(entry.header().gid().unwrap(), metadata.gid() as u64); + } + "link" => { + saw_link = true; + assert_eq!(entry.link_name().unwrap().unwrap(), Path::new("executable")); + } + _ => {} + } + } + assert!(saw_executable); + assert!(saw_link); + } + + #[test] + fn mountinfo_parser_decodes_and_keeps_only_nested_mounts() { + let mounts = parse_nested_mount_points( + Path::new("/"), + "1 0 0:1 / / rw - rootfs rootfs rw\n\ + 2 1 0:2 / /proc rw - proc proc rw\n\ + 3 1 0:3 / /with\\040space rw - tmpfs tmpfs rw\n", + ); + + assert!(mounts.contains(Path::new("/proc"))); + assert!(mounts.contains(Path::new("/with space"))); + assert!(!mounts.contains(Path::new("/"))); + } + + #[test] + fn persisted_manifest_records_terminal_metadata_and_excludes_internal_files() { + use base64::Engine; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::TempDir::new().unwrap(); + let executable = directory.path().join("probe"); + std::fs::write(&executable, b"probe").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(directory.path().join(".a3s_exit_code"), b"0").unwrap(); + std::fs::write(directory.path().join("init.trace.log"), b"runtime trace").unwrap(); + + persist_rootfs_metadata(directory.path()).unwrap(); + let manifest_path = directory.path().join(".a3s_rootfs_metadata_v1.json"); + let manifest: RootfsMetadataManifest = + serde_json::from_slice(&std::fs::read(manifest_path).unwrap()).unwrap(); + manifest.validate().unwrap(); + let probe_path = base64::engine::general_purpose::STANDARD + .encode(Path::new("./probe").as_os_str().as_bytes()); + let probe = manifest + .entries + .iter() + .find(|entry| entry.path_base64 == probe_path) + .unwrap(); + assert_eq!(probe.mode & 0o7777, 0o755); + assert!(!manifest.entries.iter().any(|entry| { + base64::engine::general_purpose::STANDARD + .decode(&entry.path_base64) + .is_ok_and(|path| path.ends_with(b".a3s_exit_code")) + })); + assert!(!manifest.entries.iter().any(|entry| { + base64::engine::general_purpose::STANDARD + .decode(&entry.path_base64) + .is_ok_and(|path| path.ends_with(b"init.trace.log")) + })); + } +} diff --git a/src/guest/init/src/user.rs b/src/guest/init/src/user.rs index 3c901448..3c7c7ce7 100644 --- a/src/guest/init/src/user.rs +++ b/src/guest/init/src/user.rs @@ -136,6 +136,27 @@ pub fn primary_gid_for_uid(rootfs: &str, uid: u32) -> Option { passwd_entry_for_uid(rootfs, uid).map(|(_, gid)| gid) } +/// Return the home directory recorded for `uid` in `/etc/passwd`. +/// +/// A process that switches from root to an image user must not keep root's +/// `HOME`. Login shells use this value to select their startup files, and a +/// mismatched inherited value can both leak root-oriented configuration and +/// produce permission errors. Callers should still preserve an explicit +/// `HOME` supplied in the process request. +pub fn home_dir_for_uid(rootfs: &str, uid: u32) -> Option { + let passwd = std::fs::read_to_string(std::path::Path::new(rootfs).join("etc/passwd")).ok()?; + for line in passwd.lines() { + let fields: Vec<&str> = line.split(':').collect(); + if fields.len() >= 7 && fields[2].parse::().ok() == Some(uid) { + let home = fields[5]; + if !home.is_empty() && home.starts_with('/') && !home.contains('\0') { + return Some(home.to_string()); + } + } + } + None +} + /// Look up a user's name and primary gid by uid in `/etc/passwd`. fn passwd_entry_for_uid(rootfs: &str, uid: u32) -> Option<(String, u32)> { let passwd = std::fs::read_to_string(std::path::Path::new(rootfs).join("etc/passwd")).ok()?; @@ -298,4 +319,24 @@ mod tests { vec![1000] ); } + + #[test] + fn test_home_dir_for_uid_uses_passwd_home() { + let dir = write_rootfs( + "root:x:0:0:root:/root:/bin/sh\ntester:x:1000:1000:tester:/home/tester:/bin/sh\n", + "", + ); + let rootfs = dir.path().to_str().unwrap(); + assert_eq!( + home_dir_for_uid(rootfs, 1000).as_deref(), + Some("/home/tester") + ); + assert_eq!(home_dir_for_uid(rootfs, 4242), None); + } + + #[test] + fn test_home_dir_for_uid_rejects_invalid_home() { + let dir = write_rootfs("tester:x:1000:1000:tester:relative:/bin/sh\n", ""); + assert_eq!(home_dir_for_uid(dir.path().to_str().unwrap(), 1000), None); + } } diff --git a/src/netproxy/Cargo.toml b/src/netproxy/Cargo.toml index 10184449..1d9a5666 100644 --- a/src/netproxy/Cargo.toml +++ b/src/netproxy/Cargo.toml @@ -12,7 +12,23 @@ name = "a3s_box_netproxy" path = "src/lib.rs" [target.'cfg(target_os = "macos")'.dependencies] -a3s-box-core = { version = "2.2.0", path = "../core" } +a3s-box-core = { version = "3.0", path = "../core" } +libc = { workspace = true } +tracing = { workspace = true } +smoltcp = { version = "0.11", default-features = false, features = [ + "medium-ethernet", + "proto-ipv4", + "socket-tcp", + "socket-udp", + "log", + "std", + "alloc", +] } + +[target.'cfg(all(unix, not(target_os = "macos")))'.dev-dependencies] +# The implementation is shipped only on macOS, but its Unix datagram, packet, +# and proxy state tests also run on Linux CI and production validation hosts. +a3s-box-core = { version = "3.0", path = "../core" } libc = { workspace = true } tracing = { workspace = true } smoltcp = { version = "0.11", default-features = false, features = [ diff --git a/src/netproxy/src/device.rs b/src/netproxy/src/device.rs new file mode 100644 index 00000000..380b4e95 --- /dev/null +++ b/src/netproxy/src/device.rs @@ -0,0 +1,401 @@ +use std::collections::VecDeque; +use std::io; +use std::os::unix::net::UnixDatagram; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, MutexGuard, +}; + +use smoltcp::time::Instant; +use smoltcp::wire::EthernetAddress; + +/// MAC address we assign to the virtual gateway interface. +pub(super) const GATEWAY_MAC: EthernetAddress = + EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); +/// Maximum Ethernet frame size (header + MTU). +pub(super) const MAX_FRAME: usize = 1514; +/// Bound userspace buffering while the libkrun datagram endpoint catches up. +const MAX_PENDING_TX_FRAMES: usize = 256; +/// Keep each non-blocking socket pass finite so network and VM work stay fair. +const IO_BURST_FRAMES: usize = 64; + +#[derive(Default)] +pub(super) struct NetStats { + rx_bytes: AtomicU64, + tx_bytes: AtomicU64, + rx_packets: AtomicU64, + tx_packets: AtomicU64, +} + +pub(super) struct NetStatsSnapshot { + pub(super) rx_bytes: u64, + pub(super) tx_bytes: u64, + pub(super) rx_packets: u64, + pub(super) tx_packets: u64, +} + +impl NetStats { + pub(super) fn record_rx(&self, bytes: usize) { + self.rx_bytes.fetch_add(bytes as u64, Ordering::Relaxed); + self.rx_packets.fetch_add(1, Ordering::Relaxed); + } + + pub(super) fn record_tx(&self, bytes: usize) { + self.tx_bytes.fetch_add(bytes as u64, Ordering::Relaxed); + self.tx_packets.fetch_add(1, Ordering::Relaxed); + } + + pub(super) fn snapshot(&self) -> NetStatsSnapshot { + NetStatsSnapshot { + rx_bytes: self.rx_bytes.load(Ordering::Relaxed), + tx_bytes: self.tx_bytes.load(Ordering::Relaxed), + rx_packets: self.rx_packets.load(Ordering::Relaxed), + tx_packets: self.tx_packets.load(Ordering::Relaxed), + } + } +} + +// ── smoltcp phy::Device ─────────────────────────────────────────────────────── + +/// smoltcp physical-layer device backed by a connected Unix datagram socket. +/// +/// Frames from the VM arrive via `recv()` and are queued in `rx_queue`. +/// smoltcp reads them through `receive()`. Frames smoltcp wants to transmit +/// are sent directly to the peer via `transmit()` / `TxToken::consume()`. +/// +/// The socket MUST be connected to the peer (via `UnixDatagram::connect`) before +/// use so that `send()` works without a destination address. On macOS, using +/// `send_to()` on a socket whose peer has called `connect()` to us causes +/// ECONNRESET / EDESTADDRREQ in the peer's receive path. +pub(super) struct UnixgramDevice { + pub(super) socket: UnixDatagram, + pub(super) bridge: Option, + pub(super) rx_queue: VecDeque>, + pub(super) stats: Arc, + pending_tx: Arc>>>, +} + +impl UnixgramDevice { + pub(super) fn new( + socket: UnixDatagram, + bridge: Option, + stats: Arc, + ) -> Self { + Self { + socket, + bridge, + rx_queue: VecDeque::new(), + stats, + pending_tx: Arc::new(Mutex::new(VecDeque::new())), + } + } + + /// Drain the socket into `rx_queue` (non-blocking, batch up to 64 frames). + pub(super) fn drain(&mut self) { + self.flush_pending_tx(); + + if let Some(bridge) = &self.bridge { + let available = MAX_PENDING_TX_FRAMES.saturating_sub(self.pending_tx_len()); + let mut frames = Vec::new(); + bridge.drain_frames(&mut frames, available.min(IO_BURST_FRAMES)); + for frame in frames { + send_or_queue(&self.socket, &self.stats, &self.pending_tx, frame); + } + } + + let available = MAX_PENDING_TX_FRAMES.saturating_sub(self.rx_queue.len()); + let mut buf = vec![0u8; MAX_FRAME]; + for _ in 0..available.min(IO_BURST_FRAMES) { + match self.socket.recv(&mut buf) { + Ok(n) => { + tracing::trace!( + bytes = n, + "NetProxy received ethernet frame from guest/libkrun" + ); + self.stats.record_tx(n); + let frame = &buf[..n]; + let deliver_locally = self + .bridge + .as_ref() + .map(|bridge| bridge.forward_from_guest(frame)) + .unwrap_or(true); + if deliver_locally { + self.rx_queue.push_back(frame.to_vec()); + } + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + Err(e) => { + tracing::warn!(error = %e, "NetProxy: recv from libkrun failed"); + break; + } + } + } + } + + fn flush_pending_tx(&self) { + let mut pending = lock_queue(&self.pending_tx); + for _ in 0..IO_BURST_FRAMES { + let Some(frame) = pending.front() else { + break; + }; + let len = frame.len(); + match self.socket.send(frame) { + Ok(sent) if sent == len => { + pending.pop_front(); + self.stats.record_rx(len); + } + Ok(sent) => { + tracing::warn!(sent, len, "NetProxy: partial datagram send to libkrun"); + pending.pop_front(); + } + Err(error) if is_tx_backpressure(&error) => break, + Err(error) => { + tracing::warn!(%error, len, "NetProxy: queued send to libkrun failed"); + pending.pop_front(); + } + } + } + } + + pub(super) fn pending_tx_len(&self) -> usize { + lock_queue(&self.pending_tx).len() + } +} + +pub(super) struct BridgePort { + socket: UnixDatagram, + directory: PathBuf, + own_path: PathBuf, +} + +impl BridgePort { + pub(super) fn bind(directory: &Path, own_mac: [u8; 6]) -> io::Result { + std::fs::create_dir_all(directory)?; + let own_path = directory.join(mac_socket_name(own_mac)); + match std::fs::remove_file(&own_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let socket = UnixDatagram::bind(&own_path)?; + socket.set_nonblocking(true)?; + Ok(Self { + socket, + directory: directory.to_path_buf(), + own_path, + }) + } + + /// Forward one guest frame. Returns whether the local gateway must also + /// receive it (broadcast/multicast or non-peer traffic). + pub(super) fn forward_from_guest(&self, frame: &[u8]) -> bool { + let Some(destination) = ethernet_destination(frame) else { + return true; + }; + if destination == GATEWAY_MAC.0 { + return true; + } + if is_group_mac(destination) { + self.flood(frame); + return true; + } + + let peer = self.directory.join(mac_socket_name(destination)); + if peer != self.own_path && peer.exists() { + if let Err(error) = self.socket.send_to(frame, &peer) { + tracing::debug!(%error, peer = %peer.display(), "Bridge peer send failed"); + } + return false; + } + + // Unknown unicast uses normal switch flooding while still allowing the + // local gateway stack to inspect traffic addressed outside this switch. + self.flood(frame); + true + } + + fn flood(&self, frame: &[u8]) { + let Ok(entries) = std::fs::read_dir(&self.directory) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path == self.own_path || path.extension().and_then(|v| v.to_str()) != Some("sock") { + continue; + } + let _ = self.socket.send_to(frame, path); + } + } + + pub(super) fn drain_frames(&self, frames: &mut Vec>, limit: usize) { + let mut buf = [0u8; MAX_FRAME]; + for _ in 0..limit { + match self.socket.recv(&mut buf) { + Ok(size) => { + frames.push(buf[..size].to_vec()); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => { + tracing::debug!(%error, "Bridge peer receive failed"); + break; + } + } + } + } +} + +impl Drop for BridgePort { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.own_path); + } +} + +fn ethernet_destination(frame: &[u8]) -> Option<[u8; 6]> { + frame.get(..6)?.try_into().ok() +} + +fn is_group_mac(mac: [u8; 6]) -> bool { + mac[0] & 1 == 1 +} + +fn mac_socket_name(mac: [u8; 6]) -> String { + format!( + "{:02x}-{:02x}-{:02x}-{:02x}-{:02x}-{:02x}.sock", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] + ) +} + +/// Owned received frame — consumed by smoltcp's interface layer. +pub(super) struct OwnedRxToken(Vec); + +impl smoltcp::phy::RxToken for OwnedRxToken { + fn consume(mut self, f: F) -> R + where + F: FnOnce(&mut [u8]) -> R, + { + f(&mut self.0) + } +} + +/// Transmit token — smoltcp writes a frame into `buf`, which we then send. +/// +/// The socket must already be connected to the peer so `send()` works without +/// an explicit destination address. +pub(super) struct TxToken { + socket: UnixDatagram, + stats: Arc, + pending_tx: Arc>>>, +} + +impl smoltcp::phy::TxToken for TxToken { + fn consume(self, len: usize, f: F) -> R + where + F: FnOnce(&mut [u8]) -> R, + { + let mut buf = vec![0u8; len]; + let result = f(&mut buf); + tracing::trace!( + bytes = len, + "NetProxy sending ethernet frame to guest/libkrun" + ); + send_or_queue(&self.socket, &self.stats, &self.pending_tx, buf); + result + } +} + +fn send_or_queue( + socket: &UnixDatagram, + stats: &NetStats, + pending_tx: &Mutex>>, + frame: Vec, +) { + let len = frame.len(); + let mut pending = lock_queue(pending_tx); + if !pending.is_empty() { + enqueue_pending(&mut pending, frame); + return; + } + + match socket.send(&frame) { + Ok(sent) if sent == len => stats.record_rx(len), + Ok(sent) => { + tracing::warn!(sent, len, "NetProxy: partial datagram send to libkrun"); + } + Err(error) if is_tx_backpressure(&error) => { + enqueue_pending(&mut pending, frame); + } + Err(error) => { + tracing::warn!(%error, len, "NetProxy: send to libkrun failed"); + } + } +} + +fn enqueue_pending(pending: &mut VecDeque>, frame: Vec) { + if pending.len() < MAX_PENDING_TX_FRAMES { + pending.push_back(frame); + } else { + // Device::receive and Device::transmit stop issuing tokens before this + // bound is reached. Reaching it means that invariant was violated. + tracing::error!( + limit = MAX_PENDING_TX_FRAMES, + "NetProxy transmit queue invariant violated; dropping frame" + ); + } +} + +fn lock_queue(queue: &Mutex>>) -> MutexGuard<'_, VecDeque>> { + queue + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +pub(super) fn is_tx_backpressure(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) || error.raw_os_error() == Some(libc::ENOBUFS) +} + +impl smoltcp::phy::Device for UnixgramDevice { + type RxToken<'a> + = OwnedRxToken + where + Self: 'a; + type TxToken<'a> + = TxToken + where + Self: 'a; + + fn receive(&mut self, _ts: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> { + if self.pending_tx_len() >= MAX_PENDING_TX_FRAMES { + return None; + } + let frame = self.rx_queue.pop_front()?; + let tx = TxToken { + socket: self.socket.try_clone().ok()?, + stats: Arc::clone(&self.stats), + pending_tx: Arc::clone(&self.pending_tx), + }; + Some((OwnedRxToken(frame), tx)) + } + + fn transmit(&mut self, _ts: Instant) -> Option> { + self.flush_pending_tx(); + if self.pending_tx_len() != 0 { + return None; + } + Some(TxToken { + socket: self.socket.try_clone().ok()?, + stats: Arc::clone(&self.stats), + pending_tx: Arc::clone(&self.pending_tx), + }) + } + + fn capabilities(&self) -> smoltcp::phy::DeviceCapabilities { + let mut caps = smoltcp::phy::DeviceCapabilities::default(); + caps.medium = smoltcp::phy::Medium::Ethernet; + caps.max_transmission_unit = MAX_FRAME; + caps + } +} diff --git a/src/netproxy/src/lib.rs b/src/netproxy/src/lib.rs index c3a927da..8ebece4a 100644 --- a/src/netproxy/src/lib.rs +++ b/src/netproxy/src/lib.rs @@ -1,4 +1,4 @@ -#![cfg(target_os = "macos")] +#![cfg(any(target_os = "macos", all(test, unix)))] //! Pure-Rust userspace network proxy for libkrun on macOS. //! @@ -9,26 +9,38 @@ //! - **DNS**: UDP/53 queries forwarded to the host's configured DNS servers. //! - **Inbound TCP port-forwarding**: `host_port → guest_ip:guest_port` pairs //! parsed from the box's `port_map` config (e.g. `"8088:80"`). -//! -//! General outbound NAT (VM → internet) is not provided by bridge mode yet. +//! - **Outbound TCP proxying**: guest connections addressed through the gateway +//! are terminated by smoltcp and connected through the host TCP stack. + +mod device; +mod manager; +#[cfg(test)] +mod tests; -use std::collections::VecDeque; +use std::collections::HashSet; use std::io::{self, Read, Write}; -use std::net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream, UdpSocket}; -use std::os::fd::{FromRawFd, IntoRawFd, RawFd}; +use std::net::{Ipv4Addr, Shutdown, SocketAddr, SocketAddrV4, TcpListener, TcpStream, UdpSocket}; use std::os::unix::net::UnixDatagram; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc::{self, Receiver, TryRecvError}, Arc, }; use std::time::Duration; -use a3s_box_core::error::{BoxError, Result}; use smoltcp::iface::{Config, Interface, SocketSet}; use smoltcp::socket::{tcp, udp}; use smoltcp::time::Instant; -use smoltcp::wire::{EthernetAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Address}; +use smoltcp::wire::{ + EthernetFrame, EthernetProtocol, IpAddress, IpCidr, IpEndpoint, IpProtocol, Ipv4Address, + Ipv4Packet, TcpPacket, +}; + +use device::{BridgePort, NetStats, UnixgramDevice, GATEWAY_MAC}; +use manager::write_stats_file; + +pub use manager::{spawn_inherited_netproxy, InheritedNetProxyConfig, NetProxyManager}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -49,185 +61,85 @@ fn to_smoltcp_ipv4(ip: Ipv4Addr) -> Ipv4Address { // ── Constants ───────────────────────────────────────────────────────────────── -/// MAC address we assign to the virtual gateway interface. -const GATEWAY_MAC: EthernetAddress = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); -/// Maximum Ethernet frame size (header + MTU). -const MAX_FRAME: usize = 1514; /// Ephemeral port range start for outbound TCP connections from the gateway. const EPHEMERAL_BASE: u16 = 49152; +/// Bound per-box memory and host resources consumed by transparent TCP flows. +const MAX_OUTBOUND_CONNECTIONS: usize = 256; +/// Do not let a host-side connect stall the guest indefinitely. +const OUTBOUND_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// Idle TCP state is eventually reclaimed even if one endpoint disappears. +const TCP_IDLE_TIMEOUT: smoltcp::time::Duration = smoltcp::time::Duration::from_secs(300); /// How often the proxy refreshes its stats file. const STATS_WRITE_INTERVAL: Duration = Duration::from_secs(1); -#[derive(Default)] -struct NetStats { - rx_bytes: AtomicU64, - tx_bytes: AtomicU64, - rx_packets: AtomicU64, - tx_packets: AtomicU64, -} +// ── Port-forward state ──────────────────────────────────────────────────────── -struct NetStatsSnapshot { - rx_bytes: u64, - tx_bytes: u64, - rx_packets: u64, - tx_packets: u64, +/// Parsed port-forward rule: `host_port → guest_ip:guest_port`. +struct PortForward { + listener: TcpListener, + guest_ip: Ipv4Addr, + guest_port: u16, + /// TCP handshake in progress from the gateway to the guest. + pending: Vec, + /// Fully established connections ready for data proxying. + active: Vec, } -impl NetStats { - fn record_rx(&self, bytes: usize) { - self.rx_bytes.fetch_add(bytes as u64, Ordering::Relaxed); - self.rx_packets.fetch_add(1, Ordering::Relaxed); - } - - fn record_tx(&self, bytes: usize) { - self.tx_bytes.fetch_add(bytes as u64, Ordering::Relaxed); - self.tx_packets.fetch_add(1, Ordering::Relaxed); - } - - fn snapshot(&self) -> NetStatsSnapshot { - NetStatsSnapshot { - rx_bytes: self.rx_bytes.load(Ordering::Relaxed), - tx_bytes: self.tx_bytes.load(Ordering::Relaxed), - rx_packets: self.rx_packets.load(Ordering::Relaxed), - tx_packets: self.tx_packets.load(Ordering::Relaxed), - } - } +struct PendingGuestConnection { + handle: smoltcp::iface::SocketHandle, + host_stream: TcpStream, + started_at: std::time::Instant, } -// ── smoltcp phy::Device ─────────────────────────────────────────────────────── - -/// smoltcp physical-layer device backed by a connected Unix datagram socket. -/// -/// Frames from the VM arrive via `recv()` and are queued in `rx_queue`. -/// smoltcp reads them through `receive()`. Frames smoltcp wants to transmit -/// are sent directly to the peer via `transmit()` / `TxToken::consume()`. -/// -/// The socket MUST be connected to the peer (via `UnixDatagram::connect`) before -/// use so that `send()` works without a destination address. On macOS, using -/// `send_to()` on a socket whose peer has called `connect()` to us causes -/// ECONNRESET / EDESTADDRREQ in the peer's receive path. -struct UnixgramDevice { - socket: UnixDatagram, - rx_queue: VecDeque>, - stats: Arc, +struct TcpProxyConnection { + handle: smoltcp::iface::SocketHandle, + host_stream: TcpStream, + host_read_closed: bool, + guest_read_closed: bool, + /// An abort raised after the most recent interface poll must survive until + /// the next poll so smoltcp can emit its reset packet. + abort_pending: bool, } -impl UnixgramDevice { - /// Drain the socket into `rx_queue` (non-blocking, batch up to 64 frames). - fn drain(&mut self) { - let mut buf = vec![0u8; MAX_FRAME]; - for _ in 0..64 { - match self.socket.recv(&mut buf) { - Ok(n) => { - tracing::trace!( - bytes = n, - "NetProxy received ethernet frame from guest/libkrun" - ); - self.stats.record_tx(n); - self.rx_queue.push_back(buf[..n].to_vec()) - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, - Err(e) => { - tracing::warn!(error = %e, "NetProxy: recv from libkrun failed"); - break; - } - } +impl TcpProxyConnection { + fn new(handle: smoltcp::iface::SocketHandle, host_stream: TcpStream) -> Self { + Self { + handle, + host_stream, + host_read_closed: false, + guest_read_closed: false, + abort_pending: false, } } } -/// Owned received frame — consumed by smoltcp's interface layer. -struct OwnedRxToken(Vec); - -impl smoltcp::phy::RxToken for OwnedRxToken { - fn consume(mut self, f: F) -> R - where - F: FnOnce(&mut [u8]) -> R, - { - f(&mut self.0) - } -} - -/// Transmit token — smoltcp writes a frame into `buf`, which we then send. -/// -/// The socket must already be connected to the peer so `send()` works without -/// an explicit destination address. -struct TxToken { - socket: UnixDatagram, - stats: Arc, +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +struct OutboundFlow { + guest_ip: Ipv4Addr, + guest_port: u16, + remote_ip: Ipv4Addr, + remote_port: u16, } -impl smoltcp::phy::TxToken for TxToken { - fn consume(self, len: usize, f: F) -> R - where - F: FnOnce(&mut [u8]) -> R, - { - let mut buf = vec![0u8; len]; - let result = f(&mut buf); - tracing::trace!( - bytes = len, - "NetProxy sending ethernet frame to guest/libkrun" - ); - if let Err(e) = self.socket.send(&buf) { - tracing::warn!(error = %e, len, "NetProxy: send to libkrun failed"); - } else { - self.stats.record_rx(len); - } - result - } +struct PendingOutboundConnection { + flow: OutboundFlow, + handle: smoltcp::iface::SocketHandle, + connect_result: Receiver>, + host_stream: Option, + started_at: std::time::Instant, + failed: bool, } -impl smoltcp::phy::Device for UnixgramDevice { - type RxToken<'a> - = OwnedRxToken - where - Self: 'a; - type TxToken<'a> - = TxToken - where - Self: 'a; - - fn receive(&mut self, _ts: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> { - let frame = self.rx_queue.pop_front()?; - let tx = TxToken { - socket: self.socket.try_clone().ok()?, - stats: Arc::clone(&self.stats), - }; - Some((OwnedRxToken(frame), tx)) - } - - fn transmit(&mut self, _ts: Instant) -> Option> { - Some(TxToken { - socket: self.socket.try_clone().ok()?, - stats: Arc::clone(&self.stats), - }) - } - - fn capabilities(&self) -> smoltcp::phy::DeviceCapabilities { - let mut caps = smoltcp::phy::DeviceCapabilities::default(); - caps.medium = smoltcp::phy::Medium::Ethernet; - caps.max_transmission_unit = MAX_FRAME; - caps - } -} - -// ── Port-forward state ──────────────────────────────────────────────────────── - -/// Parsed port-forward rule: `host_port → guest_ip:guest_port`. -struct PortForward { - listener: TcpListener, - guest_ip: Ipv4Addr, - guest_port: u16, - /// TCP handshake in progress: (smoltcp handle, host TcpStream). - pending: Vec<(smoltcp::iface::SocketHandle, TcpStream)>, - /// Fully established connections ready for data proxying. - active: Vec<(smoltcp::iface::SocketHandle, TcpStream)>, +struct ActiveOutboundConnection { + flow: OutboundFlow, + proxy: TcpProxyConnection, } // ── Proxy engine ────────────────────────────────────────────────────────────── struct ProxyEngineConfig { socket: UnixDatagram, + guest_ip: Ipv4Addr, gateway_ip: Ipv4Addr, prefix_len: u8, dns_servers: Vec, @@ -235,15 +147,20 @@ struct ProxyEngineConfig { shutdown: Arc, stats: Arc, stats_path: Option, + bridge: Option, } struct ProxyEngine { device: UnixgramDevice, iface: Interface, sockets: SocketSet<'static>, - dns_handle: smoltcp::iface::SocketHandle, - dns_servers: Vec, + dns_sockets: Vec<(smoltcp::iface::SocketHandle, Ipv4Addr)>, + guest_ip: Ipv4Addr, + gateway_ip: Ipv4Addr, port_forwards: Vec, + pending_outbound: Vec, + active_outbound: Vec, + outbound_connectors: Arc, next_ephemeral: u16, shutdown: Arc, stats: Arc, @@ -255,6 +172,7 @@ impl ProxyEngine { fn new(config: ProxyEngineConfig) -> Self { let ProxyEngineConfig { socket, + guest_ip, gateway_ip, prefix_len, dns_servers, @@ -262,13 +180,10 @@ impl ProxyEngine { shutdown, stats, stats_path, + bridge, } = config; - let mut device = UnixgramDevice { - socket, - rx_queue: VecDeque::new(), - stats: Arc::clone(&stats), - }; + let mut device = UnixgramDevice::new(socket, bridge, Arc::clone(&stats)); // Configure smoltcp interface as the gateway. let config = Config::new(GATEWAY_MAC.into()); @@ -277,23 +192,50 @@ impl ProxyEngine { let cidr = IpCidr::new(IpAddress::Ipv4(to_smoltcp_ipv4(gateway_ip)), prefix_len); addrs.push(cidr).ok(); }); + // The guest keeps the real destination IP in its packets and uses the + // gateway only as the Ethernet next hop. AnyIP plus a default route via + // our own gateway address lets smoltcp terminate those transparent TCP + // connections while preserving their original destination endpoints. + iface.set_any_ip(true); + let _ = iface + .routes_mut() + .add_default_ipv4_route(to_smoltcp_ipv4(gateway_ip)); let mut sockets = SocketSet::new(vec![]); - // DNS socket: listens on UDP/53 on the gateway IP. - let dns_rx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 8192]); - let dns_tx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 8192]); - let mut dns_socket = udp::Socket::new(dns_rx, dns_tx); - dns_socket.bind(53).ok(); - let dns_handle = sockets.add(dns_socket); + // The guest's resolv.conf contains the configured upstream addresses + // (for example 8.8.8.8), not the gateway address. Bind one AnyIP UDP + // socket per upstream so replies preserve the queried source IP; a + // wildcard :53 socket would reply from gateway_ip and resolvers would + // reject the mismatched response. + let dns_sockets = dns_servers + .iter() + .copied() + .filter_map(|server| { + let dns_rx = + udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 8192]); + let dns_tx = + udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 8192]); + let mut dns_socket = udp::Socket::new(dns_rx, dns_tx); + let endpoint = IpEndpoint::new(IpAddress::Ipv4(to_smoltcp_ipv4(server)), 53); + dns_socket + .bind(endpoint) + .ok() + .map(|_| (sockets.add(dns_socket), server)) + }) + .collect(); Self { device, iface, sockets, - dns_handle, - dns_servers, + dns_sockets, + guest_ip, + gateway_ip, port_forwards, + pending_outbound: Vec::new(), + active_outbound: Vec::new(), + outbound_connectors: Arc::new(AtomicUsize::new(0)), next_ephemeral: EPHEMERAL_BASE, shutdown, stats, @@ -314,28 +256,39 @@ impl ProxyEngine { // 1. Drain UnixGram socket into rx_queue. self.device.drain(); - // 2. Accept new host connections on port-forward listeners. - self.accept_connections(now); + // 2. Accept published-port clients and discover new guest outbound + // TCP flows before smoltcp consumes their SYN packets. + self.accept_connections(); + self.accept_outbound_flows(); - // 3. Poll smoltcp (processes ARP, TCP, UDP frames). + // 3. Collect non-blocking host connect results. Failed established + // flows are aborted before the interface poll so the reset is sent. + self.poll_outbound_connectors(); + + // 4. Poll smoltcp (processes ARP, TCP, UDP frames). self.iface.poll(now, &mut self.device, &mut self.sockets); - // 4. Promote pending TCP connections to active once established. + // Connections aborted after the previous poll have now had one + // dispatch opportunity and can release their socket handles. + self.finish_aborted_connections(); + + // 5. Promote pending TCP connections to active once established. self.promote_established(); + self.promote_outbound_established(); - // 5. Proxy data for active TCP connections. + // 6. Proxy data for active TCP connections. self.proxy_data(); - // 6. Forward DNS queries to real DNS servers. + // 7. Forward DNS queries to real DNS servers. self.forward_dns(); - // 7. Remove closed connections and release their smoltcp sockets. + // 8. Remove closed connections and release their smoltcp sockets. self.cleanup(); - // 8. Publish resource counters for `a3s-box stats`. + // 9. Publish resource counters for `a3s-box stats`. self.maybe_write_stats_snapshot(); - // 9. Sleep until the next smoltcp event or at most 5 ms. + // 10. Sleep until the next smoltcp event or at most 5 ms. let delay = self .iface .poll_delay(now, &self.sockets) @@ -364,7 +317,7 @@ impl ProxyEngine { // ── Accept new host connections ─────────────────────────────────────────── - fn accept_connections(&mut self, now: Instant) { + fn accept_connections(&mut self) { // First pass: accept connections, collect (forward_index, stream, guest_ip, guest_port). // We can't call open_guest_tcp while mutably borrowing port_forwards. let mut new_conns: Vec<(usize, TcpStream, Ipv4Addr, u16)> = Vec::new(); @@ -387,8 +340,12 @@ impl ProxyEngine { // Second pass: open smoltcp TCP sockets and push to pending. for (i, stream, guest_ip, guest_port) in new_conns { - let handle = self.open_guest_tcp(guest_ip, guest_port, now); - self.port_forwards[i].pending.push((handle, stream)); + let handle = self.open_guest_tcp(guest_ip, guest_port); + self.port_forwards[i].pending.push(PendingGuestConnection { + handle, + host_stream: stream, + started_at: std::time::Instant::now(), + }); tracing::debug!( guest = %guest_ip, port = guest_port, @@ -403,7 +360,6 @@ impl ProxyEngine { &mut self, guest_ip: Ipv4Addr, guest_port: u16, - _now: Instant, ) -> smoltcp::iface::SocketHandle { let rx = tcp::SocketBuffer::new(vec![0u8; 65536]); let tx = tcp::SocketBuffer::new(vec![0u8; 65536]); @@ -420,96 +376,246 @@ impl ProxyEngine { .connect(self.iface.context(), remote, local_port) .ok(); socket.set_keep_alive(Some(smoltcp::time::Duration::from_secs(30))); + socket.set_timeout(Some(TCP_IDLE_TIMEOUT)); self.sockets.add(socket) } + // ── Discover guest outbound TCP connections ────────────────────────────── + + fn accept_outbound_flows(&mut self) { + let queued_flows: HashSet<_> = self + .device + .rx_queue + .iter() + .filter_map(|frame| outbound_syn_flow(frame, self.guest_ip, self.gateway_ip)) + .collect(); + + for flow in queued_flows { + if self.outbound_flow_exists(flow) { + continue; + } + if self.pending_outbound.len() + self.active_outbound.len() >= MAX_OUTBOUND_CONNECTIONS + || self.outbound_connectors.load(Ordering::Relaxed) >= MAX_OUTBOUND_CONNECTIONS + { + tracing::warn!( + limit = MAX_OUTBOUND_CONNECTIONS, + "NetProxy outbound connection limit reached" + ); + continue; + } + + let connect_result = match spawn_outbound_connect( + flow, + Arc::clone(&self.outbound_connectors), + ) { + Ok(receiver) => receiver, + Err(error) => { + tracing::warn!(%error, ?flow, "NetProxy failed to spawn outbound connector"); + continue; + } + }; + + let rx = tcp::SocketBuffer::new(vec![0u8; 65536]); + let tx = tcp::SocketBuffer::new(vec![0u8; 65536]); + let mut socket = tcp::Socket::new(rx, tx); + let endpoint = IpEndpoint::new( + IpAddress::Ipv4(to_smoltcp_ipv4(flow.remote_ip)), + flow.remote_port, + ); + if let Err(error) = socket.listen(endpoint) { + tracing::warn!(?error, ?flow, "NetProxy failed to listen for outbound flow"); + continue; + } + socket.set_keep_alive(Some(smoltcp::time::Duration::from_secs(30))); + socket.set_timeout(Some(TCP_IDLE_TIMEOUT)); + let handle = self.sockets.add(socket); + + self.pending_outbound.push(PendingOutboundConnection { + flow, + handle, + connect_result, + host_stream: None, + started_at: std::time::Instant::now(), + failed: false, + }); + tracing::debug!( + ?flow, + ?handle, + "NetProxy discovered guest outbound TCP flow" + ); + } + } + + fn outbound_flow_exists(&self, flow: OutboundFlow) -> bool { + self.pending_outbound + .iter() + .any(|pending| pending.flow == flow) + || self + .active_outbound + .iter() + .any(|active| active.flow == flow) + } + + /// Collect host connect results without blocking the netproxy packet loop. + /// This runs before `iface.poll`, so an aborted smoltcp socket gets a chance + /// to emit its reset before cleanup removes it. + fn poll_outbound_connectors(&mut self) { + for pending in &mut self.pending_outbound { + if pending.failed || pending.host_stream.is_some() { + continue; + } + + match pending.connect_result.try_recv() { + Ok(Ok(stream)) => { + pending.host_stream = Some(stream); + tracing::debug!(flow = ?pending.flow, "NetProxy host TCP connection established"); + } + Ok(Err(error)) => { + tracing::debug!(%error, flow = ?pending.flow, "NetProxy host TCP connection failed"); + self.sockets.get_mut::(pending.handle).abort(); + pending.failed = true; + } + Err(TryRecvError::Empty) => { + if pending.started_at.elapsed() + > OUTBOUND_CONNECT_TIMEOUT + Duration::from_secs(1) + { + tracing::debug!(flow = ?pending.flow, "NetProxy host TCP connection timed out"); + self.sockets.get_mut::(pending.handle).abort(); + pending.failed = true; + } + } + Err(TryRecvError::Disconnected) => { + self.sockets.get_mut::(pending.handle).abort(); + pending.failed = true; + } + } + } + } + // ── Promote pending → active ────────────────────────────────────────────── fn promote_established(&mut self) { for pf in &mut self.port_forwards { let mut still_pending = Vec::new(); - for (handle, stream) in pf.pending.drain(..) { - let socket = self.sockets.get::(handle); + let mut to_remove = Vec::new(); + for pending in pf.pending.drain(..) { + let socket = self.sockets.get::(pending.handle); use smoltcp::socket::tcp::State; match socket.state() { State::Established => { - tracing::debug!(handle = ?handle, "NetProxy guest TCP connection established"); - pf.active.push((handle, stream)); + tracing::debug!(handle = ?pending.handle, "NetProxy guest TCP connection established"); + pf.active + .push(TcpProxyConnection::new(pending.handle, pending.host_stream)); } - State::Closed | State::TimeWait | State::CloseWait => { - tracing::debug!(handle = ?handle, state = ?socket.state(), "NetProxy guest TCP connection closed before establishment"); - // Connection failed; close host side - drop(stream); - self.sockets.remove(handle); + State::Closed | State::TimeWait => { + tracing::debug!(handle = ?pending.handle, state = ?socket.state(), "NetProxy guest TCP connection closed before establishment"); + to_remove.push(pending.handle); + } + _ if pending.started_at.elapsed() > OUTBOUND_CONNECT_TIMEOUT => { + tracing::debug!(handle = ?pending.handle, "NetProxy guest TCP connection timed out"); + to_remove.push(pending.handle); } _ => { - still_pending.push((handle, stream)); + still_pending.push(pending); } } } pf.pending = still_pending; + for handle in to_remove { + self.sockets.remove(handle); + } } } - // ── Bidirectional data proxy ────────────────────────────────────────────── + fn promote_outbound_established(&mut self) { + use smoltcp::socket::tcp::State; - fn proxy_data(&mut self) { - for pf in &mut self.port_forwards { - for (handle, host_stream) in &mut pf.active { - let socket = self.sockets.get_mut::(*handle); - - // smoltcp → host (data received from guest) - if socket.can_recv() { - socket - .recv(|data| { - tracing::trace!(handle = ?*handle, bytes = data.len(), "NetProxy forwarding guest -> host bytes"); - let _ = host_stream.write_all(data); - (data.len(), ()) - }) - .ok(); + let mut still_pending = Vec::new(); + let mut to_remove = Vec::new(); + for mut pending in self.pending_outbound.drain(..) { + let state = self.sockets.get::(pending.handle).state(); + if pending.failed || matches!(state, State::Closed | State::TimeWait) { + to_remove.push(pending.handle); + continue; + } + + if matches!(state, State::Established | State::CloseWait) { + if let Some(stream) = pending.host_stream.take() { + tracing::debug!(flow = ?pending.flow, handle = ?pending.handle, "NetProxy outbound TCP proxy active"); + self.active_outbound.push(ActiveOutboundConnection { + flow: pending.flow, + proxy: TcpProxyConnection::new(pending.handle, stream), + }); + continue; } + } - // host → smoltcp (data from host curl/client) - if socket.can_send() { - let mut buf = [0u8; 8192]; - match host_stream.read(&mut buf) { - Ok(0) => { - tracing::debug!(handle = ?*handle, "NetProxy host side closed connection"); - socket.close(); - } - Ok(n) => { - tracing::trace!(handle = ?*handle, bytes = n, "NetProxy forwarding host -> guest bytes"); - socket.send_slice(&buf[..n]).ok(); - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => {} - Err(_) => { - socket.close(); - } - } + still_pending.push(pending); + } + self.pending_outbound = still_pending; + for handle in to_remove { + self.sockets.remove(handle); + } + } + + fn finish_aborted_connections(&mut self) { + for pf in &mut self.port_forwards { + let mut to_remove = Vec::new(); + pf.active.retain(|connection| { + if connection.abort_pending { + to_remove.push(connection.handle); + false + } else { + true } + }); + for handle in to_remove { + self.sockets.remove(handle); } } + + let mut to_remove = Vec::new(); + self.active_outbound.retain(|connection| { + if connection.proxy.abort_pending { + to_remove.push(connection.proxy.handle); + false + } else { + true + } + }); + for handle in to_remove { + self.sockets.remove(handle); + } + } + + // ── Bidirectional data proxy ────────────────────────────────────────────── + + fn proxy_data(&mut self) { + for pf in &mut self.port_forwards { + for connection in &mut pf.active { + proxy_tcp_connection(&mut self.sockets, connection); + } + } + for connection in &mut self.active_outbound { + proxy_tcp_connection(&mut self.sockets, &mut connection.proxy); + } } // ── DNS forwarding ──────────────────────────────────────────────────────── fn forward_dns(&mut self) { - let dns_server = match self.dns_servers.first() { - Some(s) => *s, - None => return, - }; - - let socket = self.sockets.get_mut::(self.dns_handle); - if !socket.can_recv() { + let next_query = self.dns_sockets.iter().find_map(|(handle, server)| { + let socket = self.sockets.get_mut::(*handle); + if !socket.can_recv() { + return None; + } + let (query, source) = socket.recv().ok()?; + Some((*handle, *server, query.to_vec(), source)) + }); + let Some((handle, dns_server, query, source)) = next_query else { return; - } - let (query, src_endpoint) = match socket.recv() { - Ok(r) => r, - Err(_) => return, }; - let query = query.to_vec(); - let src = src_endpoint; // Forward query to the real DNS server via a host UDP socket. match UdpSocket::bind("0.0.0.0:0") { @@ -519,8 +625,8 @@ impl ProxyEngine { if udp.send_to(&query, dest).is_ok() { let mut resp = vec![0u8; 4096]; if let Ok((n, _)) = udp.recv_from(&mut resp) { - let socket = self.sockets.get_mut::(self.dns_handle); - socket.send_slice(&resp[..n], src).ok(); + let socket = self.sockets.get_mut::(handle); + socket.send_slice(&resp[..n], source).ok(); } } } @@ -537,486 +643,206 @@ impl ProxyEngine { for pf in &mut self.port_forwards { // Collect handles that need to be removed first, then remove outside retain. let mut to_remove = Vec::new(); - pf.active.retain(|(handle, _stream)| { - let state = self.sockets.get::(*handle).state(); - match state { - State::Closed | State::TimeWait | State::CloseWait => { - to_remove.push(*handle); - false - } - _ => true, + pf.active.retain(|connection| { + let state = self.sockets.get::(connection.handle).state(); + if matches!(state, State::Closed | State::TimeWait) && !connection.abort_pending { + to_remove.push(connection.handle); + false + } else { + true } }); - for h in to_remove { - self.sockets.remove(h); + for handle in to_remove { + self.sockets.remove(handle); } } - } -} -// ── NetProxyManager lifecycle ───────────────────────────────────────────────── - -/// Manages the lifecycle of the pure-Rust vfkit network proxy thread. -/// -/// Drop calls `stop()` automatically. -pub struct NetProxyManager { - socket_path: PathBuf, - stats_path: PathBuf, - net_socket_fd: Option, - net_proxy_fd: Option, -} - -impl NetProxyManager { - /// Create a new manager. Socket will be placed at - /// `~/.a3s/boxes//sockets/net.sock`. - pub fn new(box_dir: &Path) -> Self { - let socket_dir = box_dir.join("sockets"); - Self { - socket_path: socket_dir.join("net.sock"), - stats_path: socket_dir.join("net.stats.json"), - net_socket_fd: None, - net_proxy_fd: None, - } - } - - pub fn socket_path(&self) -> &Path { - &self.socket_path - } - - pub fn stats_path(&self) -> &Path { - &self.stats_path - } - - pub fn net_socket_fd(&self) -> Option { - self.net_socket_fd - } - - pub fn net_proxy_fd(&self) -> Option { - self.net_proxy_fd - } - - /// Create socketpair for NetProxy. - /// - /// Unlike the name suggests, this does NOT spawn a thread. Thread spawning - /// happens in `spawn_inherited_netproxy()` called from the shim. - pub fn spawn( - &mut self, - _ip: Ipv4Addr, - _gateway: Ipv4Addr, - _prefix_len: u8, - _dns_servers: &[Ipv4Addr], - _port_map: &[String], - ) -> Result<()> { - let (proxy_socket, krun_fd) = socketpair_unixgram()?; - self.net_socket_fd = Some(krun_fd); - self.net_proxy_fd = Some(proxy_socket.into_raw_fd()); - Ok(()) - } - - pub fn stop(&mut self) { - if let Some(fd) = self.net_socket_fd.take() { - unsafe { - libc::close(fd); - } - } - if let Some(fd) = self.net_proxy_fd.take() { - unsafe { - libc::close(fd); + let mut to_remove = Vec::new(); + self.active_outbound.retain(|connection| { + let state = self + .sockets + .get::(connection.proxy.handle) + .state(); + if matches!(state, State::Closed | State::TimeWait) && !connection.proxy.abort_pending { + to_remove.push(connection.proxy.handle); + false + } else { + true } + }); + for handle in to_remove { + self.sockets.remove(handle); } - std::fs::remove_file(&self.socket_path).ok(); - std::fs::remove_file(&self.stats_path).ok(); - } - - pub fn is_running(&mut self) -> bool { - self.net_socket_fd.is_some() || self.net_proxy_fd.is_some() } } -impl Drop for NetProxyManager { - fn drop(&mut self) { - self.stop(); +/// Extract the original four-tuple from a guest TCP SYN routed through the +/// gateway. Peer-to-peer bridge frames use the peer MAC and are deliberately +/// excluded so the local Ethernet switch keeps owning those connections. +fn outbound_syn_flow( + frame: &[u8], + expected_guest_ip: Ipv4Addr, + gateway_ip: Ipv4Addr, +) -> Option { + let ethernet = EthernetFrame::new_checked(frame).ok()?; + if ethernet.dst_addr() != GATEWAY_MAC || ethernet.ethertype() != EthernetProtocol::Ipv4 { + return None; + } + + let ipv4 = Ipv4Packet::new_checked(ethernet.payload()).ok()?; + if ipv4.next_header() != IpProtocol::Tcp { + return None; + } + let guest_ip = Ipv4Addr::from(ipv4.src_addr().0); + let remote_ip = Ipv4Addr::from(ipv4.dst_addr().0); + if guest_ip != expected_guest_ip + || remote_ip == gateway_ip + || remote_ip.is_unspecified() + || remote_ip.is_multicast() + || remote_ip == Ipv4Addr::BROADCAST + { + return None; } -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -pub fn spawn_inherited_netproxy( - fd: RawFd, - guest_ip: Ipv4Addr, - gateway: Ipv4Addr, - prefix_len: u8, - dns_servers: &[Ipv4Addr], - port_map: &[String], - stats_path: Option, -) -> Result<()> { - let socket = unsafe { UnixDatagram::from_raw_fd(fd) }; - let port_forwards = parse_port_forwards(port_map, guest_ip) - .map_err(|e| BoxError::NetworkError(format!("invalid port_map: {}", e)))?; - let dns_servers = dns_servers.to_vec(); - let shutdown = Arc::new(AtomicBool::new(false)); - let stats = Arc::new(NetStats::default()); - - std::thread::Builder::new() - .name("a3s-netproxy".to_string()) - .spawn(move || { - tracing::info!(fd, gateway = %gateway, guest_ip = %guest_ip, stats = ?stats_path, "NetProxy thread started"); - if let Err(e) = socket.set_nonblocking(true) { - tracing::error!(error = %e, "NetProxy: set_nonblocking failed"); - return; - } - let mut engine = ProxyEngine::new(ProxyEngineConfig { - socket, - gateway_ip: gateway, - prefix_len, - dns_servers, - port_forwards, - shutdown, - stats, - stats_path, - }); - engine.run(); - tracing::info!("NetProxy thread exiting"); - }) - .map_err(|e| BoxError::NetworkError(format!("failed to spawn netproxy thread: {}", e)))?; - - Ok(()) -} - -fn socketpair_unixgram() -> Result<(UnixDatagram, RawFd)> { - let mut fds = [-1; 2]; - let ret = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr()) }; - if ret != 0 { - return Err(BoxError::NetworkError(format!( - "failed to create unix datagram socketpair: {}", - io::Error::last_os_error() - ))); + let tcp = TcpPacket::new_checked(ipv4.payload()).ok()?; + if !tcp.syn() || tcp.ack() || tcp.src_port() == 0 || tcp.dst_port() == 0 { + return None; } - let proxy_socket = unsafe { UnixDatagram::from_raw_fd(fds[0]) }; - Ok((proxy_socket, fds[1])) -} - -fn write_stats_file(path: &Path, stats: NetStatsSnapshot) -> io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let updated_at_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let body = format!( - "{{\"schema\":\"a3s-box.netproxy.stats.v1\",\"rx_bytes\":{},\"tx_bytes\":{},\"rx_packets\":{},\"tx_packets\":{},\"updated_at_ms\":{}}}\n", - stats.rx_bytes, stats.tx_bytes, stats.rx_packets, stats.tx_packets, updated_at_ms - ); - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, body)?; - std::fs::rename(tmp, path) + Some(OutboundFlow { + guest_ip, + guest_port: tcp.src_port(), + remote_ip, + remote_port: tcp.dst_port(), + }) } -/// Parse `["8088:80", "443:443"]` into `Vec`. -/// -/// Each rule maps `host_port → guest_ip:guest_port`. Guest IP is always the -/// IPAM-assigned `guest_ip`. -fn parse_port_forwards( - port_map: &[String], - guest_ip: Ipv4Addr, -) -> std::result::Result, String> { - let mut forwards = Vec::new(); - for entry in port_map { - let mapping = a3s_box_core::parse_port_mapping(entry)?; - let host_port = mapping.host_port; - let guest_port = mapping.guest_port; - - let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, host_port)) - .map_err(|e| format!("cannot bind 0.0.0.0:{}: {}", host_port, e))?; - listener - .set_nonblocking(true) - .map_err(|e| format!("set_nonblocking on listener: {}", e))?; - - tracing::info!( - host_port, - guest_port, - guest_ip = %guest_ip, - "Port-forward listener ready" - ); - forwards.push(PortForward { - listener, - guest_ip, - guest_port, - pending: Vec::new(), - active: Vec::new(), +fn spawn_outbound_connect( + flow: OutboundFlow, + connector_count: Arc, +) -> io::Result>> { + let (sender, receiver) = mpsc::sync_channel(1); + connector_count.fetch_add(1, Ordering::Relaxed); + let thread_count = Arc::clone(&connector_count); + let spawn = std::thread::Builder::new() + .name("a3s-netproxy-connect".to_string()) + .spawn(move || { + let address = SocketAddr::V4(SocketAddrV4::new(flow.remote_ip, flow.remote_port)); + let result = + TcpStream::connect_timeout(&address, OUTBOUND_CONNECT_TIMEOUT).and_then(|stream| { + stream.set_nonblocking(true)?; + let _ = stream.set_nodelay(true); + Ok(stream) + }); + let _ = sender.send(result); + thread_count.fetch_sub(1, Ordering::Relaxed); }); - } - Ok(forwards) -} - -// ── Tests ───────────────────────────────────────────────────────────────────── -#[cfg(test)] -mod tests { - use super::*; - - fn port_is_bindable(port: u16) -> bool { - TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)).is_ok() - } - - fn ports_are_bindable(ports: &[u16]) -> bool { - ports.iter().copied().all(port_is_bindable) - } - - #[test] - fn test_smoltcp_now_returns_reasonable_value() { - let now = smoltcp_now(); - // Should return microseconds since epoch - assert!(now.micros() > 0); - } - - #[test] - fn test_to_smoltcp_ipv4_conversion() { - let ip = Ipv4Addr::new(10, 88, 0, 1); - let smol_ip = to_smoltcp_ipv4(ip); - assert_eq!(smol_ip.as_bytes(), &[10, 88, 0, 1]); - } - - #[test] - fn test_to_smoltcp_ipv4_loopback() { - let ip = Ipv4Addr::new(127, 0, 0, 1); - let smol_ip = to_smoltcp_ipv4(ip); - assert_eq!(smol_ip.as_bytes(), &[127, 0, 0, 1]); - } - - #[test] - fn test_net_stats_records_bytes_and_packets() { - let stats = NetStats::default(); - - stats.record_rx(64); - stats.record_rx(128); - stats.record_tx(512); - - let snapshot = stats.snapshot(); - assert_eq!(snapshot.rx_bytes, 192); - assert_eq!(snapshot.rx_packets, 2); - assert_eq!(snapshot.tx_bytes, 512); - assert_eq!(snapshot.tx_packets, 1); - } - - #[test] - fn test_parse_port_forwards_empty_rules() { - let guest = Ipv4Addr::new(10, 89, 0, 2); - let fwds = parse_port_forwards(&[], guest).unwrap(); - assert!(fwds.is_empty()); - } - - #[test] - fn test_parse_port_forwards_rejects_udp_suffix() { - let guest = Ipv4Addr::new(10, 89, 0, 2); - let rules = vec!["19990:80/udp".to_string()]; - let error = match parse_port_forwards(&rules, guest) { - Ok(_) => panic!("UDP port mapping unexpectedly succeeded"), - Err(error) => error, - }; - - assert!(error.contains("only TCP is supported")); - } - - #[test] - fn test_parse_port_forwards_multiple_rules() { - let guest = Ipv4Addr::new(10, 89, 0, 2); - if !ports_are_bindable(&[19991, 19992, 19993]) { - eprintln!("skipping test: one or more host ports are not bindable"); - return; + match spawn { + Ok(_) => Ok(receiver), + Err(error) => { + connector_count.fetch_sub(1, Ordering::Relaxed); + Err(error) } - let rules = vec![ - "19991:80".to_string(), - "19992:443".to_string(), - "19993:8080".to_string(), - ]; - let fwds = parse_port_forwards(&rules, guest).unwrap(); - assert_eq!(fwds.len(), 3); - assert_eq!(fwds[0].guest_port, 80); - assert_eq!(fwds[1].guest_port, 443); - assert_eq!(fwds[2].guest_port, 8080); } +} - #[test] - fn test_parse_port_forwards_empty_string() { - let guest = Ipv4Addr::new(10, 89, 0, 2); - // Empty entry should fail parsing - let rules = vec!["".to_string()]; - let result = parse_port_forwards(&rules, guest); - assert!(result.is_err()); +/// Move as much data as each non-blocking endpoint can currently accept. +/// Consuming only the byte count returned by `write` and reading directly into +/// smoltcp's available transmit slice prevents partial writes from dropping +/// bytes under backpressure. +fn proxy_tcp_connection(sockets: &mut SocketSet<'static>, connection: &mut TcpProxyConnection) { + let handle = connection.handle; + let socket = sockets.get_mut::(handle); + + let mut guest_to_host_bytes = 0usize; + let mut host_write_error = None; + if socket.can_recv() { + let _ = socket.recv(|data| match connection.host_stream.write(data) { + Ok(0) if !data.is_empty() => { + host_write_error = Some(io::Error::from(io::ErrorKind::WriteZero)); + (0, ()) + } + Ok(written) => { + guest_to_host_bytes = written; + (written, ()) + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) => + { + (0, ()) + } + Err(error) => { + host_write_error = Some(error); + (0, ()) + } + }); } - - #[test] - fn test_netproxy_manager_new() { - let dir = tempfile::tempdir().unwrap(); - let mgr = NetProxyManager::new(dir.path()); - assert_eq!( - mgr.socket_path(), - dir.path().join("sockets").join("net.sock") - ); - assert_eq!( - mgr.stats_path(), - dir.path().join("sockets").join("net.stats.json") + if guest_to_host_bytes > 0 { + tracing::trace!( + ?handle, + bytes = guest_to_host_bytes, + "NetProxy forwarded guest -> host bytes" ); - assert_eq!(mgr.net_socket_fd(), None); - } - - #[test] - fn test_netproxy_manager_not_running_initially() { - let dir = tempfile::tempdir().unwrap(); - let mut mgr = NetProxyManager::new(dir.path()); - assert!(!mgr.is_running()); - } - - #[test] - fn test_netproxy_manager_stop_when_not_started() { - let dir = tempfile::tempdir().unwrap(); - let mut mgr = NetProxyManager::new(dir.path()); - mgr.stop(); // must not panic - assert!(!mgr.is_running()); } - - #[test] - fn test_netproxy_manager_spawn_creates_socketpair_fds_and_stop_closes_them() { - let dir = tempfile::tempdir().unwrap(); - let mut mgr = NetProxyManager::new(dir.path()); - - mgr.spawn( - Ipv4Addr::new(10, 89, 0, 2), - Ipv4Addr::new(10, 89, 0, 1), - 24, - &[Ipv4Addr::new(8, 8, 8, 8)], - &[], - ) - .unwrap(); - - assert!(mgr.is_running()); - assert!(mgr.net_socket_fd().is_some()); - assert!(mgr.net_proxy_fd().is_some()); - - mgr.stop(); - assert!(!mgr.is_running()); - assert!(mgr.net_socket_fd().is_none()); - assert!(mgr.net_proxy_fd().is_none()); + if let Some(error) = host_write_error { + tracing::debug!(%error, ?handle, "NetProxy host write failed"); + let _ = connection.host_stream.shutdown(Shutdown::Both); + socket.abort(); + connection.abort_pending = true; + return; } - #[test] - fn test_netproxy_manager_drop_cleans_up() { - let dir = tempfile::tempdir().unwrap(); - let socket_path = dir.path().join("sockets").join("net.sock"); - std::fs::create_dir_all(dir.path().join("sockets")).unwrap(); - std::fs::write(&socket_path, "fake").unwrap(); - { - let _mgr = NetProxyManager::new(dir.path()); - // Drop triggers cleanup - } - assert!(!socket_path.exists()); - } - - #[test] - fn test_write_stats_file_writes_json_snapshot() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("sockets").join("net.stats.json"); - - write_stats_file( - &path, - NetStatsSnapshot { - rx_bytes: 1024, - tx_bytes: 2048, - rx_packets: 3, - tx_packets: 4, - }, - ) - .unwrap(); - - let json: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - assert_eq!(json["schema"], "a3s-box.netproxy.stats.v1"); - assert_eq!(json["rx_bytes"], 1024); - assert_eq!(json["tx_bytes"], 2048); - assert_eq!(json["rx_packets"], 3); - assert_eq!(json["tx_packets"], 4); - } - - #[test] - fn test_write_stats_file_overwrites_existing_file_and_removes_temp() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("net.stats.json"); - let tmp = path.with_extension("json.tmp"); - std::fs::write(&path, "old").unwrap(); - std::fs::write(&tmp, "stale temp").unwrap(); - - write_stats_file( - &path, - NetStatsSnapshot { - rx_bytes: 1, - tx_bytes: 2, - rx_packets: 3, - tx_packets: 4, - }, - ) - .unwrap(); - - let json: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(json["rx_bytes"], 1); - assert_eq!(json["tx_bytes"], 2); - assert!(!tmp.exists()); - } - - #[test] - fn test_parse_port_forwards_valid() { - let guest = Ipv4Addr::new(10, 89, 0, 2); - if !ports_are_bindable(&[19988, 19443]) { - eprintln!("skipping test: one or more host ports are not bindable"); - return; - } - // Use a random high port to avoid conflicts - let rules = vec!["19988:80".to_string(), "19443:443".to_string()]; - let fwds = parse_port_forwards(&rules, guest).unwrap(); - assert_eq!(fwds.len(), 2); - assert_eq!(fwds[0].guest_port, 80); - assert_eq!(fwds[1].guest_port, 443); + if !connection.guest_read_closed && !socket.may_recv() { + let _ = connection.host_stream.shutdown(Shutdown::Write); + connection.guest_read_closed = true; } - #[test] - fn test_parse_port_forwards_with_protocol_suffix() { - let guest = Ipv4Addr::new(10, 89, 0, 2); - if !port_is_bindable(19989) { - eprintln!("skipping test: host port 19989 is not bindable"); - return; - } - let rules = vec!["19989:80/tcp".to_string()]; - let fwds = parse_port_forwards(&rules, guest).unwrap(); - assert_eq!(fwds[0].guest_port, 80); + let mut host_to_guest_bytes = 0usize; + let mut host_eof = false; + let mut host_read_error = None; + if !connection.host_read_closed && socket.can_send() { + let _ = socket.send(|buffer| match connection.host_stream.read(buffer) { + Ok(0) => { + host_eof = true; + (0, ()) + } + Ok(read) => { + host_to_guest_bytes = read; + (read, ()) + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) => + { + (0, ()) + } + Err(error) => { + host_read_error = Some(error); + (0, ()) + } + }); } - - #[test] - fn test_parse_port_forwards_invalid_format() { - let guest = Ipv4Addr::new(10, 89, 0, 2); - assert!(parse_port_forwards(&["notaport".to_string()], guest).is_err()); - assert!(parse_port_forwards(&["abc:80".to_string()], guest).is_err()); - assert!(parse_port_forwards(&["80:xyz".to_string()], guest).is_err()); + if host_to_guest_bytes > 0 { + tracing::trace!( + ?handle, + bytes = host_to_guest_bytes, + "NetProxy forwarded host -> guest bytes" + ); } - - #[test] - fn test_parse_port_forwards_reports_bind_conflict() { - let guest = Ipv4Addr::new(10, 89, 0, 2); - let held = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)).unwrap(); - let port = held.local_addr().unwrap().port(); - - let error = match parse_port_forwards(&[format!("{port}:80")], guest) { - Ok(_) => panic!("port-forward bind conflict should return an error"), - Err(error) => error, - }; - - assert!(error.contains(&format!("cannot bind 0.0.0.0:{port}"))); + if let Some(error) = host_read_error { + tracing::debug!(%error, ?handle, "NetProxy host read failed"); + let _ = connection.host_stream.shutdown(Shutdown::Both); + socket.abort(); + connection.abort_pending = true; + } else if host_eof { + tracing::debug!(?handle, "NetProxy host side closed its write half"); + connection.host_read_closed = true; + socket.close(); } - - // Note: test_netproxy_manager_spawn_binds_and_releases_host_ports was removed - // because spawn() no longer spawns a thread or binds ports. Port binding - // now happens in spawn_inherited_netproxy() called from the shim. } diff --git a/src/netproxy/src/manager.rs b/src/netproxy/src/manager.rs new file mode 100644 index 00000000..103516ee --- /dev/null +++ b/src/netproxy/src/manager.rs @@ -0,0 +1,231 @@ +use std::io; +use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; +use std::os::fd::{FromRawFd, IntoRawFd, RawFd}; +use std::os::unix::net::UnixDatagram; +use std::path::{Path, PathBuf}; +use std::sync::{atomic::AtomicBool, Arc}; + +use a3s_box_core::error::{BoxError, Result}; + +use super::device::{BridgePort, NetStats, NetStatsSnapshot}; +use super::{PortForward, ProxyEngine, ProxyEngineConfig}; + +// ── NetProxyManager lifecycle ───────────────────────────────────────────────── + +/// Manages the lifecycle of the pure-Rust vfkit network proxy thread. +/// +/// Drop calls `stop()` automatically. +pub struct NetProxyManager { + socket_path: PathBuf, + stats_path: PathBuf, + net_socket_fd: Option, + net_proxy_fd: Option, +} + +impl NetProxyManager { + /// Create a new manager. Socket will be placed at + /// `~/.a3s/boxes//sockets/net.sock`. + pub fn new(box_dir: &Path) -> Self { + let socket_dir = box_dir.join("sockets"); + Self { + socket_path: socket_dir.join("net.sock"), + stats_path: socket_dir.join("net.stats.json"), + net_socket_fd: None, + net_proxy_fd: None, + } + } + + pub fn socket_path(&self) -> &Path { + &self.socket_path + } + + pub fn stats_path(&self) -> &Path { + &self.stats_path + } + + pub fn net_socket_fd(&self) -> Option { + self.net_socket_fd + } + + pub fn net_proxy_fd(&self) -> Option { + self.net_proxy_fd + } + + /// Create socketpair for NetProxy. + /// + /// Unlike the name suggests, this does NOT spawn a thread. Thread spawning + /// happens in `spawn_inherited_netproxy()` called from the shim. + pub fn spawn( + &mut self, + _ip: Ipv4Addr, + _gateway: Ipv4Addr, + _prefix_len: u8, + _dns_servers: &[Ipv4Addr], + _port_map: &[String], + ) -> Result<()> { + let (proxy_socket, krun_fd) = socketpair_unixgram()?; + self.net_socket_fd = Some(krun_fd); + self.net_proxy_fd = Some(proxy_socket.into_raw_fd()); + Ok(()) + } + + pub fn stop(&mut self) { + if let Some(fd) = self.net_socket_fd.take() { + unsafe { + libc::close(fd); + } + } + if let Some(fd) = self.net_proxy_fd.take() { + unsafe { + libc::close(fd); + } + } + std::fs::remove_file(&self.socket_path).ok(); + std::fs::remove_file(&self.stats_path).ok(); + } + + pub fn is_running(&mut self) -> bool { + self.net_socket_fd.is_some() || self.net_proxy_fd.is_some() + } +} + +impl Drop for NetProxyManager { + fn drop(&mut self) { + self.stop(); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +pub struct InheritedNetProxyConfig<'a> { + pub guest_ip: Ipv4Addr, + pub gateway: Ipv4Addr, + pub prefix_len: u8, + pub dns_servers: &'a [Ipv4Addr], + pub port_map: &'a [String], + pub stats_path: Option, + pub bridge_socket_dir: Option, + pub own_mac: [u8; 6], +} + +pub fn spawn_inherited_netproxy(fd: RawFd, config: InheritedNetProxyConfig<'_>) -> Result<()> { + let InheritedNetProxyConfig { + guest_ip, + gateway, + prefix_len, + dns_servers, + port_map, + stats_path, + bridge_socket_dir, + own_mac, + } = config; + let socket = unsafe { UnixDatagram::from_raw_fd(fd) }; + let port_forwards = parse_port_forwards(port_map, guest_ip) + .map_err(|e| BoxError::NetworkError(format!("invalid port_map: {e}")))?; + let dns_servers = dns_servers.to_vec(); + let shutdown = Arc::new(AtomicBool::new(false)); + let stats = Arc::new(NetStats::default()); + let bridge = bridge_socket_dir + .as_deref() + .map(|directory| BridgePort::bind(directory, own_mac)) + .transpose() + .map_err(|error| { + BoxError::NetworkError(format!("failed to join bridge Ethernet switch: {error}")) + })?; + + std::thread::Builder::new() + .name("a3s-netproxy".to_string()) + .spawn(move || { + tracing::info!(fd, gateway = %gateway, guest_ip = %guest_ip, stats = ?stats_path, "NetProxy thread started"); + if let Err(e) = socket.set_nonblocking(true) { + tracing::error!(error = %e, "NetProxy: set_nonblocking failed"); + return; + } + + let mut engine = ProxyEngine::new(ProxyEngineConfig { + socket, + guest_ip, + gateway_ip: gateway, + prefix_len, + dns_servers, + port_forwards, + shutdown, + stats, + stats_path, + bridge, + }); + engine.run(); + tracing::info!("NetProxy thread exiting"); + }) + .map_err(|e| BoxError::NetworkError(format!("failed to spawn netproxy thread: {e}")))?; + + Ok(()) +} + +fn socketpair_unixgram() -> Result<(UnixDatagram, RawFd)> { + let mut fds = [-1; 2]; + let ret = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr()) }; + if ret != 0 { + return Err(BoxError::NetworkError(format!( + "failed to create unix datagram socketpair: {}", + io::Error::last_os_error() + ))); + } + + let proxy_socket = unsafe { UnixDatagram::from_raw_fd(fds[0]) }; + Ok((proxy_socket, fds[1])) +} + +pub(super) fn write_stats_file(path: &Path, stats: NetStatsSnapshot) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let updated_at_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let body = format!( + "{{\"schema\":\"a3s-box.netproxy.stats.v1\",\"rx_bytes\":{},\"tx_bytes\":{},\"rx_packets\":{},\"tx_packets\":{},\"updated_at_ms\":{}}}\n", + stats.rx_bytes, stats.tx_bytes, stats.rx_packets, stats.tx_packets, updated_at_ms + ); + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, body)?; + std::fs::rename(tmp, path) +} + +/// Parse `["8088:80", "443:443"]` into `Vec`. +/// +/// Each rule maps `host_port → guest_ip:guest_port`. Guest IP is always the +/// IPAM-assigned `guest_ip`. +pub(super) fn parse_port_forwards( + port_map: &[String], + guest_ip: Ipv4Addr, +) -> std::result::Result, String> { + let mut forwards = Vec::new(); + for entry in port_map { + let mapping = a3s_box_core::parse_port_mapping(entry)?; + let host_port = mapping.host_port; + let guest_port = mapping.guest_port; + + let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, host_port)) + .map_err(|e| format!("cannot bind 0.0.0.0:{host_port}: {e}"))?; + listener + .set_nonblocking(true) + .map_err(|e| format!("set_nonblocking on listener: {e}"))?; + + tracing::info!( + host_port, + guest_port, + guest_ip = %guest_ip, + "Port-forward listener ready" + ); + forwards.push(PortForward { + listener, + guest_ip, + guest_port, + pending: Vec::new(), + active: Vec::new(), + }); + } + Ok(forwards) +} diff --git a/src/netproxy/src/tests.rs b/src/netproxy/src/tests.rs new file mode 100644 index 00000000..67dd3d49 --- /dev/null +++ b/src/netproxy/src/tests.rs @@ -0,0 +1,687 @@ +use super::device::{is_tx_backpressure, NetStatsSnapshot, MAX_FRAME}; +use super::manager::{parse_port_forwards, write_stats_file}; +use super::*; + +use smoltcp::wire::EthernetAddress; + +const TEST_GUEST_IP: Ipv4Addr = Ipv4Addr::new(10, 88, 0, 2); +const TEST_GATEWAY_IP: Ipv4Addr = Ipv4Addr::new(10, 88, 0, 1); +const TEST_GUEST_MAC: EthernetAddress = EthernetAddress([0x02, 0x42, 10, 88, 0, 2]); + +struct TestGuest { + device: UnixgramDevice, + iface: Interface, + sockets: SocketSet<'static>, +} + +fn test_guest_and_proxy(dns_servers: Vec) -> (TestGuest, ProxyEngine) { + let (guest_socket, proxy_socket) = UnixDatagram::pair().unwrap(); + guest_socket.set_nonblocking(true).unwrap(); + proxy_socket.set_nonblocking(true).unwrap(); + + let stats = Arc::new(NetStats::default()); + let mut guest_device = UnixgramDevice::new(guest_socket, None, Arc::clone(&stats)); + let mut guest_iface = Interface::new( + Config::new(TEST_GUEST_MAC.into()), + &mut guest_device, + smoltcp_now(), + ); + guest_iface.update_ip_addrs(|addrs| { + addrs + .push(IpCidr::new( + IpAddress::Ipv4(to_smoltcp_ipv4(TEST_GUEST_IP)), + 24, + )) + .unwrap(); + }); + guest_iface + .routes_mut() + .add_default_ipv4_route(to_smoltcp_ipv4(TEST_GATEWAY_IP)) + .unwrap(); + + let guest = TestGuest { + device: guest_device, + iface: guest_iface, + sockets: SocketSet::new(vec![]), + }; + let proxy = ProxyEngine::new(ProxyEngineConfig { + socket: proxy_socket, + guest_ip: TEST_GUEST_IP, + gateway_ip: TEST_GATEWAY_IP, + prefix_len: 24, + dns_servers, + port_forwards: Vec::new(), + shutdown: Arc::new(AtomicBool::new(false)), + stats, + stats_path: None, + bridge: None, + }); + (guest, proxy) +} + +fn poll_test_guest(guest: &mut TestGuest) { + guest.device.drain(); + guest + .iface + .poll(smoltcp_now(), &mut guest.device, &mut guest.sockets); +} + +fn poll_test_proxy_tcp(proxy: &mut ProxyEngine) { + let now = smoltcp_now(); + proxy.device.drain(); + proxy.accept_connections(); + proxy.accept_outbound_flows(); + proxy.poll_outbound_connectors(); + proxy.iface.poll(now, &mut proxy.device, &mut proxy.sockets); + proxy.finish_aborted_connections(); + proxy.promote_established(); + proxy.promote_outbound_established(); + proxy.proxy_data(); + proxy.cleanup(); +} + +fn port_is_bindable(port: u16) -> bool { + TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)).is_ok() +} + +fn ports_are_bindable(ports: &[u16]) -> bool { + ports.iter().copied().all(port_is_bindable) +} + +#[test] +fn test_smoltcp_now_returns_reasonable_value() { + let now = smoltcp_now(); + // Should return microseconds since epoch + assert!(now.micros() > 0); +} + +#[test] +fn test_to_smoltcp_ipv4_conversion() { + let ip = Ipv4Addr::new(10, 88, 0, 1); + let smol_ip = to_smoltcp_ipv4(ip); + assert_eq!(smol_ip.as_bytes(), &[10, 88, 0, 1]); +} + +#[test] +fn test_to_smoltcp_ipv4_loopback() { + let ip = Ipv4Addr::new(127, 0, 0, 1); + let smol_ip = to_smoltcp_ipv4(ip); + assert_eq!(smol_ip.as_bytes(), &[127, 0, 0, 1]); +} + +fn ethernet_frame(destination: [u8; 6], source: [u8; 6]) -> Vec { + let mut frame = vec![0u8; 64]; + frame[..6].copy_from_slice(&destination); + frame[6..12].copy_from_slice(&source); + frame[12..14].copy_from_slice(&[0x08, 0x00]); + frame[14..].fill(0x5a); + frame +} + +fn outbound_syn_frame( + destination_mac: [u8; 6], + source_ip: Ipv4Addr, + source_port: u16, + destination_ip: Ipv4Addr, + destination_port: u16, + ack: bool, +) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 20]; + frame[..6].copy_from_slice(&destination_mac); + frame[6..12].copy_from_slice(&[0x02, 0x42, 10, 88, 0, 2]); + frame[12..14].copy_from_slice(&[0x08, 0x00]); + + let ipv4 = &mut frame[14..34]; + ipv4[0] = 0x45; + ipv4[2..4].copy_from_slice(&40u16.to_be_bytes()); + ipv4[8] = 64; + ipv4[9] = 6; + ipv4[12..16].copy_from_slice(&source_ip.octets()); + ipv4[16..20].copy_from_slice(&destination_ip.octets()); + + let tcp = &mut frame[34..]; + tcp[..2].copy_from_slice(&source_port.to_be_bytes()); + tcp[2..4].copy_from_slice(&destination_port.to_be_bytes()); + tcp[12] = 0x50; + tcp[13] = if ack { 0x12 } else { 0x02 }; + tcp[14..16].copy_from_slice(&65535u16.to_be_bytes()); + frame +} + +#[test] +fn outbound_syn_flow_preserves_original_destination() { + let guest = Ipv4Addr::new(10, 88, 0, 2); + let gateway = Ipv4Addr::new(10, 88, 0, 1); + let remote = Ipv4Addr::new(93, 184, 216, 34); + let frame = outbound_syn_frame(GATEWAY_MAC.0, guest, 50123, remote, 443, false); + + assert_eq!( + outbound_syn_flow(&frame, guest, gateway), + Some(OutboundFlow { + guest_ip: guest, + guest_port: 50123, + remote_ip: remote, + remote_port: 443, + }) + ); +} + +#[test] +fn outbound_syn_flow_ignores_retransmitted_handshake_ack_and_peer_mac() { + let guest = Ipv4Addr::new(10, 88, 0, 2); + let gateway = Ipv4Addr::new(10, 88, 0, 1); + let remote = Ipv4Addr::new(93, 184, 216, 34); + let ack = outbound_syn_frame(GATEWAY_MAC.0, guest, 50123, remote, 443, true); + assert_eq!(outbound_syn_flow(&ack, guest, gateway), None); + + let peer_mac = [0x02, 0x42, 10, 88, 0, 3]; + let peer = outbound_syn_frame(peer_mac, guest, 50123, remote, 443, false); + assert_eq!(outbound_syn_flow(&peer, guest, gateway), None); +} + +#[test] +fn proxy_engine_enables_any_ip_for_transparent_outbound_tcp() { + let (guest_socket, proxy_socket) = UnixDatagram::pair().unwrap(); + guest_socket.set_nonblocking(true).unwrap(); + proxy_socket.set_nonblocking(true).unwrap(); + let engine = ProxyEngine::new(ProxyEngineConfig { + socket: proxy_socket, + guest_ip: Ipv4Addr::new(10, 88, 0, 2), + gateway_ip: Ipv4Addr::new(10, 88, 0, 1), + prefix_len: 24, + dns_servers: vec![Ipv4Addr::new(8, 8, 8, 8)], + port_forwards: Vec::new(), + shutdown: Arc::new(AtomicBool::new(false)), + stats: Arc::new(NetStats::default()), + stats_path: None, + bridge: None, + }); + + assert!(engine.iface.any_ip()); + let (dns_handle, dns_server) = engine.dns_sockets[0]; + let dns_socket = engine.sockets.get::(dns_handle); + assert_eq!(dns_server, Ipv4Addr::new(8, 8, 8, 8)); + assert_eq!( + dns_socket.endpoint().addr, + Some(IpAddress::Ipv4(to_smoltcp_ipv4(dns_server))) + ); +} + +#[test] +fn outbound_tcp_proxy_transfers_bytes_end_to_end() { + const GUEST_REQUEST: &[u8] = b"guest-request"; + const HOST_RESPONSE: &[u8] = b"host-response"; + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let host_port = listener.local_addr().unwrap().port(); + let (mut guest, mut proxy) = test_guest_and_proxy(Vec::new()); + + let rx = tcp::SocketBuffer::new(vec![0u8; 4096]); + let tx = tcp::SocketBuffer::new(vec![0u8; 4096]); + let mut guest_tcp = tcp::Socket::new(rx, tx); + guest_tcp + .connect( + guest.iface.context(), + ( + IpAddress::Ipv4(to_smoltcp_ipv4(Ipv4Addr::LOCALHOST)), + host_port, + ), + 50123, + ) + .unwrap(); + let guest_handle = guest.sockets.add(guest_tcp); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut host_stream = None; + let mut guest_sent = false; + let mut host_received = Vec::new(); + let mut host_response_offset = 0; + let mut guest_received = Vec::new(); + + while std::time::Instant::now() < deadline && guest_received != HOST_RESPONSE { + poll_test_guest(&mut guest); + poll_test_proxy_tcp(&mut proxy); + + if host_stream.is_none() { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(true).unwrap(); + host_stream = Some(stream); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => panic!("host listener accept failed: {error}"), + } + } + + { + let socket = guest.sockets.get_mut::(guest_handle); + if !guest_sent && socket.can_send() { + assert_eq!( + socket.send_slice(GUEST_REQUEST).unwrap(), + GUEST_REQUEST.len() + ); + guest_sent = true; + } + if socket.can_recv() { + socket + .recv(|data| { + guest_received.extend_from_slice(data); + (data.len(), ()) + }) + .unwrap(); + } + } + + if let Some(stream) = host_stream.as_mut() { + let mut buffer = [0u8; 128]; + match stream.read(&mut buffer) { + Ok(0) => {} + Ok(size) => host_received.extend_from_slice(&buffer[..size]), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) => {} + Err(error) => panic!("host stream read failed: {error}"), + } + + if host_received == GUEST_REQUEST && host_response_offset < HOST_RESPONSE.len() { + match stream.write(&HOST_RESPONSE[host_response_offset..]) { + Ok(0) => panic!("host stream returned write zero"), + Ok(size) => host_response_offset += size, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) => {} + Err(error) => panic!("host stream write failed: {error}"), + } + } + } + + std::thread::sleep(Duration::from_millis(1)); + } + + assert!(guest_sent, "guest TCP connection never became writable"); + assert_eq!(host_received, GUEST_REQUEST); + assert_eq!(guest_received, HOST_RESPONSE); + assert_eq!(proxy.pending_outbound.len(), 0); + assert_eq!(proxy.active_outbound.len(), 1); +} + +#[test] +fn dns_response_preserves_queried_server_endpoint_end_to_end() { + const QUERY: &[u8] = b"dns-query"; + const RESPONSE: &[u8] = b"dns-response"; + + let dns_server = Ipv4Addr::new(8, 8, 8, 8); + let (mut guest, mut proxy) = test_guest_and_proxy(vec![dns_server]); + let rx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 4], vec![0u8; 1024]); + let tx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 4], vec![0u8; 1024]); + let mut guest_udp = udp::Socket::new(rx, tx); + guest_udp.bind(53000).unwrap(); + guest_udp + .send_slice( + QUERY, + IpEndpoint::new(IpAddress::Ipv4(to_smoltcp_ipv4(dns_server)), 53), + ) + .unwrap(); + let guest_handle = guest.sockets.add(guest_udp); + let (proxy_handle, _) = proxy.dns_sockets[0]; + + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let mut injected_response = false; + let mut received = None; + + while std::time::Instant::now() < deadline && received.is_none() { + poll_test_guest(&mut guest); + proxy.device.drain(); + proxy + .iface + .poll(smoltcp_now(), &mut proxy.device, &mut proxy.sockets); + + if !injected_response { + let socket = proxy.sockets.get_mut::(proxy_handle); + if socket.can_recv() { + let (query, source) = socket.recv().unwrap(); + assert_eq!(query, QUERY); + assert_eq!( + source.endpoint, + IpEndpoint::new(IpAddress::Ipv4(to_smoltcp_ipv4(TEST_GUEST_IP)), 53000,) + ); + socket.send_slice(RESPONSE, source).unwrap(); + injected_response = true; + } + } + + proxy + .iface + .poll(smoltcp_now(), &mut proxy.device, &mut proxy.sockets); + poll_test_guest(&mut guest); + + let socket = guest.sockets.get_mut::(guest_handle); + if socket.can_recv() { + let (payload, source) = socket.recv().unwrap(); + received = Some((payload.to_vec(), source.endpoint)); + } + std::thread::sleep(Duration::from_millis(1)); + } + + assert!( + injected_response, + "proxy never received the guest DNS query" + ); + assert_eq!( + received, + Some(( + RESPONSE.to_vec(), + IpEndpoint::new(IpAddress::Ipv4(to_smoltcp_ipv4(dns_server)), 53), + )) + ); +} + +#[test] +fn bridge_port_unicasts_frame_to_matching_peer_mac() { + let dir = tempfile::tempdir().unwrap(); + let mac_a = [0x02, 0x42, 10, 88, 0, 2]; + let mac_b = [0x02, 0x42, 10, 88, 0, 3]; + let bridge_a = BridgePort::bind(dir.path(), mac_a).unwrap(); + let bridge_b = BridgePort::bind(dir.path(), mac_b).unwrap(); + let frame = ethernet_frame(mac_b, mac_a); + + assert!(!bridge_a.forward_from_guest(&frame)); + let mut received = Vec::new(); + bridge_b.drain_frames(&mut received, 1); + + assert_eq!(received, vec![frame]); +} + +#[test] +fn bridge_port_floods_broadcast_and_keeps_gateway_delivery() { + let dir = tempfile::tempdir().unwrap(); + let mac_a = [0x02, 0x42, 10, 88, 0, 2]; + let mac_b = [0x02, 0x42, 10, 88, 0, 3]; + let bridge_a = BridgePort::bind(dir.path(), mac_a).unwrap(); + let bridge_b = BridgePort::bind(dir.path(), mac_b).unwrap(); + let frame = ethernet_frame([0xff; 6], mac_a); + + assert!(bridge_a.forward_from_guest(&frame)); + let mut received = Vec::new(); + bridge_b.drain_frames(&mut received, 1); + + assert_eq!(received, vec![frame]); +} + +#[test] +fn unixgram_device_retries_frame_after_socket_backpressure() { + use smoltcp::phy::{Device as _, TxToken as _}; + + let (sender, receiver) = UnixDatagram::pair().unwrap(); + sender.set_nonblocking(true).unwrap(); + receiver.set_nonblocking(true).unwrap(); + let stats = Arc::new(NetStats::default()); + let mut device = UnixgramDevice::new(sender, None, Arc::clone(&stats)); + let filler = vec![0x5a; MAX_FRAME]; + let mut filled = 0; + + loop { + match device.socket.send(&filler) { + Ok(_) => filled += 1, + Err(error) if is_tx_backpressure(&error) => break, + Err(error) => panic!("failed to fill Unix datagram buffer: {error}"), + } + } + assert!(filled > 0); + + let marker = vec![0xa5; MAX_FRAME]; + let token = device.transmit(smoltcp_now()).unwrap(); + token.consume(marker.len(), |buffer| buffer.copy_from_slice(&marker)); + assert_eq!(device.pending_tx_len(), 1); + assert!(device.transmit(smoltcp_now()).is_none()); + + let mut buffer = vec![0u8; MAX_FRAME]; + while receiver.recv(&mut buffer).is_ok() {} + + device.drain(); + assert_eq!(device.pending_tx_len(), 0); + let size = receiver.recv(&mut buffer).unwrap(); + assert_eq!(&buffer[..size], marker.as_slice()); + + let snapshot = stats.snapshot(); + assert_eq!(snapshot.rx_packets, 1); + assert_eq!(snapshot.rx_bytes, marker.len() as u64); +} + +#[test] +fn test_net_stats_records_bytes_and_packets() { + let stats = NetStats::default(); + + stats.record_rx(64); + stats.record_rx(128); + stats.record_tx(512); + + let snapshot = stats.snapshot(); + assert_eq!(snapshot.rx_bytes, 192); + assert_eq!(snapshot.rx_packets, 2); + assert_eq!(snapshot.tx_bytes, 512); + assert_eq!(snapshot.tx_packets, 1); +} + +#[test] +fn test_parse_port_forwards_empty_rules() { + let guest = Ipv4Addr::new(10, 89, 0, 2); + let fwds = parse_port_forwards(&[], guest).unwrap(); + assert!(fwds.is_empty()); +} + +#[test] +fn test_parse_port_forwards_rejects_udp_suffix() { + let guest = Ipv4Addr::new(10, 89, 0, 2); + let rules = vec!["19990:80/udp".to_string()]; + let error = match parse_port_forwards(&rules, guest) { + Ok(_) => panic!("UDP port mapping unexpectedly succeeded"), + Err(error) => error, + }; + + assert!(error.contains("only TCP is supported")); +} + +#[test] +fn test_parse_port_forwards_multiple_rules() { + let guest = Ipv4Addr::new(10, 89, 0, 2); + if !ports_are_bindable(&[19991, 19992, 19993]) { + eprintln!("skipping test: one or more host ports are not bindable"); + return; + } + let rules = vec![ + "19991:80".to_string(), + "19992:443".to_string(), + "19993:8080".to_string(), + ]; + let fwds = parse_port_forwards(&rules, guest).unwrap(); + assert_eq!(fwds.len(), 3); + assert_eq!(fwds[0].guest_port, 80); + assert_eq!(fwds[1].guest_port, 443); + assert_eq!(fwds[2].guest_port, 8080); +} + +#[test] +fn test_parse_port_forwards_empty_string() { + let guest = Ipv4Addr::new(10, 89, 0, 2); + // Empty entry should fail parsing + let rules = vec!["".to_string()]; + let result = parse_port_forwards(&rules, guest); + assert!(result.is_err()); +} + +#[test] +fn test_netproxy_manager_new() { + let dir = tempfile::tempdir().unwrap(); + let mgr = NetProxyManager::new(dir.path()); + assert_eq!( + mgr.socket_path(), + dir.path().join("sockets").join("net.sock") + ); + assert_eq!( + mgr.stats_path(), + dir.path().join("sockets").join("net.stats.json") + ); + assert_eq!(mgr.net_socket_fd(), None); +} + +#[test] +fn test_netproxy_manager_not_running_initially() { + let dir = tempfile::tempdir().unwrap(); + let mut mgr = NetProxyManager::new(dir.path()); + assert!(!mgr.is_running()); +} + +#[test] +fn test_netproxy_manager_stop_when_not_started() { + let dir = tempfile::tempdir().unwrap(); + let mut mgr = NetProxyManager::new(dir.path()); + mgr.stop(); // must not panic + assert!(!mgr.is_running()); +} + +#[test] +fn test_netproxy_manager_spawn_creates_socketpair_fds_and_stop_closes_them() { + let dir = tempfile::tempdir().unwrap(); + let mut mgr = NetProxyManager::new(dir.path()); + + mgr.spawn( + Ipv4Addr::new(10, 89, 0, 2), + Ipv4Addr::new(10, 89, 0, 1), + 24, + &[Ipv4Addr::new(8, 8, 8, 8)], + &[], + ) + .unwrap(); + + assert!(mgr.is_running()); + assert!(mgr.net_socket_fd().is_some()); + assert!(mgr.net_proxy_fd().is_some()); + + mgr.stop(); + assert!(!mgr.is_running()); + assert!(mgr.net_socket_fd().is_none()); + assert!(mgr.net_proxy_fd().is_none()); +} + +#[test] +fn test_netproxy_manager_drop_cleans_up() { + let dir = tempfile::tempdir().unwrap(); + let socket_path = dir.path().join("sockets").join("net.sock"); + std::fs::create_dir_all(dir.path().join("sockets")).unwrap(); + std::fs::write(&socket_path, "fake").unwrap(); + { + let _mgr = NetProxyManager::new(dir.path()); + // Drop triggers cleanup + } + assert!(!socket_path.exists()); +} + +#[test] +fn test_write_stats_file_writes_json_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("sockets").join("net.stats.json"); + + write_stats_file( + &path, + NetStatsSnapshot { + rx_bytes: 1024, + tx_bytes: 2048, + rx_packets: 3, + tx_packets: 4, + }, + ) + .unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(json["schema"], "a3s-box.netproxy.stats.v1"); + assert_eq!(json["rx_bytes"], 1024); + assert_eq!(json["tx_bytes"], 2048); + assert_eq!(json["rx_packets"], 3); + assert_eq!(json["tx_packets"], 4); +} + +#[test] +fn test_write_stats_file_overwrites_existing_file_and_removes_temp() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("net.stats.json"); + let tmp = path.with_extension("json.tmp"); + std::fs::write(&path, "old").unwrap(); + std::fs::write(&tmp, "stale temp").unwrap(); + + write_stats_file( + &path, + NetStatsSnapshot { + rx_bytes: 1, + tx_bytes: 2, + rx_packets: 3, + tx_packets: 4, + }, + ) + .unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(json["rx_bytes"], 1); + assert_eq!(json["tx_bytes"], 2); + assert!(!tmp.exists()); +} + +#[test] +fn test_parse_port_forwards_valid() { + let guest = Ipv4Addr::new(10, 89, 0, 2); + if !ports_are_bindable(&[19988, 19443]) { + eprintln!("skipping test: one or more host ports are not bindable"); + return; + } + // Use a random high port to avoid conflicts + let rules = vec!["19988:80".to_string(), "19443:443".to_string()]; + let fwds = parse_port_forwards(&rules, guest).unwrap(); + assert_eq!(fwds.len(), 2); + assert_eq!(fwds[0].guest_port, 80); + assert_eq!(fwds[1].guest_port, 443); +} + +#[test] +fn test_parse_port_forwards_with_protocol_suffix() { + let guest = Ipv4Addr::new(10, 89, 0, 2); + if !port_is_bindable(19989) { + eprintln!("skipping test: host port 19989 is not bindable"); + return; + } + let rules = vec!["19989:80/tcp".to_string()]; + let fwds = parse_port_forwards(&rules, guest).unwrap(); + assert_eq!(fwds[0].guest_port, 80); +} + +#[test] +fn test_parse_port_forwards_invalid_format() { + let guest = Ipv4Addr::new(10, 89, 0, 2); + assert!(parse_port_forwards(&["notaport".to_string()], guest).is_err()); + assert!(parse_port_forwards(&["abc:80".to_string()], guest).is_err()); + assert!(parse_port_forwards(&["80:xyz".to_string()], guest).is_err()); +} + +#[test] +fn test_parse_port_forwards_reports_bind_conflict() { + let guest = Ipv4Addr::new(10, 89, 0, 2); + let held = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)).unwrap(); + let port = held.local_addr().unwrap().port(); + + let error = match parse_port_forwards(&[format!("{port}:80")], guest) { + Ok(_) => panic!("port-forward bind conflict should return an error"), + Err(error) => error, + }; + + assert!(error.contains(&format!("cannot bind 0.0.0.0:{port}"))); +} + +// Note: test_netproxy_manager_spawn_binds_and_releases_host_ports was removed +// because spawn() no longer spawns a thread or binds ports. Port binding +// now happens in spawn_inherited_netproxy() called from the shim. diff --git a/src/runtime/Cargo.toml b/src/runtime/Cargo.toml index 20b7ae65..8b7d1fd9 100644 --- a/src/runtime/Cargo.toml +++ b/src/runtime/Cargo.toml @@ -11,6 +11,11 @@ description = "MicroVM runtime engine — VM lifecycle, OCI images, attestation, name = "a3s_box_runtime" path = "src/lib.rs" +[[example]] +name = "managed-sandbox-smoke" +path = "examples/managed_sandbox_smoke.rs" +required-features = ["vm"] + [features] default = ["vm", "pool", "scale", "compose", "operator", "build"] vm = [] @@ -21,7 +26,7 @@ operator = [] build = [] [dependencies] -a3s-box-core = { version = "2.2.0", path = "../core" } +a3s-box-core = { version = "3.0", path = "../core" } a3s-transport = { workspace = true } # Async runtime @@ -51,6 +56,7 @@ base64 = { workspace = true } # HTTP client reqwest = { workspace = true } +oci-reqwest = { package = "reqwest", version = "0.12", default-features = false, features = ["json", "stream"] } # Time chrono = { workspace = true } @@ -82,9 +88,19 @@ prometheus = { workspace = true } # macOS userspace network proxy (replaces gvproxy) [target.'cfg(target_os = "macos")'.dependencies] -a3s-box-netproxy = { version = "2.2.0", path = "../netproxy" } +a3s-box-netproxy = { version = "3.0", path = "../netproxy" } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.48", features = [ + "Win32_Foundation", + "Win32_System_Threading", +] } [target.'cfg(unix)'.dependencies] +# Filesystem Snapshot fidelity +filetime = "0.2" +xattr = "1.6" + # TEE attestation verification p384 = { workspace = true } ecdsa = { workspace = true } @@ -104,5 +120,6 @@ bzip2 = "0.5" xz2 = "0.1" [dev-dependencies] +axum = { workspace = true } rand = { workspace = true } tempfile = { workspace = true } diff --git a/src/runtime/examples/managed_sandbox_smoke.rs b/src/runtime/examples/managed_sandbox_smoke.rs new file mode 100644 index 00000000..40f454a6 --- /dev/null +++ b/src/runtime/examples/managed_sandbox_smoke.rs @@ -0,0 +1,723 @@ +//! Destructive lifecycle smoke test for a dedicated A3S OS Sandbox test home. +//! +//! The caller must set `A3S_BOX_MANAGED_SMOKE=1`, point `A3S_HOME` at a +//! dedicated directory whose name contains `managed-smoke`, and set +//! `A3S_BOX_CRUN_PATH` to that directory's certified `bin/crun` artifact. + +#[cfg(not(target_os = "linux"))] +fn main() { + eprintln!("managed-sandbox-smoke requires Linux"); + std::process::exit(2); +} + +#[cfg(target_os = "linux")] +#[tokio::main] +async fn main() { + if let Err(error) = linux::run().await { + eprintln!("managed Sandbox smoke test failed: {error}"); + std::process::exit(1); + } +} + +#[cfg(target_os = "linux")] +mod linux { + use std::collections::BTreeMap; + use std::error::Error; + use std::io; + use std::path::{Path, PathBuf}; + + use a3s_box_core::{ + BoxConfig, CreateExecutionRequest, ExecutionBackend, ExecutionGeneration, ExecutionId, + ExecutionIsolation, ExecutionManager, ExecutionManagerError, ExecutionSnapshotId, + ExecutionState, IsolationClass, KillOutcome, NetworkMode, OperationId, ReconcileOutcome, + ResourceConfig, + }; + use a3s_box_runtime::{LocalExecutionManager, ManagedExecutionStore}; + + type AnyError = Box; + + pub(super) async fn run() -> Result<(), AnyError> { + // Production services commonly use UMask=0077. Keep the real crun + // smoke from accidentally relying on world-searchable runtime paths. + let _umask = RestrictiveUmask::install(); + let home_dir = validated_home()?; + let state_path = home_dir.join("managed-executions.json"); + let source_operation_id = + OperationId::new(format!("managed-sandbox-smoke-{}", uuid::Uuid::new_v4()))?; + let restored_operation_id = + OperationId::new(format!("managed-sandbox-restore-{}", uuid::Uuid::new_v4()))?; + let rejected_restore_operation_id = OperationId::new(format!( + "managed-sandbox-rejected-restore-{}", + uuid::Uuid::new_v4() + ))?; + let snapshot_id = + ExecutionSnapshotId::new(format!("managed-smoke-{}", uuid::Uuid::new_v4().simple()))?; + let rejected_snapshot_id = ExecutionSnapshotId::new(format!( + "managed-smoke-rejected-{}", + uuid::Uuid::new_v4().simple() + ))?; + + let result = exercise( + &home_dir, + &state_path, + &source_operation_id, + &restored_operation_id, + &rejected_restore_operation_id, + &snapshot_id, + &rejected_snapshot_id, + ) + .await; + for operation_id in [ + &source_operation_id, + &restored_operation_id, + &rejected_restore_operation_id, + ] { + if let Err(cleanup_error) = cleanup(&home_dir, &state_path, operation_id).await { + if result.is_ok() { + return Err(cleanup_error); + } + eprintln!("managed Sandbox cleanup also failed: {cleanup_error}"); + } + } + let cleanup_manager = LocalExecutionManager::with_vm_backend(&state_path, &home_dir); + for cleanup_snapshot_id in [&snapshot_id, &rejected_snapshot_id] { + if let Err(cleanup_error) = cleanup_manager + .delete_filesystem_snapshot(cleanup_snapshot_id) + .await + { + if result.is_ok() { + return Err(cleanup_error.into()); + } + eprintln!("managed snapshot cleanup also failed: {cleanup_error}"); + } + } + result + } + + fn validated_home() -> Result { + require( + std::env::var("A3S_BOX_MANAGED_SMOKE").as_deref() == Ok("1"), + "set A3S_BOX_MANAGED_SMOKE=1 to acknowledge the destructive smoke test", + )?; + let home_dir = std::env::var_os("A3S_HOME") + .map(PathBuf::from) + .ok_or_else(|| failure("A3S_HOME must point to a dedicated smoke-test directory"))?; + require(home_dir.is_absolute(), "A3S_HOME must be absolute")?; + require( + home_dir + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains("managed-smoke")), + "A3S_HOME must name a dedicated managed-smoke directory", + )?; + + let expected_crun = home_dir.join("bin/crun").canonicalize()?; + let configured_crun = std::env::var_os("A3S_BOX_CRUN_PATH") + .map(PathBuf::from) + .ok_or_else(|| failure("A3S_BOX_CRUN_PATH must select the isolated crun artifact"))? + .canonicalize()?; + require( + configured_crun == expected_crun, + "A3S_BOX_CRUN_PATH must equal A3S_HOME/bin/crun", + )?; + require( + home_dir.join("bin/a3s-box-guest-init").is_file(), + "A3S_HOME/bin/a3s-box-guest-init is missing", + )?; + require( + home_dir.join("bin/a3s-box-shim").is_file(), + "A3S_HOME/bin/a3s-box-shim is missing", + )?; + Ok(home_dir) + } + + async fn exercise( + home_dir: &Path, + state_path: &Path, + source_operation_id: &OperationId, + restored_operation_id: &OperationId, + rejected_restore_operation_id: &OperationId, + snapshot_id: &ExecutionSnapshotId, + rejected_snapshot_id: &ExecutionSnapshotId, + ) -> Result<(), AnyError> { + let image = + std::env::var("A3S_BOX_SMOKE_IMAGE").unwrap_or_else(|_| "alpine:3.20".to_string()); + let config = BoxConfig { + isolation: ExecutionIsolation::Sandbox, + image, + cmd: vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "mkdir -p /state; if [ ! -f /state/value ]; then printf 'captured' > /state/value; fi; printf 'sandbox-state=%s\\n' \"$(cat /state/value)\"; printf 'sandbox-stderr\\n' >&2; while :; do sleep 60; done" + .to_string(), + ], + network: NetworkMode::None, + ..Default::default() + }; + let request = CreateExecutionRequest { + external_sandbox_id: "managed-sandbox-smoke-external-id".to_string(), + config: config.clone(), + labels: BTreeMap::from([("purpose".to_string(), "managed-sandbox-smoke".to_string())]), + policy: Default::default(), + rootfs_snapshot_id: None, + }; + + let manager = LocalExecutionManager::with_vm_backend(state_path, home_dir); + let reservation = manager.create(request, source_operation_id).await?; + require( + reservation.plan.backend == ExecutionBackend::Crun + && reservation.plan.isolation_class == IsolationClass::SharedKernel, + "Sandbox request did not resolve exclusively to the crun shared-kernel backend", + )?; + let execution_id = reservation.execution_id.clone(); + let status = manager.inspect(&execution_id).await?; + require( + status.state == ExecutionState::Created, + "new managed Sandbox reservation is not created", + )?; + require( + status.generation == reservation.generation && status.plan == reservation.plan, + "created Sandbox inspection disagrees with its reservation", + )?; + + let box_dir = home_dir.join("boxes").join(execution_id.as_str()); + validate_runtime_absent(home_dir, &execution_id)?; + println!( + "created execution={} backend=crun state=created", + execution_id + ); + + drop(manager); + let restarted = LocalExecutionManager::with_vm_backend(state_path, home_dir); + let recovered_reservation = match restarted.reconcile(source_operation_id).await? { + ReconcileOutcome::Created(reservation) => reservation, + _ => { + return Err(failure( + "restarted manager did not recover a created reservation", + )) + } + }; + require( + recovered_reservation.execution_id == reservation.execution_id + && recovered_reservation.generation == reservation.generation + && recovered_reservation.plan == reservation.plan + && same_resources(&recovered_reservation.resources, &reservation.resources), + "recovered Sandbox reservation differs from the durable reservation", + )?; + let recovered_status = restarted.inspect(&execution_id).await?; + require( + recovered_status.state == ExecutionState::Created, + "restarted manager did not preserve the created Sandbox state", + )?; + validate_runtime_absent(home_dir, &execution_id)?; + println!("recovered execution={} state=created", execution_id); + + let lease = restarted + .start(&execution_id, recovered_reservation.generation) + .await?; + require( + lease.execution_id == reservation.execution_id + && lease.generation == reservation.generation + && lease.plan == reservation.plan + && same_resources(&lease.resources, &reservation.resources), + "started Sandbox lease differs from its reservation", + )?; + let running_status = restarted.inspect(&execution_id).await?; + require( + running_status.state == ExecutionState::Running, + "started managed Sandbox is not running", + )?; + let log_worker_identity = validate_runtime_record(home_dir, &box_dir, &execution_id)?; + validate_structured_logs(&box_dir).await?; + println!("started execution={} state=running", execution_id); + + let paused = restarted + .pause(&execution_id, running_status.generation, true) + .await?; + require( + restarted.inspect(&execution_id).await?.state == ExecutionState::Paused, + "managed Sandbox did not enter the paused state", + )?; + let resumed = restarted.resume(&execution_id, paused.generation).await?; + require( + resumed.generation.get() == paused.generation.get() + 1 + && restarted.inspect(&execution_id).await?.state == ExecutionState::Running, + "managed Sandbox did not resume at the next generation", + )?; + println!( + "pause-resume execution={} generation={}", + execution_id, + resumed.generation.get() + ); + + validate_special_file_rejection( + &restarted, + home_dir, + &box_dir, + &execution_id, + resumed.generation, + rejected_snapshot_id, + ) + .await?; + + let snapshot_started = std::time::Instant::now(); + let snapshot = restarted + .create_filesystem_snapshot(&execution_id, resumed.generation, snapshot_id) + .await?; + let snapshot_elapsed = snapshot_started.elapsed(); + require( + snapshot.state == ExecutionState::Running + && snapshot.lease.generation == resumed.generation + && snapshot.size_bytes > 0, + "managed Sandbox snapshot returned inconsistent evidence", + )?; + require( + snapshot_elapsed <= std::time::Duration::from_secs(30), + "managed Sandbox snapshot exceeded the 30-second smoke gate", + )?; + println!( + "snapshot execution={} id={} bytes={} elapsed_ms={}", + execution_id, + snapshot_id, + snapshot.size_bytes, + snapshot_elapsed.as_millis() + ); + + validate_failed_restore_cleanup( + &restarted, + home_dir, + state_path, + &config, + snapshot_id, + rejected_restore_operation_id, + ) + .await?; + + let outcome = restarted.kill(&execution_id, resumed.generation).await?; + require( + outcome == KillOutcome::Killed, + "managed Sandbox kill did not own runtime cleanup", + )?; + let stopped = restarted.inspect(&execution_id).await?; + require( + stopped.state == ExecutionState::Stopped, + "managed Sandbox did not persist a stopped state", + )?; + require(!box_dir.exists(), "managed Sandbox box directory leaked")?; + require( + !home_dir + .join("run/crun") + .join(execution_id.as_str()) + .exists(), + "managed Sandbox crun state directory leaked", + )?; + require( + !Path::new("/tmp/a3s-box-sockets") + .join(execution_id.as_str()) + .exists(), + "managed Sandbox socket directory leaked", + )?; + wait_for_process_exit(log_worker_identity).await?; + println!("killed execution={} state=stopped cleanup=ok", execution_id); + + let restored_request = CreateExecutionRequest { + external_sandbox_id: "managed-sandbox-restored-external-id".to_string(), + config, + labels: BTreeMap::from([( + "purpose".to_string(), + "managed-sandbox-restore-smoke".to_string(), + )]), + policy: Default::default(), + rootfs_snapshot_id: Some(snapshot_id.clone()), + }; + let restore_started = std::time::Instant::now(); + let restored_lease = restarted + .create_and_start(restored_request, restored_operation_id) + .await?; + let restore_elapsed = restore_started.elapsed(); + require( + restored_lease.plan.backend == ExecutionBackend::Crun + && restored_lease.plan.isolation_class == IsolationClass::SharedKernel, + "restored Sandbox did not stay on the crun backend", + )?; + require( + restore_elapsed <= std::time::Duration::from_secs(30), + "managed Sandbox restore exceeded the 30-second smoke gate", + )?; + let restored_id = restored_lease.execution_id.clone(); + let restored_box_dir = home_dir.join("boxes").join(restored_id.as_str()); + let restored_log_worker = + validate_runtime_record(home_dir, &restored_box_dir, &restored_id)?; + validate_snapshot_marker(home_dir, &restored_box_dir, snapshot_id)?; + validate_structured_logs(&restored_box_dir).await?; + require( + matches!( + restarted.delete_filesystem_snapshot(snapshot_id).await, + Err(ExecutionManagerError::Conflict { .. }) + ), + "active restored Sandbox did not protect its snapshot lower", + )?; + println!( + "restored execution={} snapshot={} elapsed_ms={}", + restored_id, + snapshot_id, + restore_elapsed.as_millis() + ); + + require( + restarted + .kill(&restored_id, restored_lease.generation) + .await? + == KillOutcome::Killed, + "restored Sandbox kill did not own runtime cleanup", + )?; + require( + restarted.delete_filesystem_snapshot(snapshot_id).await?, + "managed snapshot was not deleted after restored Sandbox cleanup", + )?; + require( + !restored_box_dir.exists(), + "restored managed Sandbox box directory leaked", + )?; + wait_for_process_exit(restored_log_worker).await?; + println!( + "killed restored_execution={} snapshot_delete=ok cleanup=ok", + restored_id + ); + Ok(()) + } + + async fn validate_failed_restore_cleanup( + manager: &LocalExecutionManager, + home_dir: &Path, + state_path: &Path, + config: &BoxConfig, + snapshot_id: &ExecutionSnapshotId, + operation_id: &OperationId, + ) -> Result<(), AnyError> { + let snapshot_file = home_dir + .join("snapshots") + .join(snapshot_id.as_str()) + .join("rootfs/state/value"); + let original = std::fs::read(&snapshot_file)?; + let mut corrupted = original.clone(); + corrupted.extend_from_slice(b"-corrupted"); + std::fs::write(&snapshot_file, corrupted)?; + + let request = CreateExecutionRequest { + external_sandbox_id: "managed-sandbox-rejected-restore-external-id".to_string(), + config: config.clone(), + labels: BTreeMap::from([( + "purpose".to_string(), + "managed-sandbox-rejected-restore-smoke".to_string(), + )]), + policy: Default::default(), + rootfs_snapshot_id: Some(snapshot_id.clone()), + }; + let restore = manager.create_and_start(request, operation_id).await; + std::fs::write(&snapshot_file, original)?; + + let error = restore + .err() + .ok_or_else(|| failure("corrupted filesystem Snapshot unexpectedly started"))?; + require( + error.to_string().contains("rootfs metadata mismatch"), + "corrupted filesystem Snapshot failed for an unexpected reason", + )?; + let record = ManagedExecutionStore::new(state_path) + .get_by_operation_id(operation_id)? + .ok_or_else(|| failure("failed restore did not persist its managed record"))?; + let execution_id = ExecutionId::new(record.id)?; + require( + manager.inspect(&execution_id).await?.state == ExecutionState::Failed, + "failed restore did not enter the failed state", + )?; + validate_failed_runtime_absent(home_dir, &execution_id)?; + println!( + "restore-rejection execution={} reason=rootfs-metadata cleanup=ok", + execution_id + ); + Ok(()) + } + + async fn validate_special_file_rejection( + manager: &LocalExecutionManager, + home_dir: &Path, + box_dir: &Path, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + snapshot_id: &ExecutionSnapshotId, + ) -> Result<(), AnyError> { + use std::os::unix::ffi::OsStrExt; + + let fifo = box_dir.join("merged/state/snapshot-blocker"); + let fifo_path = std::ffi::CString::new(fifo.as_os_str().as_bytes())?; + if unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) } != 0 { + return Err(std::io::Error::last_os_error().into()); + } + let started = std::time::Instant::now(); + let capture = manager + .create_filesystem_snapshot(execution_id, generation, snapshot_id) + .await; + let elapsed = started.elapsed(); + std::fs::remove_file(&fifo)?; + let error = capture + .err() + .ok_or_else(|| failure("managed Snapshot accepted a FIFO"))?; + require( + matches!( + &error, + ExecutionManagerError::Unavailable(message) + if message.contains("unsupported special file") && message.contains("fifo") + ), + "managed Snapshot did not fail closed on a FIFO", + )?; + require( + elapsed <= std::time::Duration::from_secs(5), + "managed Snapshot special-file rejection was not bounded", + )?; + require( + manager.inspect(execution_id).await?.state == ExecutionState::Running, + "managed Snapshot failure did not restore the running source", + )?; + require( + manager + .filesystem_snapshot_size(snapshot_id) + .await? + .is_none(), + "rejected managed Snapshot was published", + )?; + require( + std::fs::read_dir(home_dir.join("snapshots"))?.all(|entry| { + entry.is_ok_and(|entry| { + !entry.file_name().to_string_lossy().starts_with(".staging-") + }) + }), + "rejected managed Snapshot leaked a staging tree", + )?; + let _ = validate_runtime_record(home_dir, box_dir, execution_id)?; + println!( + "snapshot-rejection execution={} kind=fifo source=running elapsed_ms={}", + execution_id, + elapsed.as_millis() + ); + Ok(()) + } + + fn validate_runtime_absent( + home_dir: &Path, + execution_id: &ExecutionId, + ) -> Result<(), AnyError> { + require( + !home_dir.join("boxes").join(execution_id.as_str()).exists(), + "created Sandbox unexpectedly allocated a box directory", + )?; + require( + !home_dir + .join("run/crun") + .join(execution_id.as_str()) + .exists(), + "created Sandbox unexpectedly allocated a crun state directory", + )?; + require( + !Path::new("/tmp/a3s-box-sockets") + .join(execution_id.as_str()) + .exists(), + "created Sandbox unexpectedly allocated a socket directory", + ) + } + + fn validate_failed_runtime_absent( + home_dir: &Path, + execution_id: &ExecutionId, + ) -> Result<(), AnyError> { + require( + !home_dir.join("boxes").join(execution_id.as_str()).exists(), + "failed Sandbox restore leaked its box directory or overlay mount", + )?; + require( + !home_dir + .join("run/crun") + .join(execution_id.as_str()) + .exists(), + "failed Sandbox restore leaked its crun state directory", + )?; + require( + !Path::new("/tmp/a3s-box-sockets") + .join(execution_id.as_str()) + .exists(), + "failed Sandbox restore leaked its socket directory", + ) + } + + fn same_resources(left: &ResourceConfig, right: &ResourceConfig) -> bool { + left.vcpus == right.vcpus + && left.memory_mb == right.memory_mb + && left.disk_mb == right.disk_mb + && left.timeout == right.timeout + } + + fn validate_snapshot_marker( + home_dir: &Path, + box_dir: &Path, + snapshot_id: &ExecutionSnapshotId, + ) -> Result<(), AnyError> { + let expected = home_dir + .join("snapshots") + .join(snapshot_id.as_str()) + .join("rootfs") + .canonicalize()?; + let marker = box_dir.join(".snapshot-lower"); + let actual = PathBuf::from(std::fs::read_to_string(&marker)?.trim()); + require( + actual == expected, + "restored Sandbox has the wrong CoW lower", + )?; + require( + std::fs::symlink_metadata(marker)?.file_type().is_file(), + "restored Sandbox snapshot marker is not a regular file", + ) + } + + fn validate_runtime_record( + home_dir: &Path, + box_dir: &Path, + execution_id: &ExecutionId, + ) -> Result<(u32, u64), AnyError> { + let runtime_record = box_dir.join("sandbox/runtime.json"); + let record: serde_json::Value = serde_json::from_slice(&std::fs::read(&runtime_record)?)?; + require( + record.get("schema").and_then(serde_json::Value::as_str) + == Some("a3s.box.sandbox-runtime.v1"), + "Sandbox runtime record has an unexpected schema", + )?; + require( + record + .get("container_id") + .and_then(serde_json::Value::as_str) + == Some(execution_id.as_str()), + "Sandbox runtime record does not use the internal execution ID", + )?; + let runtime_path = record + .get("runtime_path") + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) + .ok_or_else(|| failure("Sandbox runtime record has no runtime path"))?; + require( + runtime_path.canonicalize()? == home_dir.join("bin/crun").canonicalize()?, + "Sandbox runtime record does not reference the certified crun artifact", + )?; + let log_worker_pid = record + .get("log_worker_pid") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| failure("Sandbox runtime record has no log worker PID"))?; + let log_worker_pid_start_time = record + .get("log_worker_pid_start_time") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| failure("Sandbox runtime record has no log worker PID identity"))?; + require( + a3s_box_runtime::is_process_alive_with_identity( + log_worker_pid, + Some(log_worker_pid_start_time), + ), + "Sandbox log worker is not alive with its recorded identity", + )?; + Ok((log_worker_pid, log_worker_pid_start_time)) + } + + async fn validate_structured_logs(box_dir: &Path) -> Result<(), AnyError> { + let path = box_dir.join("logs/container.json"); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Ok(contents) = tokio::fs::read_to_string(&path).await { + let entries: Vec = contents + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let stdout = entries.iter().any(|entry| { + entry.stream == "stdout" && entry.log == "sandbox-state=captured\n" + }); + let stderr = entries + .iter() + .any(|entry| entry.stream == "stderr" && entry.log == "sandbox-stderr\n"); + if stdout && stderr { + println!("logs stdout=ok stderr=ok format=json-file"); + return Ok(()); + } + } + if tokio::time::Instant::now() >= deadline { + return Err(failure(format!( + "Sandbox structured logs did not capture both streams at {}", + path.display() + ))); + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + async fn wait_for_process_exit(identity: (u32, u64)) -> Result<(), AnyError> { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while a3s_box_runtime::is_process_alive_with_identity(identity.0, Some(identity.1)) { + if tokio::time::Instant::now() >= deadline { + return Err(failure("Sandbox log worker leaked after terminal cleanup")); + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + Ok(()) + } + + async fn cleanup( + home_dir: &Path, + state_path: &Path, + operation_id: &OperationId, + ) -> Result<(), AnyError> { + if !state_path.exists() { + return Ok(()); + } + let store = ManagedExecutionStore::new(state_path); + let Some(record) = store.get_by_operation_id(operation_id)? else { + return Ok(()); + }; + let execution_id = ExecutionId::new(record.id.clone())?; + let generation = record + .managed_execution + .as_ref() + .ok_or_else(|| failure("smoke-test execution lost managed metadata"))? + .generation; + let manager = LocalExecutionManager::with_vm_backend(state_path, home_dir); + let _ = manager.kill(&execution_id, generation).await?; + Ok(()) + } + + fn require(condition: bool, message: &str) -> Result<(), AnyError> { + if condition { + Ok(()) + } else { + Err(failure(message)) + } + } + + fn failure(message: impl Into) -> AnyError { + Box::new(io::Error::other(message.into())) + } + + struct RestrictiveUmask(libc::mode_t); + + impl RestrictiveUmask { + fn install() -> Self { + // SAFETY: umask has no memory-safety preconditions. This smoke is a + // single-purpose process, and the guard restores the caller value. + Self(unsafe { libc::umask(0o077) }) + } + } + + impl Drop for RestrictiveUmask { + fn drop(&mut self) { + // SAFETY: see `install`; restoring the process umask is infallible. + unsafe { + libc::umask(self.0); + } + } + } +} diff --git a/src/runtime/src/box_record.rs b/src/runtime/src/box_record.rs new file mode 100644 index 00000000..b213496d --- /dev/null +++ b/src/runtime/src/box_record.rs @@ -0,0 +1,670 @@ +//! Canonical persisted metadata schema for local box executions. + +use std::collections::HashMap; +use std::path::PathBuf; + +use a3s_box_core::config::ResourceLimits; +use a3s_box_core::log::LogConfig; +use a3s_box_core::{ + CreateExecutionRequest, ExecutionGeneration, ExecutionIsolation, ExecutionSnapshotId, + NetworkMode, OperationId, ResolvedExecutionPlan, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +pub use a3s_box_core::ExecutionHealthCheck as HealthCheck; + +/// Metadata record for a single local box execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoxRecord { + /// Full UUID. + pub id: String, + /// First 12 hex characters of the UUID, without dashes. + pub short_id: String, + /// User-assigned or generated name. + pub name: String, + /// OCI image reference. + pub image: String, + /// Requested execution isolation. Records written before this field default to MicroVM. + #[serde(default)] + pub isolation: ExecutionIsolation, + /// Runtime lifecycle identity and recoverable creation intent. + /// + /// Legacy CLI-created records omit this field. Managed executions persist + /// it before launch so an operation can be reconciled after a service + /// restart without creating a second execution. + #[serde(default)] + pub managed_execution: Option, + /// Persisted lifecycle state. + /// + /// Legacy records use `created`, `running`, `paused`, `stopped`, and + /// `dead`. Managed executions additionally use the durable transition + /// states defined by [`ManagedExecutionState`]. + pub status: String, + /// Shim process PID while the execution is active. + pub pid: Option, + /// Start-time identity token used to reject a reused PID. + #[serde(default)] + pub pid_start_time: Option, + /// Number of virtual CPUs. + pub cpus: u32, + /// Memory in MiB. + pub memory_mb: u32, + /// Volume mounts encoded as host-to-guest pairs. + pub volumes: Vec, + /// virtio-fs cache mode for host directory volumes. + #[serde(default)] + pub virtiofs_cache: Option, + /// Environment variables. + pub env: HashMap, + /// Command override. + pub cmd: Vec, + /// Entrypoint override. + #[serde(default)] + pub entrypoint: Option>, + /// Host-side execution directory. + pub box_dir: PathBuf, + /// Path to the exec socket. + #[serde(default)] + pub exec_socket_path: PathBuf, + /// Path to the console log. + pub console_log: PathBuf, + /// Creation timestamp. + pub created_at: DateTime, + /// Start timestamp. + pub started_at: Option>, + /// Whether the execution is removed automatically after it stops. + pub auto_remove: bool, + /// Custom hostname. + #[serde(default)] + pub hostname: Option, + /// User inside the workload. + #[serde(default)] + pub user: Option, + /// Working directory inside the workload. + #[serde(default)] + pub workdir: Option, + /// Restart policy. + #[serde(default = "default_restart_policy")] + pub restart_policy: String, + /// Port mappings. + #[serde(default)] + pub port_map: Vec, + /// User-defined labels. + #[serde(default)] + pub labels: HashMap, + /// Whether the execution was explicitly stopped by a user. + #[serde(default)] + pub stopped_by_user: bool, + /// Automatic restart count. + #[serde(default)] + pub restart_count: u32, + /// Maximum restart count for a bounded on-failure policy. + #[serde(default)] + pub max_restart_count: u32, + /// Last captured exit code. + #[serde(default)] + pub exit_code: Option, + /// Health-check configuration. + #[serde(default)] + pub health_check: Option, + /// Whether an image-defined health check was disabled explicitly. + #[serde(default)] + pub healthcheck_disabled: bool, + /// Current health state. + #[serde(default = "default_health_status")] + pub health_status: String, + /// Consecutive health-check failures. + #[serde(default)] + pub health_retries: u32, + /// Timestamp of the most recent health check. + #[serde(default)] + pub health_last_check: Option>, + /// Network mode. + #[serde(default)] + pub network_mode: NetworkMode, + /// Attached bridge network name. + #[serde(default)] + pub network_name: Option, + /// Attached named volumes. + #[serde(default)] + pub volume_names: Vec, + /// tmpfs mounts. + #[serde(default)] + pub tmpfs: Vec, + /// Anonymous volumes materialized from OCI declarations. + #[serde(default)] + pub anonymous_volumes: Vec, + /// Host resource controls. + #[serde(default)] + pub resource_limits: ResourceLimits, + /// Logging configuration. + #[serde(default)] + pub log_config: LogConfig, + /// Custom host-to-IP mappings. + #[serde(default)] + pub add_host: Vec, + /// Target OCI platform. + #[serde(default)] + pub platform: Option, + /// Whether to run an init process as PID 1. + #[serde(default)] + pub init: bool, + /// Whether the root filesystem is read-only. + #[serde(default)] + pub read_only: bool, + /// Added Linux capabilities. + #[serde(default)] + pub cap_add: Vec, + /// Dropped Linux capabilities. + #[serde(default)] + pub cap_drop: Vec, + /// OCI security options. + #[serde(default)] + pub security_opt: Vec, + /// Whether extended privileges are enabled. + #[serde(default)] + pub privileged: bool, + /// Device mappings. + #[serde(default)] + pub devices: Vec, + /// GPU selection. + #[serde(default)] + pub gpus: Option, + /// Shared-memory size in bytes. + #[serde(default)] + pub shm_size: Option, + /// Signal used for graceful stop. + #[serde(default)] + pub stop_signal: Option, + /// Graceful stop timeout in seconds. + #[serde(default)] + pub stop_timeout: Option, + /// Whether the OOM killer is disabled. + #[serde(default)] + pub oom_kill_disable: bool, + /// Host OOM score adjustment. + #[serde(default)] + pub oom_score_adj: Option, +} + +impl BoxRecord { + /// Generate the stable short ID used by local CLI and SDK lookup. + pub fn make_short_id(id: &str) -> String { + id.replace('-', "").chars().take(12).collect() + } + + /// Whether the persisted lifecycle state represents an active execution. + pub fn is_active(&self) -> bool { + if self.managed_execution.is_some() { + return self + .managed_state() + .is_ok_and(|state| state.is_some_and(ManagedExecutionState::keeps_resources)); + } + matches!(self.status.as_str(), "running" | "paused") + } + + /// Parse the lifecycle state of a managed execution. + /// + /// Legacy records return `None`. Unknown managed states fail closed so a + /// runtime service cannot operate on a record written by incompatible + /// code. + pub fn managed_state(&self) -> a3s_box_core::Result> { + let Some(metadata) = self.managed_execution.as_ref() else { + return Ok(None); + }; + let state = ManagedExecutionState::from_status(&self.status)?; + validate_pending_operation(state, metadata)?; + Ok(Some(state)) + } + + /// Render a concise lifecycle status with health, exit, and restart annotations. + pub fn status_summary(&self) -> String { + let mut annotations = Vec::new(); + if self.is_active() && self.health_check.is_some() && self.health_status != "none" { + annotations.push(self.health_status.clone()); + } + if matches!(self.status.as_str(), "stopped" | "dead") { + if let Some(exit_code) = self.exit_code { + annotations.push(format!("Exit {exit_code}")); + } + } + if self.restart_count > 0 { + annotations.push(format!("Restarts: {}", self.restart_count)); + } + if annotations.is_empty() { + self.status.clone() + } else { + format!("{} ({})", self.status, annotations.join(", ")) + } + } +} + +/// Durable lifecycle state for an execution owned by `ExecutionManager`. +/// +/// Transitional states are persisted before backend side effects. This lets +/// a restarted manager distinguish work that was never claimed from work that +/// may already have reached the runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedExecutionState { + Creating, + Created, + Starting, + Running, + Pausing, + Paused, + Resuming, + Snapshotting, + Killing, + RestartStopping, + RestartStarting, + Stopped, + Failed, +} + +impl ManagedExecutionState { + /// Canonical value written to [`BoxRecord::status`]. + pub const fn as_status(self) -> &'static str { + match self { + Self::Creating => "creating", + Self::Created => "created", + Self::Starting => "starting", + Self::Running => "running", + Self::Pausing => "pausing", + Self::Paused => "paused", + Self::Resuming => "resuming", + Self::Snapshotting => "snapshotting", + Self::Killing => "killing", + Self::RestartStopping => "restart_stopping", + Self::RestartStarting => "restart_starting", + Self::Stopped => "stopped", + Self::Failed => "failed", + } + } + + /// Parse a persisted managed lifecycle state. + pub fn from_status(status: &str) -> a3s_box_core::Result { + match status { + "creating" => Ok(Self::Creating), + "created" => Ok(Self::Created), + "starting" => Ok(Self::Starting), + "running" => Ok(Self::Running), + "pausing" => Ok(Self::Pausing), + "paused" => Ok(Self::Paused), + "resuming" => Ok(Self::Resuming), + "snapshotting" => Ok(Self::Snapshotting), + "killing" => Ok(Self::Killing), + "restart_stopping" => Ok(Self::RestartStopping), + "restart_starting" => Ok(Self::RestartStarting), + "stopped" => Ok(Self::Stopped), + "dead" | "failed" => Ok(Self::Failed), + other => Err(a3s_box_core::BoxError::StateError(format!( + "unknown managed execution state: {other}" + ))), + } + } + + /// Whether host resources may still belong to this execution. + pub const fn keeps_resources(self) -> bool { + !matches!( + self, + Self::Creating | Self::Created | Self::Stopped | Self::Failed + ) + } + + /// Whether no further lifecycle operation can revive this execution. + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Stopped | Self::Failed) + } +} + +impl std::fmt::Display for ManagedExecutionState { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_status()) + } +} + +/// Durable lifecycle metadata for an execution owned by [`ExecutionManager`]. +/// +/// [`ExecutionManager`]: a3s_box_core::ExecutionManager +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedExecutionMetadata { + /// Idempotency key of the create operation. + pub operation_id: OperationId, + /// Runtime generation used to reject stale lifecycle requests. + pub generation: ExecutionGeneration, + /// Full creation intent required to recover an interrupted launch. + pub request: CreateExecutionRequest, + /// Backend resolution validated before any launch side effects. + pub plan: ResolvedExecutionPlan, + /// Lifecycle side effect claimed before calling the backend. + #[serde(default)] + pub pending_operation: Option, + /// Most recent completed restart retained for idempotent response replay. + #[serde(default)] + pub last_restart: Option, +} + +/// Recoverable backend operation associated with a transitional state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ManagedExecutionOperation { + Start, + Pause { + keep_memory: bool, + }, + Resume, + Snapshot { + snapshot_id: ExecutionSnapshotId, + source_state: ManagedExecutionState, + }, + Kill, + Restart { + operation_id: OperationId, + source_generation: ExecutionGeneration, + source_state: ManagedExecutionState, + #[serde(default)] + stop_timeout_secs: Option, + }, +} + +/// Durable result of the most recent restart operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedRestartOutcome { + Running, + Failed, +} + +/// Restart identity retained after its transitional state has completed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedRestartCompletion { + pub operation_id: OperationId, + pub source_generation: ExecutionGeneration, + pub target_generation: ExecutionGeneration, + pub outcome: ManagedRestartOutcome, + #[serde(default)] + pub stop_timeout_secs: Option, +} + +impl ManagedExecutionMetadata { + /// Build validated recovery metadata from one creation request. + pub fn new( + operation_id: OperationId, + generation: ExecutionGeneration, + request: CreateExecutionRequest, + ) -> a3s_box_core::Result { + if request.external_sandbox_id.trim().is_empty() { + return Err(a3s_box_core::BoxError::ConfigError( + "external sandbox ID cannot be empty".to_string(), + )); + } + let plan = a3s_box_core::resolve_execution(&request.config)?; + Ok(Self { + operation_id, + generation, + request, + plan, + pending_operation: None, + last_restart: None, + }) + } + + /// Validate deserialized metadata before it participates in reconciliation. + pub fn validate(&self) -> a3s_box_core::Result<()> { + if self.request.external_sandbox_id.trim().is_empty() { + return Err(a3s_box_core::BoxError::StateError( + "managed execution has an empty external sandbox ID".to_string(), + )); + } + let resolved = a3s_box_core::resolve_execution(&self.request.config)?; + if resolved != self.plan { + return Err(a3s_box_core::BoxError::StateError( + "managed execution plan does not match its persisted creation request".to_string(), + )); + } + if let Some(completed) = &self.last_restart { + let expected_target = next_generation(completed.source_generation)?; + if completed.target_generation != expected_target { + return Err(a3s_box_core::BoxError::StateError(format!( + "completed restart {} has inconsistent generations", + completed.operation_id + ))); + } + validate_restart_timeout(completed.stop_timeout_secs)?; + } + Ok(()) + } +} + +fn validate_pending_operation( + state: ManagedExecutionState, + metadata: &ManagedExecutionMetadata, +) -> a3s_box_core::Result<()> { + let operation = metadata.pending_operation.as_ref(); + let consistent = matches!( + (state, operation), + ( + ManagedExecutionState::Starting, + Some(ManagedExecutionOperation::Start) + ) | ( + ManagedExecutionState::Pausing, + Some(ManagedExecutionOperation::Pause { .. }) + ) | ( + ManagedExecutionState::Resuming, + Some(ManagedExecutionOperation::Resume) + ) | ( + ManagedExecutionState::Snapshotting, + Some(ManagedExecutionOperation::Snapshot { .. }) + ) | ( + ManagedExecutionState::Killing, + Some(ManagedExecutionOperation::Kill) + ) | ( + ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting, + Some(ManagedExecutionOperation::Restart { .. }) + ) | ( + ManagedExecutionState::Creating + | ManagedExecutionState::Created + | ManagedExecutionState::Running + | ManagedExecutionState::Paused + | ManagedExecutionState::Stopped + | ManagedExecutionState::Failed, + None + ) + ); + if !consistent { + return Err(a3s_box_core::BoxError::StateError(format!( + "managed execution state {state} has inconsistent pending operation" + ))); + } + + if let Some(ManagedExecutionOperation::Restart { + source_generation, + source_state, + stop_timeout_secs, + .. + }) = operation + { + if !matches!( + source_state, + ManagedExecutionState::Created + | ManagedExecutionState::Running + | ManagedExecutionState::Paused + | ManagedExecutionState::Stopped + | ManagedExecutionState::Failed + ) { + return Err(a3s_box_core::BoxError::StateError( + "restart source state is not stable".to_string(), + )); + } + let expected = match state { + ManagedExecutionState::RestartStopping => *source_generation, + ManagedExecutionState::RestartStarting => next_generation(*source_generation)?, + _ => { + return Err(a3s_box_core::BoxError::StateError( + "restart operation is attached to a non-restart state".to_string(), + )) + } + }; + if metadata.generation != expected { + return Err(a3s_box_core::BoxError::StateError(format!( + "restart state {state} has generation {}, expected {}", + metadata.generation.get(), + expected.get() + ))); + } + validate_restart_timeout(*stop_timeout_secs)?; + } + if let Some(ManagedExecutionOperation::Snapshot { source_state, .. }) = operation { + if state != ManagedExecutionState::Snapshotting + || !matches!( + source_state, + ManagedExecutionState::Running | ManagedExecutionState::Paused + ) + { + return Err(a3s_box_core::BoxError::StateError( + "snapshot operation has an invalid source state".to_string(), + )); + } + } + Ok(()) +} + +fn validate_restart_timeout(timeout_secs: Option) -> a3s_box_core::Result<()> { + if timeout_secs.is_some_and(|timeout| timeout.checked_mul(1_000).is_none()) { + Err(a3s_box_core::BoxError::StateError( + "restart stop timeout is too large".to_string(), + )) + } else { + Ok(()) + } +} + +fn next_generation(generation: ExecutionGeneration) -> a3s_box_core::Result { + let value = generation.get().checked_add(1).ok_or_else(|| { + a3s_box_core::BoxError::StateError("execution generation is exhausted".to_string()) + })?; + ExecutionGeneration::new(value).map_err(|error| { + a3s_box_core::BoxError::StateError(format!("invalid execution generation: {error}")) + }) +} + +fn default_restart_policy() -> String { + "no".to_string() +} + +fn default_health_status() -> String { + "none".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn minimal_record() -> serde_json::Value { + serde_json::json!({ + "id": "11111111-1111-4111-8111-111111111111", + "short_id": "111111111111", + "name": "fixture", + "image": "alpine:latest", + "status": "created", + "pid": null, + "cpus": 1, + "memory_mb": 128, + "volumes": [], + "env": {}, + "cmd": ["sh"], + "box_dir": "/tmp/fixture", + "console_log": "/tmp/fixture/console.log", + "created_at": "2026-07-14T12:00:00Z", + "started_at": null, + "auto_remove": false + }) + } + + #[test] + fn legacy_records_default_without_losing_runtime_fields() { + let mut value = minimal_record(); + value["virtiofs_cache"] = serde_json::json!("always"); + let record: BoxRecord = serde_json::from_value(value).unwrap(); + + assert_eq!(record.isolation, ExecutionIsolation::Microvm); + assert!(record.managed_execution.is_none()); + assert_eq!(record.virtiofs_cache.as_deref(), Some("always")); + assert_eq!(record.restart_policy, "no"); + assert_eq!(record.health_status, "none"); + assert_eq!( + serde_json::to_value(record).unwrap()["virtiofs_cache"], + "always" + ); + } + + #[test] + fn managed_execution_metadata_round_trips_recovery_intent() { + let mut config = a3s_box_core::BoxConfig { + image: "alpine:latest".to_string(), + isolation: ExecutionIsolation::Sandbox, + ..Default::default() + }; + config.resources.vcpus = 1; + config.resources.memory_mb = 128; + let metadata = ManagedExecutionMetadata::new( + OperationId::new("create-op-1").unwrap(), + ExecutionGeneration::INITIAL, + CreateExecutionRequest { + external_sandbox_id: "sandbox-1".to_string(), + config, + labels: Default::default(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + ) + .unwrap(); + let mut value = minimal_record(); + value["managed_execution"] = serde_json::to_value(metadata).unwrap(); + + let record: BoxRecord = serde_json::from_value(value).unwrap(); + let encoded = serde_json::to_value(&record).unwrap(); + assert_eq!( + record.managed_state().unwrap(), + Some(ManagedExecutionState::Created) + ); + assert!(!record.is_active()); + let managed = record.managed_execution.unwrap(); + + assert_eq!(managed.operation_id.as_str(), "create-op-1"); + assert_eq!(managed.generation, ExecutionGeneration::INITIAL); + assert_eq!(managed.request.external_sandbox_id, "sandbox-1"); + assert_eq!( + managed.request.config.isolation, + ExecutionIsolation::Sandbox + ); + assert_eq!(encoded["managed_execution"]["generation"], 1); + } + + #[test] + fn managed_execution_validation_rejects_plan_drift() { + let config = a3s_box_core::BoxConfig { + image: "alpine:latest".to_string(), + isolation: ExecutionIsolation::Sandbox, + ..Default::default() + }; + let mut metadata = ManagedExecutionMetadata::new( + OperationId::new("create-op-1").unwrap(), + ExecutionGeneration::INITIAL, + CreateExecutionRequest { + external_sandbox_id: "sandbox-1".to_string(), + config, + labels: Default::default(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + ) + .unwrap(); + metadata.plan = + a3s_box_core::resolve_execution(&a3s_box_core::BoxConfig::default()).unwrap(); + + assert!(metadata.validate().is_err()); + } +} diff --git a/src/runtime/src/box_state.rs b/src/runtime/src/box_state.rs new file mode 100644 index 00000000..18805f7d --- /dev/null +++ b/src/runtime/src/box_state.rs @@ -0,0 +1,538 @@ +//! Durable local state store for box execution records. + +use std::path::{Path, PathBuf}; + +use crate::file_lock::FileLock; +use crate::store_io::quarantine_label; +use crate::BoxRecord; +use a3s_box_core::{ExecutionId, OperationId}; + +/// Durable collection of local box execution records. +/// +/// All mutating operations use the sibling `boxes.json.lock` advisory lock and +/// a durable temporary-file rename. Callers must keep transaction closures +/// synchronous and must not acquire the same store lock recursively. +#[derive(Debug)] +pub struct BoxStateStore { + path: PathBuf, + records: Vec, +} + +impl BoxStateStore { + /// Build an in-memory store for `path` from existing records. + pub fn from_records(path: impl Into, records: Vec) -> Self { + Self { + path: path.into(), + records, + } + } + + /// Load state strictly, returning invalid JSON or schema data as an error. + /// + /// A missing state file is represented by an empty store and its parent + /// directory is created for subsequent writes. + pub fn load(path: &Path) -> std::io::Result { + Self::load_unlocked(path, CorruptionPolicy::ReturnError, true) + } + + /// Load state and preserve an invalid file as a timestamped sibling. + /// + /// This compatibility path keeps the CLI available for manual recovery. + /// New runtime services should prefer [`Self::load`] and fail closed. + pub fn load_or_quarantine(path: &Path) -> std::io::Result { + Self::load_unlocked(path, CorruptionPolicy::Quarantine, true) + } + + /// Load a side-effect-free snapshot. + /// + /// This never creates directories, quarantines invalid data, acquires a + /// lock, reconciles process state, or writes the file back. + pub fn load_readonly(path: impl Into) -> std::io::Result { + let path = path.into(); + Self::load_unlocked(&path, CorruptionPolicy::ReturnError, false) + } + + /// Save this snapshot under the cross-process state lock. + pub fn save(&self) -> std::io::Result<()> { + let _lock = FileLock::acquire(&self.path)?; + self.write_unlocked() + } + + /// Apply a strict atomic read-modify-write transaction. + /// + /// The closure runs while the cross-process lock is held. If it returns an + /// error, no write is performed. + pub fn modify( + path: &Path, + f: impl FnOnce(&mut Self) -> std::io::Result, + ) -> std::io::Result { + Self::modify_with_policy(path, CorruptionPolicy::ReturnError, f) + } + + /// Apply a strict atomic transaction with a caller-defined error type. + /// + /// This is equivalent to [`Self::modify`] but lets a domain repository + /// return typed conflicts while still converting state I/O errors. + pub fn transact(path: &Path, f: impl FnOnce(&mut Self) -> Result) -> Result + where + E: From, + { + Self::modify_with_policy(path, CorruptionPolicy::ReturnError, f) + } + + /// Apply an atomic read-modify-write transaction that quarantines invalid + /// existing state before starting from an empty collection. + /// + /// This exists for CLI behavior compatibility. Runtime services should use + /// [`Self::modify`] so corrupt durable state fails closed. + pub fn modify_or_quarantine( + path: &Path, + f: impl FnOnce(&mut Self) -> Result, + ) -> Result + where + E: From, + { + Self::modify_with_policy(path, CorruptionPolicy::Quarantine, f) + } + + fn modify_with_policy( + path: &Path, + policy: CorruptionPolicy, + f: impl FnOnce(&mut Self) -> Result, + ) -> Result + where + E: From, + { + let _lock = FileLock::acquire(path).map_err(E::from)?; + let mut store = Self::load_unlocked(path, policy, true).map_err(E::from)?; + let output = f(&mut store)?; + store.write_unlocked().map_err(E::from)?; + Ok(output) + } + + fn load_unlocked( + path: &Path, + corruption_policy: CorruptionPolicy, + create_parent: bool, + ) -> std::io::Result { + if !path.exists() { + if create_parent { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + } + return Ok(Self::from_records(path.to_path_buf(), Vec::new())); + } + + let data = std::fs::read_to_string(path)?; + let parsed = serde_json::from_str::>(&data) + .map_err(|error| error.to_string()) + .and_then(|records| { + validate_managed_records(&records)?; + Ok(records) + }); + match parsed { + Ok(records) => Ok(Self::from_records(path.to_path_buf(), records)), + Err(error) if corruption_policy == CorruptionPolicy::ReturnError => { + Err(std::io::Error::new(std::io::ErrorKind::InvalidData, error)) + } + Err(error) => { + let preserved = quarantine_label(path); + eprintln!( + "a3s-box: WARNING: state file {} is corrupt ({error}); preserved a \ + copy at {preserved} and started from empty state. Running boxes are \ + no longer tracked; repair and restore the preserved records, then \ + reconcile state. Otherwise remove leaked executions manually.", + path.display(), + ); + Ok(Self::from_records(path.to_path_buf(), Vec::new())) + } + } + } + + fn write_unlocked(&self) -> std::io::Result<()> { + validate_managed_records(&self.records) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let data = serde_json::to_vec_pretty(&self.records).map_err(std::io::Error::other)?; + let temporary_path = self.path.with_extension("json.tmp"); + a3s_box_core::fs_atomic::write_durable(&temporary_path, &self.path, &data) + } + + /// Path of the durable state file. + pub fn path(&self) -> &Path { + &self.path + } + + /// All execution records in persistence order. + pub fn records(&self) -> &[BoxRecord] { + &self.records + } + + /// Mutable execution records for a synchronous transaction. + pub fn records_mut(&mut self) -> &mut Vec { + &mut self.records + } + + /// Find a record by exact execution ID. + pub fn find_by_id(&self, id: &str) -> Option<&BoxRecord> { + self.records.iter().find(|record| record.id == id) + } + + /// Find a mutable record by exact execution ID. + pub fn find_by_id_mut(&mut self, id: &str) -> Option<&mut BoxRecord> { + self.records.iter_mut().find(|record| record.id == id) + } + + /// Remove a record by exact execution ID. + pub fn remove_by_id(&mut self, id: &str) -> bool { + let previous_len = self.records.len(); + self.records.retain(|record| record.id != id); + self.records.len() < previous_len + } + + /// Find a record by exact user-visible name. + pub fn find_by_name(&self, name: &str) -> Option<&BoxRecord> { + self.records.iter().find(|record| record.name == name) + } + + /// Find a managed execution by its idempotent creation operation. + pub fn find_by_operation_id(&self, operation_id: &OperationId) -> Option<&BoxRecord> { + self.records.iter().find(|record| { + record + .managed_execution + .as_ref() + .is_some_and(|metadata| &metadata.operation_id == operation_id) + }) + } + + /// Find a mutable managed execution by its idempotent creation operation. + pub fn find_by_operation_id_mut( + &mut self, + operation_id: &OperationId, + ) -> Option<&mut BoxRecord> { + self.records.iter_mut().find(|record| { + record + .managed_execution + .as_ref() + .is_some_and(|metadata| &metadata.operation_id == operation_id) + }) + } + + /// Find records matching a full-ID or short-ID prefix. + pub fn find_by_id_prefix(&self, prefix: &str) -> Vec<&BoxRecord> { + self.records + .iter() + .filter(|record| record.id.starts_with(prefix) || record.short_id.starts_with(prefix)) + .collect() + } + + /// List all records or only records in the running state. + pub fn list(&self, all: bool) -> Vec<&BoxRecord> { + self.records + .iter() + .filter(|record| all || record.status == "running") + .collect() + } +} + +fn validate_managed_records(records: &[BoxRecord]) -> Result<(), String> { + let mut operation_ids = std::collections::HashSet::new(); + for record in records { + let Some(metadata) = &record.managed_execution else { + continue; + }; + metadata + .validate() + .map_err(|error| format!("invalid managed execution {}: {error}", record.id))?; + ExecutionId::new(record.id.clone()) + .map_err(|error| format!("invalid managed execution {}: {error}", record.id))?; + record + .managed_state() + .map_err(|error| format!("invalid managed execution {}: {error}", record.id))?; + if !operation_ids.insert(metadata.operation_id.clone()) { + return Err(format!( + "duplicate managed operation ID: {}", + metadata.operation_id + )); + } + } + Ok(()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CorruptionPolicy { + ReturnError, + Quarantine, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(id: &str) -> BoxRecord { + serde_json::from_value(serde_json::json!({ + "id": id, + "short_id": BoxRecord::make_short_id(id), + "name": format!("box-{id}"), + "image": "alpine:latest", + "status": "created", + "pid": null, + "cpus": 1, + "memory_mb": 128, + "volumes": [], + "env": {}, + "cmd": ["sh"], + "box_dir": format!("/tmp/{id}"), + "console_log": format!("/tmp/{id}/console.log"), + "created_at": "2026-07-14T12:00:00Z", + "started_at": null, + "auto_remove": false + })) + .unwrap() + } + + fn managed_record(id: &str, operation_id: OperationId) -> BoxRecord { + let mut record = record(id); + let config = a3s_box_core::BoxConfig { + image: "alpine:latest".to_string(), + isolation: a3s_box_core::ExecutionIsolation::Sandbox, + ..Default::default() + }; + record.managed_execution = Some( + crate::ManagedExecutionMetadata::new( + operation_id, + a3s_box_core::ExecutionGeneration::INITIAL, + a3s_box_core::CreateExecutionRequest { + external_sandbox_id: format!("sandbox-{id}"), + config, + labels: Default::default(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + ) + .unwrap(), + ); + record + } + + #[test] + fn missing_state_is_empty_and_creates_parent() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("nested").join("boxes.json"); + + let store = BoxStateStore::load(&path).unwrap(); + + assert!(store.records().is_empty()); + assert!(path.parent().unwrap().exists()); + assert!(!path.exists()); + } + + #[test] + fn strict_load_reports_corruption_without_moving_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + std::fs::write(&path, "invalid json").unwrap(); + + let error = BoxStateStore::load(&path).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "invalid json"); + } + + #[test] + fn compatibility_load_quarantines_corruption() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + std::fs::write(&path, "invalid json").unwrap(); + + let store = BoxStateStore::load_or_quarantine(&path).unwrap(); + + assert!(store.records().is_empty()); + assert!(!path.exists()); + let backups: Vec<_> = std::fs::read_dir(directory.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().contains(".corrupt-")) + .collect(); + assert_eq!(backups.len(), 1); + assert_eq!( + std::fs::read_to_string(backups[0].path()).unwrap(), + "invalid json" + ); + } + + #[test] + fn failed_transaction_does_not_write_mutations() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + BoxStateStore::from_records(path.clone(), vec![record("original")]) + .save() + .unwrap(); + + let result = BoxStateStore::modify(&path, |store| { + store.records_mut().push(record("discarded")); + Err::<(), _>(std::io::Error::other("abort")) + }); + + assert!(result.is_err()); + let persisted = BoxStateStore::load(&path).unwrap(); + assert_eq!(persisted.records().len(), 1); + assert_eq!(persisted.records()[0].id, "original"); + } + + #[test] + fn save_preserves_runtime_owned_fields() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + let mut value = record("runtime-field"); + value.virtiofs_cache = Some("always".to_string()); + + BoxStateStore::from_records(path.clone(), vec![value]) + .save() + .unwrap(); + + let persisted = BoxStateStore::load(&path).unwrap(); + assert_eq!( + persisted.records()[0].virtiofs_cache.as_deref(), + Some("always") + ); + } + + #[test] + fn operation_lookup_ignores_legacy_records_and_finds_managed_intent() { + let operation_id = OperationId::new("operation-1").unwrap(); + let managed = managed_record("managed", operation_id.clone()); + let mut store = + BoxStateStore::from_records("/tmp/boxes.json", vec![record("legacy"), managed]); + + assert_eq!( + store.find_by_operation_id(&operation_id).unwrap().id, + "managed" + ); + store + .find_by_operation_id_mut(&operation_id) + .unwrap() + .status = "running".to_string(); + assert_eq!(store.find_by_id("managed").unwrap().status, "running"); + assert!(store + .find_by_operation_id(&OperationId::new("missing").unwrap()) + .is_none()); + } + + #[test] + fn strict_load_rejects_duplicate_managed_operation_ids() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + let operation_id = OperationId::new("operation-1").unwrap(); + let records = vec![ + managed_record("first", operation_id.clone()), + managed_record("second", operation_id), + ]; + std::fs::write(&path, serde_json::to_vec(&records).unwrap()).unwrap(); + + let error = BoxStateStore::load(&path).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error.to_string().contains("duplicate managed operation ID")); + } + + #[test] + fn transaction_rejects_duplicate_operation_without_changing_disk() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + let operation_id = OperationId::new("operation-1").unwrap(); + BoxStateStore::from_records( + path.clone(), + vec![managed_record("first", operation_id.clone())], + ) + .save() + .unwrap(); + + let error = BoxStateStore::modify(&path, |store| { + store + .records_mut() + .push(managed_record("second", operation_id)); + Ok(()) + }) + .unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + let persisted = BoxStateStore::load(&path).unwrap(); + assert_eq!(persisted.records().len(), 1); + assert_eq!(persisted.records()[0].id, "first"); + } + + #[test] + fn strict_load_rejects_managed_plan_drift() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + let mut record = managed_record("managed", OperationId::new("operation-1").unwrap()); + record.managed_execution.as_mut().unwrap().plan = + a3s_box_core::resolve_execution(&a3s_box_core::BoxConfig::default()).unwrap(); + std::fs::write(&path, serde_json::to_vec(&vec![record]).unwrap()).unwrap(); + + let error = BoxStateStore::load(&path).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error.to_string().contains("does not match")); + } + + #[test] + fn strict_load_rejects_unknown_managed_lifecycle_state() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + let mut record = managed_record("managed", OperationId::new("operation-1").unwrap()); + record.status = "future-state".to_string(); + std::fs::write(&path, serde_json::to_vec(&vec![record]).unwrap()).unwrap(); + + let error = BoxStateStore::load(&path).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error + .to_string() + .contains("unknown managed execution state")); + } + + #[test] + fn strict_load_rejects_transition_without_matching_pending_operation() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + let mut record = managed_record("managed", OperationId::new("operation-1").unwrap()); + record.status = "pausing".to_string(); + std::fs::write(&path, serde_json::to_vec(&vec![record]).unwrap()).unwrap(); + + let error = BoxStateStore::load(&path).unwrap_err(); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error.to_string().contains("inconsistent pending operation")); + } + + #[cfg(unix)] + #[test] + fn concurrent_transactions_do_not_lose_records() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("boxes.json"); + let handles: Vec<_> = (0..8) + .map(|index| { + let path = path.clone(); + std::thread::spawn(move || { + BoxStateStore::modify(&path, |store| { + store.records_mut().push(record(&format!("id-{index}"))); + Ok::<(), std::io::Error>(()) + }) + .unwrap(); + }) + }) + .collect(); + + for handle in handles { + handle.join().unwrap(); + } + + let store = BoxStateStore::load(&path).unwrap(); + assert_eq!(store.records().len(), 8); + } +} diff --git a/src/runtime/src/cache/layer_cache.rs b/src/runtime/src/cache/layer_cache.rs index f4bca300..18fb033d 100644 --- a/src/runtime/src/cache/layer_cache.rs +++ b/src/runtime/src/cache/layer_cache.rs @@ -255,6 +255,17 @@ fn preserve_owner(meta: &std::fs::Metadata, dst: &Path) { fn preserve_owner(_meta: &std::fs::Metadata, _dst: &Path) {} pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + #[cfg(target_os = "macos")] + { + let dst_preexisted = dst.exists(); + if copy_dir_recursive_cow(src, dst).unwrap_or(false) { + return Ok(()); + } + if !dst_preexisted && dst.exists() { + let _ = std::fs::remove_dir_all(dst); + } + } + std::fs::create_dir_all(dst).map_err(|e| { BoxError::CacheError(format!( "Failed to create directory {}: {}", @@ -262,10 +273,15 @@ pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { e )) })?; + let src_meta = std::fs::symlink_metadata(src).map_err(|e| { + BoxError::CacheError(format!( + "Failed to read directory metadata for {}: {}", + src.display(), + e + )) + })?; // Mirror the source directory's ownership onto the destination (root only). - if let Ok(src_meta) = std::fs::symlink_metadata(src) { - preserve_owner(&src_meta, dst); - } + preserve_owner(&src_meta, dst); for entry in std::fs::read_dir(src).map_err(|e| { BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e)) @@ -276,7 +292,7 @@ pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { let dst_path = dst.join(entry.file_name()); // Use symlink_metadata so is_symlink() works correctly (does not follow links). - let meta = entry.metadata().map_err(|e| { + let meta = std::fs::symlink_metadata(&src_path).map_err(|e| { BoxError::CacheError(format!( "Failed to read metadata for {}: {}", src_path.display(), @@ -326,16 +342,51 @@ pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { } } + // Apply directory permissions only after copying its children. This both + // preserves image modes in the cache and avoids making a read-only source + // directory's destination unwritable before recursion is complete. + std::fs::set_permissions(dst, src_meta.permissions()).map_err(|e| { + BoxError::CacheError(format!( + "Failed to preserve directory permissions on {}: {}", + dst.display(), + e + )) + })?; + Ok(()) } -/// Copy a regular file, preferring a copy-on-write reflink (`FICLONE`) so a new -/// box's rootfs shares blocks with the cached image — instant, no extra disk — on -/// reflink-capable filesystems (btrfs, XFS `reflink=1`, bcachefs). Falls back to a -/// plain byte copy when reflink is unsupported (e.g. ext4) or the source and -/// destination are on different filesystems. Overlay is preferred on Linux, so -/// this only runs on the `CopyProvider` fallback path. -fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> { +#[cfg(target_os = "macos")] +fn copy_dir_recursive_cow(src: &Path, dst: &Path) -> std::io::Result { + use std::os::unix::ffi::OsStrExt; + + let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "source path contains a NUL byte", + ) + })?; + let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "destination path contains a NUL byte", + ) + })?; + let flags = libc::COPYFILE_CLONE | libc::COPYFILE_RECURSIVE; + // SAFETY: both C strings are valid NUL-terminated paths for the duration of + // the call. A non-zero result means the system fast path could not handle + // this tree, so callers fall back to the portable recursive copy. + let rc = unsafe { libc::copyfile(src_c.as_ptr(), dst_c.as_ptr(), std::ptr::null_mut(), flags) }; + Ok(rc == 0) +} + +/// Copy a regular file, preferring copy-on-write cloning so a new box's rootfs +/// shares blocks with the cached image — instant, no extra disk — on capable +/// filesystems (Linux FICLONE, macOS APFS clonefile). Falls back to a plain byte +/// copy when cloning is unsupported or the source and destination are on +/// different filesystems. Overlay is preferred on Linux, so this mostly helps +/// macOS/HVF and Linux `CopyProvider` fallback paths. +pub(crate) fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> { #[cfg(target_os = "linux")] { use std::os::unix::io::AsRawFd; @@ -366,6 +417,43 @@ fn copy_file_cow(src: &Path, dst: &Path) -> std::io::Result<()> { return Ok(()); } } + + #[cfg(target_os = "macos")] + { + use std::os::unix::ffi::OsStrExt; + + let cloned = (|| -> std::io::Result { + let src_c = std::ffi::CString::new(src.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "source path contains a NUL byte", + ) + })?; + let dst_c = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "destination path contains a NUL byte", + ) + })?; + + // SAFETY: both C strings are valid NUL-terminated paths for the + // duration of the call. A non-zero result means clonefile is not + // available for this path/filesystem and we fall back to byte copy. + let rc = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), 0) }; + if rc != 0 { + return Ok(false); + } + if let Ok(perm) = std::fs::metadata(src).map(|m| m.permissions()) { + let _ = std::fs::set_permissions(dst, perm); + } + Ok(true) + })() + .unwrap_or(false); + if cloned { + return Ok(()); + } + } + std::fs::copy(src, dst).map(|_| ()) } @@ -429,17 +517,21 @@ pub(crate) fn write_meta_atomically(meta_path: &Path, json: &str) -> Result<()> /// Calculate the total size of a directory recursively. pub(crate) fn dir_size(path: &Path) -> std::io::Result { - let mut total = 0; - if path.is_dir() { - for entry in std::fs::read_dir(path)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - total += dir_size(&path)?; - } else { - total += entry.metadata()?.len(); - } - } + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() || metadata.is_file() { + return Ok(metadata.len()); + } + if !metadata.is_dir() { + return Ok(0); + } + + let mut total = 0_u64; + for entry in std::fs::read_dir(path)? { + total = total.saturating_add(dir_size(&entry?.path())?); } Ok(total) } @@ -770,6 +862,26 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn test_copy_dir_recursive_preserves_symlinks() { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src"); + let dst = tmp.path().join("dst"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("target.txt"), "target").unwrap(); + std::os::unix::fs::symlink("target.txt", src.join("link.txt")).unwrap(); + + copy_dir_recursive(&src, &dst).unwrap(); + + let link_meta = std::fs::symlink_metadata(dst.join("link.txt")).unwrap(); + assert!(link_meta.file_type().is_symlink()); + assert_eq!( + std::fs::read_link(dst.join("link.txt")).unwrap(), + std::path::PathBuf::from("target.txt") + ); + } + #[test] fn test_dir_size() { let tmp = TempDir::new().unwrap(); @@ -786,6 +898,42 @@ mod tests { assert_eq!(size, 10); } + #[cfg(unix)] + #[test] + fn test_dir_size_does_not_follow_external_directory_symlink() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("sized"); + let outside = tmp.path().join("outside"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(dir.join("local"), b"local").unwrap(); + std::fs::write(outside.join("host-data"), vec![0_u8; 4096]).unwrap(); + let link = dir.join("external"); + std::os::unix::fs::symlink(&outside, &link).unwrap(); + + let size = dir_size(&dir).unwrap(); + + assert_eq!( + size, + 5 + std::fs::symlink_metadata(link).unwrap().len(), + "cache sizing must count the symlink itself without entering its target" + ); + } + + #[cfg(unix)] + #[test] + fn test_dir_size_does_not_follow_symlink_cycle() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("sized"); + std::fs::create_dir_all(&dir).unwrap(); + let link = dir.join("loop"); + std::os::unix::fs::symlink(".", &link).unwrap(); + + let size = dir_size(&dir).unwrap(); + + assert_eq!(size, std::fs::symlink_metadata(link).unwrap().len()); + } + #[test] fn test_dir_size_empty_directory() { let tmp = TempDir::new().unwrap(); @@ -1053,6 +1201,32 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn test_copy_dir_recursive_preserves_directory_modes() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src"); + let nested = src.join("nested"); + let dst = tmp.path().join("dst"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("file"), b"content").unwrap(); + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o710)).unwrap(); + + copy_dir_recursive(&src, &dst).unwrap(); + + let root_mode = std::fs::metadata(&dst).unwrap().permissions().mode() & 0o777; + let nested_mode = std::fs::metadata(dst.join("nested")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(root_mode, 0o755); + assert_eq!(nested_mode, 0o710); + } + #[test] fn test_copy_file_cow_overwrites_existing_dst() { // FICLONE and the fs::copy fallback both truncate the destination. diff --git a/src/runtime/src/cache/rootfs_cache.rs b/src/runtime/src/cache/rootfs_cache.rs index e01dc6d6..67749d49 100644 --- a/src/runtime/src/cache/rootfs_cache.rs +++ b/src/runtime/src/cache/rootfs_cache.rs @@ -64,7 +64,10 @@ impl RootfsCache { env: &[(String, String)], ) -> String { let mut hasher = Sha256::new(); - hasher.update(b"rootfs-cache-v1\n"); + // v2 excludes OCI-provided overlayfs private xattrs before a cached + // directory may become a metacopy lower. Do not reuse v1 entries that + // predate that ingestion invariant. + hasher.update(b"rootfs-cache-v2\n"); hasher.update(image_ref.as_bytes()); hasher.update(b"\n"); diff --git a/src/runtime/src/grpc/exec.rs b/src/runtime/src/grpc/exec.rs index 072fd6ab..e196f8a1 100644 --- a/src/runtime/src/grpc/exec.rs +++ b/src/runtime/src/grpc/exec.rs @@ -12,6 +12,11 @@ const EXEC_CONTROL_CANCEL: &[u8] = b"cancel"; const EXEC_CONTROL_STDIN_CLOSE: &[u8] = b"stdin-close"; /// Host→guest control: flush all buffered output and reply with a flush-ack. const EXEC_CONTROL_FLUSH: &[u8] = b"flush"; +/// Host→guest control: stream a tar archive of the guest-visible rootfs. +const EXEC_CONTROL_ARCHIVE_ROOTFS: &[u8] = b"archive-rootfs-v1"; +const EXEC_CONTROL_ARCHIVE_ROOTFS_PAUSE: &[u8] = b"archive-rootfs-v1:pause"; +/// Guest→host marker after every archive data frame has been sent. +const EXEC_ARCHIVE_ROOTFS_DONE: &[u8] = b"archive-rootfs-v1-done"; /// Guest→host marker (carried in a Control frame) acknowledging a flush. Kept /// distinct from an `ExecExit` JSON payload so `next_event` can tell them apart. /// Must match the guest's `EXEC_FLUSH_ACK` in `guest/init/src/exec_server.rs`. @@ -24,6 +29,9 @@ const EXEC_SIGNAL_MAIN_ACK: &[u8] = b"signal-main-ack"; /// received and the container main spawned. Matches the guest's /// `EXEC_SPAWN_MAIN_ACK` in `guest/init/src/exec_server.rs`. const EXEC_SPAWN_MAIN_ACK: &[u8] = b"spawn-main-ack"; +/// Guest→host negative acknowledgement for `spawn-main`, followed by a UTF-8-ish +/// diagnostic string from guest-init. +const EXEC_SPAWN_MAIN_NACK: &[u8] = b"spawn-main-nack:"; /// Host-side slack added to a one-shot exec's in-guest `timeout_ns` before the /// host gives up reading the reply. The in-guest timeout cannot fire if the @@ -47,21 +55,19 @@ pub struct ExecClient { } impl ExecClient { + pub(crate) fn for_socket(socket_path: &Path) -> Self { + Self { + socket_path: socket_path.to_path_buf(), + } + } + /// Connect to the exec server via Unix socket. /// /// Verifies the socket is connectable. pub async fn connect(socket_path: &Path) -> Result { - let _stream = UnixStream::connect(socket_path).await.map_err(|e| { - BoxError::ExecError(format!( - "Failed to connect to exec server at {}: {}", - socket_path.display(), - e, - )) - })?; - - Ok(Self { - socket_path: socket_path.to_path_buf(), - }) + let client = Self::for_socket(socket_path); + let _stream = client.open_stream().await?; + Ok(client) } /// Get the socket path this client is connected to. @@ -69,24 +75,35 @@ impl ExecClient { &self.socket_path } + pub(crate) async fn open_stream(&self) -> Result { + UnixStream::connect(&self.socket_path).await.map_err(|e| { + BoxError::ExecError(format!( + "Exec connection failed to {}: {}", + self.socket_path.display(), + e, + )) + }) + } + /// Execute a command in the guest. /// /// Sends a Data frame with JSON ExecRequest, reads a Data frame with JSON ExecOutput. pub async fn exec_command( &self, request: &a3s_box_core::exec::ExecRequest, + ) -> Result { + let stream = self.open_stream().await?; + self.exec_command_on_stream(stream, request).await + } + + pub(crate) async fn exec_command_on_stream( + &self, + mut stream: UnixStream, + request: &a3s_box_core::exec::ExecRequest, ) -> Result { let payload = serde_json::to_vec(request) .map_err(|e| BoxError::ExecError(format!("Failed to serialize exec request: {}", e)))?; - let mut stream = UnixStream::connect(&self.socket_path).await.map_err(|e| { - BoxError::ExecError(format!( - "Exec connection failed to {}: {}", - self.socket_path.display(), - e, - )) - })?; - // Send request as Data frame let request_frame = a3s_transport::Frame::data(payload); let encoded = request_frame.encode().map_err(|e| { @@ -148,6 +165,15 @@ impl ExecClient { pub async fn exec_stream( &self, request: &a3s_box_core::exec::ExecRequest, + ) -> Result { + let stream = self.open_stream().await?; + self.exec_stream_on_stream(stream, request).await + } + + pub(crate) async fn exec_stream_on_stream( + &self, + stream: UnixStream, + request: &a3s_box_core::exec::ExecRequest, ) -> Result { let mut req = request.clone(); req.streaming = true; @@ -155,14 +181,6 @@ impl ExecClient { let payload = serde_json::to_vec(&req) .map_err(|e| BoxError::ExecError(format!("Failed to serialize exec request: {}", e)))?; - let stream = UnixStream::connect(&self.socket_path).await.map_err(|e| { - BoxError::ExecError(format!( - "Exec connection failed to {}: {}", - self.socket_path.display(), - e, - )) - })?; - let (r, w) = tokio::io::split(stream); let mut writer = a3s_transport::FrameWriter::new(w); writer @@ -183,24 +201,102 @@ impl ExecClient { }) } + /// Stream a guest-created rootfs tar archive into `output`. + /// + /// The guest performs `stat` and tar-header creation, preserving Linux + /// uid/gid/mode even when the host virtio-fs backing directory exposes + /// different macOS metadata. Mounted subtrees are excluded by guest-init. + pub async fn archive_rootfs(&self, output: &mut W, pause: bool) -> Result + where + W: tokio::io::AsyncWrite + Unpin, + { + let mut stream = UnixStream::connect(&self.socket_path) + .await + .map_err(|error| { + BoxError::ExecError(format!( + "Rootfs archive connection failed to {}: {error}", + self.socket_path.display() + )) + })?; + + let control = if pause { + EXEC_CONTROL_ARCHIVE_ROOTFS_PAUSE + } else { + EXEC_CONTROL_ARCHIVE_ROOTFS + }; + let request = a3s_transport::Frame::control(control.to_vec()); + stream + .write_all(&request.encode().map_err(|error| { + BoxError::ExecError(format!("Rootfs archive request encode failed: {error}")) + })?) + .await + .map_err(|error| { + BoxError::ExecError(format!("Rootfs archive request write failed: {error}")) + })?; + + let (reader, _writer) = tokio::io::split(stream); + let mut reader = a3s_transport::FrameReader::new(reader); + let mut written = 0u64; + loop { + let frame = reader + .read_frame() + .await + .map_err(|error| { + BoxError::ExecError(format!("Rootfs archive read failed: {error}")) + })? + .ok_or_else(|| { + BoxError::ExecError( + "Rootfs archive stream closed before completion".to_string(), + ) + })?; + + match frame.frame_type { + a3s_transport::FrameType::Data => { + output.write_all(&frame.payload).await.map_err(|error| { + BoxError::ExecError(format!("Rootfs archive output write failed: {error}")) + })?; + written = written.saturating_add(frame.payload.len() as u64); + } + a3s_transport::FrameType::Control if frame.payload == EXEC_ARCHIVE_ROOTFS_DONE => { + output.flush().await.map_err(|error| { + BoxError::ExecError(format!("Rootfs archive output flush failed: {error}")) + })?; + return Ok(written); + } + a3s_transport::FrameType::Error => { + return Err(BoxError::ExecError(format!( + "Guest rootfs archive failed: {}", + String::from_utf8_lossy(&frame.payload) + ))); + } + other => { + return Err(BoxError::ExecError(format!( + "Unexpected rootfs archive frame: {other:?}" + ))); + } + } + } + } + /// Transfer a file to/from the guest. /// /// Sends a Data frame with JSON FileRequest, reads a Data frame with JSON FileResponse. pub async fn file_transfer( &self, request: &a3s_box_core::exec::FileRequest, + ) -> Result { + let stream = self.open_stream().await?; + self.file_transfer_on_stream(stream, request).await + } + + pub(crate) async fn file_transfer_on_stream( + &self, + mut stream: UnixStream, + request: &a3s_box_core::exec::FileRequest, ) -> Result { let payload = serde_json::to_vec(request) .map_err(|e| BoxError::ExecError(format!("Failed to serialize file request: {}", e)))?; - let mut stream = UnixStream::connect(&self.socket_path).await.map_err(|e| { - BoxError::ExecError(format!( - "Exec connection failed to {}: {}", - self.socket_path.display(), - e, - )) - })?; - let request_frame = a3s_transport::Frame::data(payload); let encoded = request_frame.encode().map_err(|e| { BoxError::ExecError(format!("Failed to encode file request frame: {}", e)) @@ -342,6 +438,15 @@ impl ExecClient { { Ok(true) } + Ok(Some(f)) + if f.frame_type == a3s_transport::FrameType::Control + && f.payload.starts_with(EXEC_SPAWN_MAIN_NACK) => + { + let reason = String::from_utf8_lossy(&f.payload[EXEC_SPAWN_MAIN_NACK.len()..]); + Err(BoxError::ExecError(format!( + "spawn-main rejected by guest: {reason}" + ))) + } _ => Ok(false), } } @@ -694,6 +799,45 @@ mod tests { assert!(!result); } + #[tokio::test] + async fn test_archive_rootfs_streams_data_until_done_marker() { + let tmp = tempfile::TempDir::new().unwrap(); + let sock_path = tmp.path().join("archive.sock"); + let Some(listener) = bind_test_listener(&sock_path) else { + return; + }; + + tokio::spawn(async move { + // ExecClient::connect performs one reachability connection first. + let (stream, _) = listener.accept().await.unwrap(); + drop(stream); + let (mut stream, _) = listener.accept().await.unwrap(); + let mut header = [0u8; 5]; + stream.read_exact(&mut header).await.unwrap(); + assert_eq!(header[0], a3s_transport::FrameType::Control as u8); + let length = u32::from_be_bytes(header[1..5].try_into().unwrap()) as usize; + let mut payload = vec![0u8; length]; + stream.read_exact(&mut payload).await.unwrap(); + assert_eq!(payload, EXEC_CONTROL_ARCHIVE_ROOTFS); + + for payload in [b"first".as_slice(), b"-second".as_slice()] { + let frame = a3s_transport::Frame::data(payload.to_vec()); + stream.write_all(&frame.encode().unwrap()).await.unwrap(); + } + let done = a3s_transport::Frame::control(EXEC_ARCHIVE_ROOTFS_DONE.to_vec()); + stream.write_all(&done.encode().unwrap()).await.unwrap(); + }); + + let client = ExecClient::connect(&sock_path).await.unwrap(); + let output_path = tmp.path().join("rootfs.tar"); + let mut output = tokio::fs::File::create(&output_path).await.unwrap(); + let written = client.archive_rootfs(&mut output, false).await.unwrap(); + drop(output); + + assert_eq!(written, 12); + assert_eq!(std::fs::read(output_path).unwrap(), b"first-second"); + } + #[tokio::test] async fn test_exec_signal_main_round_trip() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/src/runtime/src/lib.rs b/src/runtime/src/lib.rs index 8c7fecf1..bef9f80c 100644 --- a/src/runtime/src/lib.rs +++ b/src/runtime/src/lib.rs @@ -15,17 +15,24 @@ // -- Core modules (always compiled) -- pub mod audit; +pub mod box_record; +pub mod box_state; pub mod cache; pub(crate) mod file_lock; pub mod fs; pub mod grpc; pub mod host_check; +pub mod local_execution; pub mod log; +pub mod managed_execution_store; pub mod network; pub mod oci; +pub mod process; pub mod prom; pub mod resize; +mod resolved_image; pub mod rootfs; +pub mod sandbox; pub mod snapshot; mod store_io; #[cfg(unix)] @@ -51,6 +58,23 @@ pub mod scale; // Audit pub use audit::{read_audit_log, AuditLog, AuditQuery}; +// Canonical local execution metadata +pub use box_record::{ + BoxRecord, HealthCheck, ManagedExecutionMetadata, ManagedExecutionOperation, + ManagedExecutionState, ManagedRestartCompletion, ManagedRestartOutcome, +}; +pub use box_state::BoxStateStore; +#[cfg(feature = "vm")] +pub use local_execution::VmLocalExecutionBackend; +pub use local_execution::{ + LocalExecutionBackend, LocalExecutionHandle, LocalExecutionManager, LocalExecutionObservation, +}; +pub use managed_execution_store::{ + ManagedExecutionReservation, ManagedExecutionStore, ManagedExecutionStoreError, + ManagedExecutionStoreResult, +}; +pub use process::{is_process_alive, is_process_alive_with_identity, pid_start_time}; + // gRPC clients #[cfg(unix)] pub use grpc::{ @@ -68,7 +92,7 @@ pub use network::NetworkStore; // OCI images pub use a3s_box_core::StoredImage; -pub use oci::{CredentialStore, PushResult, RegistryPusher}; +pub use oci::{CredentialStore, PushResult, RegistryProtocol, RegistryPusher}; pub use oci::{ImagePuller, ImageReference, ImageStore, RegistryAuth}; pub use oci::{OciImage, SignResult, SignaturePolicy}; @@ -76,6 +100,7 @@ pub use oci::{OciImage, SignResult, SignaturePolicy}; pub use prom::RuntimeMetrics; // Snapshot +pub use resolved_image::{load_resolved_image_config, RESOLVED_IMAGE_CONFIG_FILE}; pub use snapshot::SnapshotStore; // TEE @@ -91,7 +116,7 @@ pub use tee::{AttestationReport, AttestationRequest, PlatformInfo}; // VM #[cfg(feature = "vm")] -pub use vm::{BoxState, VmManager}; +pub use vm::{BoxState, PullProgressFn, VmManager}; #[cfg(feature = "vm")] pub use vmm::{ Entrypoint, FsMount, InstanceSpec, NetworkInstanceConfig, ShimHandler, TeeInstanceConfig, @@ -107,7 +132,7 @@ pub use volume::VolumeStore; // ── Feature-gated re-exports ── #[cfg(feature = "build")] -pub use oci::{BuildConfig, Dockerfile, Instruction}; +pub use oci::{BuildConfig, BuildRunPoolConfig, Dockerfile, Instruction}; #[cfg(feature = "compose")] pub use compose::{ComposeProject, HealthCheckSpec}; diff --git a/src/runtime/src/local_execution/api.rs b/src/runtime/src/local_execution/api.rs new file mode 100644 index 00000000..8befeece --- /dev/null +++ b/src/runtime/src/local_execution/api.rs @@ -0,0 +1,242 @@ +use a3s_box_core::{ + CreateExecutionRequest, ExecutionGeneration, ExecutionId, ExecutionLease, ExecutionManager, + ExecutionManagerError, ExecutionManagerResult, ExecutionReservation, ExecutionSnapshot, + ExecutionSnapshotId, ExecutionState, ExecutionStatus, KillOutcome, OperationId, + ReconcileOutcome, RestartExecutionOptions, +}; +use async_trait::async_trait; + +use super::support::{managed_state, outcome_from_record, require_generation, state_conflict}; +use super::{ + build_managed_record, status_from_record, LocalExecutionManager, ManagedExecutionState, + RuntimeUpdate, +}; + +#[async_trait] +impl ExecutionManager for LocalExecutionManager { + async fn create( + &self, + request: CreateExecutionRequest, + operation_id: &OperationId, + ) -> ExecutionManagerResult { + let execution_id = ExecutionId::new(uuid::Uuid::new_v4().to_string())?; + let record = build_managed_record( + &self.home_dir, + &execution_id, + operation_id.clone(), + request, + chrono::Utc::now(), + )?; + let reservation = self.reserve(record).await?; + super::record::reservation_from_record(reservation.record()) + } + + async fn start( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + require_generation(&record, execution_id, expected_generation)?; + self.ensure_started(record).await + } + + async fn inspect(&self, execution_id: &ExecutionId) -> ExecutionManagerResult { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + let record = self.stabilize_snapshot(record).await?; + let (record, state) = self.observe_record(record).await?; + status_from_record(&record, state) + } + + async fn read_logs( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + ) -> ExecutionManagerResult> { + self.read_structured_logs(execution_id, expected_generation) + .await + } + + async fn create_filesystem_snapshot( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + self.create_snapshot(execution_id, expected_generation, snapshot_id) + .await + } + + async fn filesystem_snapshot_size( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult> { + self.snapshot_size(snapshot_id).await + } + + async fn delete_filesystem_snapshot( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + self.delete_snapshot(snapshot_id).await + } + + async fn pause( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + keep_memory: bool, + ) -> ExecutionManagerResult { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + let record = self.stabilize_snapshot(record).await?; + require_generation(&record, execution_id, expected_generation)?; + if managed_state(&record)? != ManagedExecutionState::Running { + return Err(state_conflict(&record, execution_id, "pause")); + } + let claimed = self + .transition( + &record, + ManagedExecutionState::Running, + ManagedExecutionState::Pausing, + RuntimeUpdate::PauseClaim(keep_memory), + ) + .await?; + self.finish_pause(claimed).await + } + + async fn resume( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + let record = self.stabilize_snapshot(record).await?; + require_generation(&record, execution_id, expected_generation)?; + if managed_state(&record)? != ManagedExecutionState::Paused { + return Err(state_conflict(&record, execution_id, "resume")); + } + let claimed = self + .transition( + &record, + ManagedExecutionState::Paused, + ManagedExecutionState::Resuming, + RuntimeUpdate::None, + ) + .await?; + self.finish_resume(claimed).await + } + + async fn restart_with_options( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + operation_id: &OperationId, + options: RestartExecutionOptions, + ) -> ExecutionManagerResult { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + self.restart_record(record, expected_generation, operation_id, options) + .await + } + + async fn kill( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + let record = self.stabilize_snapshot(record).await?; + require_generation(&record, execution_id, expected_generation)?; + let state = managed_state(&record)?; + if state.is_terminal() { + return Ok(KillOutcome::AlreadyStopped); + } + if matches!( + state, + ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting + ) { + return Err(state_conflict(&record, execution_id, "kill")); + } + let claimed = if state == ManagedExecutionState::Killing { + record + } else { + self.transition( + &record, + state, + ManagedExecutionState::Killing, + RuntimeUpdate::None, + ) + .await? + }; + self.finish_kill(claimed).await + } + + async fn reconcile( + &self, + operation_id: &OperationId, + ) -> ExecutionManagerResult { + let Some(record) = self.get_by_operation(operation_id).await? else { + return Ok(ReconcileOutcome::Absent); + }; + match managed_state(&record)? { + ManagedExecutionState::Creating | ManagedExecutionState::Created => Ok( + ReconcileOutcome::Created(super::record::reservation_from_record(&record)?), + ), + ManagedExecutionState::Starting => self.recover_start(record).await, + ManagedExecutionState::Pausing => { + let (record, state) = self.observe_record(record).await?; + if managed_state(&record)? == ManagedExecutionState::Pausing + && state == ExecutionState::Running + { + return self.finish_pause(record).await.map(ReconcileOutcome::Ready); + } + outcome_from_record(record, state) + } + ManagedExecutionState::Resuming => { + let (record, state) = self.observe_record(record).await?; + if managed_state(&record)? == ManagedExecutionState::Resuming + && state == ExecutionState::Paused + { + return self + .finish_resume(record) + .await + .map(ReconcileOutcome::Ready); + } + outcome_from_record(record, state) + } + ManagedExecutionState::Snapshotting => self + .recover_snapshot(record) + .await + .map(ReconcileOutcome::Ready), + ManagedExecutionState::Killing => { + self.finish_kill(record).await?; + Ok(ReconcileOutcome::Failed) + } + ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting => self + .resume_restart(record) + .await + .map(ReconcileOutcome::Ready), + _ => { + let (record, state) = self.observe_record(record).await?; + outcome_from_record(record, state) + } + } + } +} diff --git a/src/runtime/src/local_execution/backend.rs b/src/runtime/src/local_execution/backend.rs new file mode 100644 index 00000000..56f5163e --- /dev/null +++ b/src/runtime/src/local_execution/backend.rs @@ -0,0 +1,108 @@ +//! Injectable process/runtime boundary for local execution orchestration. + +use std::path::PathBuf; + +use a3s_box_core::{ + ExecutionId, ExecutionManagerError, ExecutionManagerResult, ExecutionState, KillOutcome, +}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use crate::BoxRecord; + +/// Runtime evidence persisted after an execution becomes ready. +#[derive(Debug, Clone)] +pub struct LocalExecutionHandle { + pub started_at: DateTime, + pub pid: Option, + pub pid_start_time: Option, + pub exec_socket_path: PathBuf, + pub console_log: PathBuf, + pub anonymous_volumes: Vec, +} + +impl LocalExecutionHandle { + pub(crate) fn validate(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<()> { + if self.pid.is_none() && self.pid_start_time.is_some() { + return Err(ExecutionManagerError::Internal(format!( + "backend returned a PID start time without a PID for {execution_id}" + ))); + } + if self.exec_socket_path.as_os_str().is_empty() { + return Err(ExecutionManagerError::Internal(format!( + "backend returned an empty exec socket path for {execution_id}" + ))); + } + if self.console_log.as_os_str().is_empty() { + return Err(ExecutionManagerError::Internal(format!( + "backend returned an empty console log path for {execution_id}" + ))); + } + Ok(()) + } +} + +/// One backend observation used during inspection and restart recovery. +#[derive(Debug, Clone)] +pub struct LocalExecutionObservation { + pub state: ExecutionState, + pub handle: Option, + pub exit_code: Option, +} + +impl LocalExecutionObservation { + pub(crate) fn validate(&self, execution_id: &ExecutionId) -> ExecutionManagerResult<()> { + match self.state { + ExecutionState::Running | ExecutionState::Paused => { + self.handle.as_ref().ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "backend returned {:?} without runtime evidence for {execution_id}", + self.state + )) + })?; + } + ExecutionState::Created + | ExecutionState::Creating + | ExecutionState::Stopped + | ExecutionState::Failed => {} + } + if let Some(handle) = &self.handle { + handle.validate(execution_id)?; + } + Ok(()) + } +} + +/// Backend operations invoked outside the durable state lock. +/// +/// Implementations must key all host/runtime paths by [`BoxRecord::id`]. The +/// external sandbox ID in managed metadata is an untrusted diagnostic label. +#[async_trait] +pub trait LocalExecutionBackend: Send + Sync { + async fn start(&self, record: &BoxRecord) -> ExecutionManagerResult; + + async fn inspect( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult; + + async fn pause( + &self, + record: &BoxRecord, + keep_memory: bool, + ) -> ExecutionManagerResult; + + async fn resume(&self, record: &BoxRecord) -> ExecutionManagerResult; + + /// Stop the current runtime while preserving execution-owned storage for + /// the replacement generation. + async fn stop_for_restart( + &self, + record: &BoxRecord, + _timeout_secs: Option, + ) -> ExecutionManagerResult { + self.kill(record).await + } + + async fn kill(&self, record: &BoxRecord) -> ExecutionManagerResult; +} diff --git a/src/runtime/src/local_execution/create.rs b/src/runtime/src/local_execution/create.rs new file mode 100644 index 00000000..9a418206 --- /dev/null +++ b/src/runtime/src/local_execution/create.rs @@ -0,0 +1,215 @@ +use a3s_box_core::{ExecutionLease, ExecutionManagerError, ExecutionManagerResult, ExecutionState}; + +use super::record::{execution_id, lease_from_record}; +use super::store::RuntimeUpdate; +use super::support::{managed_state, required_handle}; +use super::{BoxRecord, LocalExecutionManager, ManagedExecutionState}; + +impl LocalExecutionManager { + pub(super) async fn ensure_started( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + match managed_state(&record)? { + state @ (ManagedExecutionState::Creating | ManagedExecutionState::Created) => { + self.claim_and_start(record, state).await + } + ManagedExecutionState::Starting => { + let execution_id = execution_id(&record)?; + match self.backend.inspect(&record).await { + Ok(observation) => { + observation.validate(&execution_id)?; + match observation.state { + ExecutionState::Running => { + let handle = required_handle(&observation, &execution_id)?; + let running = self + .complete_with_handle( + &record, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + handle, + ) + .await?; + lease_from_record(&running) + } + ExecutionState::Creating => Err(ExecutionManagerError::Unavailable( + format!("execution {execution_id} is still starting"), + )), + ExecutionState::Created => { + Err(ExecutionManagerError::Internal(format!( + "backend reported created state while starting {execution_id}" + ))) + } + ExecutionState::Stopped | ExecutionState::Failed => { + self.transition( + &record, + ManagedExecutionState::Starting, + ManagedExecutionState::Failed, + RuntimeUpdate::Terminal(observation.exit_code), + ) + .await?; + Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "the reserved creation operation is terminal" + .to_string(), + }) + } + ExecutionState::Paused => Err(ExecutionManagerError::Internal( + format!("execution {execution_id} became paused while starting"), + )), + } + } + Err(ExecutionManagerError::NotFound(_)) => { + Err(ExecutionManagerError::Unavailable(format!( + "execution {execution_id} has been claimed for startup" + ))) + } + Err(error) => Err(error), + } + } + ManagedExecutionState::Running => lease_from_record(&record), + state => Err(ExecutionManagerError::Conflict { + execution_id: execution_id(&record)?, + message: format!("creation operation is {state}"), + }), + } + } + + pub(super) async fn claim_and_start( + &self, + record: BoxRecord, + expected_state: ManagedExecutionState, + ) -> ExecutionManagerResult { + let claimed = match self + .transition( + &record, + expected_state, + ManagedExecutionState::Starting, + RuntimeUpdate::None, + ) + .await + { + Ok(claimed) => claimed, + Err(ExecutionManagerError::Conflict { .. }) => { + let id = execution_id(&record)?; + let current = self + .get(&id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(id.clone()))?; + return self.ensure_started_after_lost_claim(current).await; + } + Err(error) => return Err(error), + }; + + let execution_id = execution_id(&claimed)?; + match self.backend.start(&claimed).await { + Ok(handle) => { + handle.validate(&execution_id)?; + let running = self + .complete_with_handle( + &claimed, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + handle, + ) + .await?; + lease_from_record(&running) + } + Err(error) => self.resolve_start_error(claimed, error).await, + } + } + + async fn ensure_started_after_lost_claim( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + match managed_state(&record)? { + ManagedExecutionState::Running => lease_from_record(&record), + ManagedExecutionState::Creating | ManagedExecutionState::Created => { + Err(ExecutionManagerError::Unavailable(format!( + "execution {} startup claim was released; retry the request", + execution_id(&record)? + ))) + } + ManagedExecutionState::Starting => { + let id = execution_id(&record)?; + match self.backend.inspect(&record).await { + Ok(observation) if observation.state == ExecutionState::Running => { + observation.validate(&id)?; + let running = self + .complete_with_handle( + &record, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + required_handle(&observation, &id)?, + ) + .await?; + lease_from_record(&running) + } + Ok(_) | Err(ExecutionManagerError::NotFound(_)) => { + Err(ExecutionManagerError::Unavailable(format!( + "execution {id} startup is owned by another caller" + ))) + } + Err(error) => Err(error), + } + } + state => Err(ExecutionManagerError::Conflict { + execution_id: execution_id(&record)?, + message: format!("creation claim moved to {state}"), + }), + } + } + + async fn resolve_start_error( + &self, + claimed: BoxRecord, + start_error: ExecutionManagerError, + ) -> ExecutionManagerResult { + let id = execution_id(&claimed)?; + match self.backend.inspect(&claimed).await { + Ok(observation) => { + observation.validate(&id)?; + match observation.state { + ExecutionState::Running => { + let running = self + .complete_with_handle( + &claimed, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + required_handle(&observation, &id)?, + ) + .await?; + lease_from_record(&running) + } + ExecutionState::Stopped | ExecutionState::Failed => { + let _ = self + .transition( + &claimed, + ManagedExecutionState::Starting, + ManagedExecutionState::Failed, + RuntimeUpdate::Terminal(observation.exit_code), + ) + .await; + Err(start_error) + } + ExecutionState::Created | ExecutionState::Creating | ExecutionState::Paused => { + Err(start_error) + } + } + } + Err(ExecutionManagerError::NotFound(_)) => { + let _ = self + .transition( + &claimed, + ManagedExecutionState::Starting, + ManagedExecutionState::Failed, + RuntimeUpdate::Terminal(None), + ) + .await; + Err(start_error) + } + Err(_) => Err(start_error), + } + } +} diff --git a/src/runtime/src/local_execution/logs.rs b/src/runtime/src/local_execution/logs.rs new file mode 100644 index 00000000..3c8be376 --- /dev/null +++ b/src/runtime/src/local_execution/logs.rs @@ -0,0 +1,211 @@ +use std::ffi::OsString; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; + +use a3s_box_core::log::{LogDriver, LogEntry}; +use a3s_box_core::{ + ExecutionGeneration, ExecutionId, ExecutionManagerError, ExecutionManagerResult, +}; +use flate2::read::GzDecoder; + +use super::support::require_generation; +use super::LocalExecutionManager; + +const MAX_DECOMPRESSED_LOG_BYTES: u64 = 64 * 1024 * 1024; +const MAX_ROTATED_LOG_FILES: u32 = 100; + +impl LocalExecutionManager { + pub(super) async fn read_structured_logs( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + ) -> ExecutionManagerResult> { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + require_generation(&record, execution_id, expected_generation)?; + if record.log_config.driver != LogDriver::JsonFile { + return Ok(Vec::new()); + } + + let expected_box_dir = safe_box_dir(&self.home_dir, execution_id)?; + if record.box_dir != expected_box_dir { + return Err(ExecutionManagerError::Internal(format!( + "execution {execution_id} has an unexpected log directory" + ))); + } + let log_dir = expected_box_dir.join("logs"); + let max_files = record.log_config.max_file().min(MAX_ROTATED_LOG_FILES); + tokio::task::spawn_blocking(move || read_log_files(&log_dir, max_files)) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!("structured log reader failed: {error}")) + })? + .map_err(ExecutionManagerError::Internal) + } +} + +fn safe_box_dir(home_dir: &Path, execution_id: &ExecutionId) -> ExecutionManagerResult { + let value = execution_id.as_str(); + if value.is_empty() + || value == "." + || value == ".." + || value.contains('/') + || value.contains('\\') + || value.contains('\0') + { + return Err(ExecutionManagerError::Internal(format!( + "execution {execution_id} has an unsafe internal identity" + ))); + } + Ok(home_dir.join("boxes").join(value)) +} + +fn read_log_files(log_dir: &Path, max_files: u32) -> Result, String> { + let base = log_dir.join("container.json"); + let mut entries = Vec::new(); + let mut bytes_read = 0_u64; + + for index in (1..=max_files).rev() { + let path = rotated_path(&base, index); + let Some(file) = open_if_present(&path)? else { + continue; + }; + read_entries( + GzDecoder::new(file), + &path, + false, + &mut bytes_read, + &mut entries, + )?; + } + if let Some(file) = open_if_present(&base)? { + read_entries(file, &base, true, &mut bytes_read, &mut entries)?; + } + Ok(entries) +} + +fn open_if_present(path: &Path) -> Result, String> { + match std::fs::File::open(path) { + Ok(file) => Ok(Some(file)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("failed to open {}: {error}", path.display())), + } +} + +fn read_entries( + source: impl Read, + path: &Path, + allow_trailing_partial: bool, + bytes_read: &mut u64, + entries: &mut Vec, +) -> Result<(), String> { + let mut reader = BufReader::new(source); + let mut line = Vec::new(); + loop { + line.clear(); + let count = reader + .read_until(b'\n', &mut line) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + if count == 0 { + return Ok(()); + } + *bytes_read = bytes_read + .checked_add(count as u64) + .ok_or_else(|| "structured log byte count overflowed".to_string())?; + if *bytes_read > MAX_DECOMPRESSED_LOG_BYTES { + return Err(format!( + "structured logs exceed the {} byte read limit", + MAX_DECOMPRESSED_LOG_BYTES + )); + } + if !line.ends_with(b"\n") { + if allow_trailing_partial { + return Ok(()); + } + return Err(format!( + "rotated structured log {} ends with a partial entry", + path.display() + )); + } + line.pop(); + if line.ends_with(b"\r") { + line.pop(); + } + if line.is_empty() { + continue; + } + entries.push(serde_json::from_slice(&line).map_err(|error| { + format!( + "structured log {} contains invalid JSON: {error}", + path.display() + ) + })?); + } +} + +fn rotated_path(base: &Path, index: u32) -> PathBuf { + let mut path: OsString = base.as_os_str().to_owned(); + path.push(format!(".{index}.gz")); + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use flate2::write::GzEncoder; + use flate2::Compression; + + use super::*; + + fn entry(message: &str, timestamp: &str) -> LogEntry { + LogEntry { + log: message.to_string(), + stream: "stdout".to_string(), + time: timestamp.to_string(), + } + } + + #[test] + fn reads_rotated_logs_oldest_first_and_ignores_a_partial_live_tail() { + let temporary = tempfile::tempdir().unwrap(); + let base = temporary.path().join("container.json"); + let rotated = rotated_path(&base, 1); + let mut encoder = + GzEncoder::new(std::fs::File::create(rotated).unwrap(), Compression::fast()); + writeln!( + encoder, + "{}", + serde_json::to_string(&entry("old\n", "2026-07-14T12:00:00Z")).unwrap() + ) + .unwrap(); + encoder.finish().unwrap(); + std::fs::write( + &base, + format!( + "{}\n{{\"log\":\"partial", + serde_json::to_string(&entry("new\n", "2026-07-14T12:00:01Z")).unwrap() + ), + ) + .unwrap(); + + assert_eq!( + read_log_files(temporary.path(), 1).unwrap(), + vec![ + entry("old\n", "2026-07-14T12:00:00Z"), + entry("new\n", "2026-07-14T12:00:01Z") + ] + ); + } + + #[test] + fn rejects_invalid_complete_entries() { + let temporary = tempfile::tempdir().unwrap(); + std::fs::write(temporary.path().join("container.json"), "not-json\n").unwrap(); + assert!(read_log_files(temporary.path(), 0) + .unwrap_err() + .contains("invalid JSON")); + } +} diff --git a/src/runtime/src/local_execution/mod.rs b/src/runtime/src/local_execution/mod.rs new file mode 100644 index 00000000..d71144c1 --- /dev/null +++ b/src/runtime/src/local_execution/mod.rs @@ -0,0 +1,108 @@ +//! Durable implementation of the backend-neutral local execution lifecycle. + +mod api; +mod backend; +mod create; +mod logs; +mod operations; +mod port; +mod record; +mod recovery; +mod resources; +mod restart; +#[cfg(unix)] +mod session; +mod snapshot; +mod store; +mod support; +#[cfg(feature = "vm")] +mod vm_backend; +#[cfg(feature = "vm")] +mod vm_process; + +use std::path::PathBuf; +use std::sync::Arc; + +use a3s_box_core::{ + ExecutionGeneration, ExecutionId, ExecutionManagerError, ExecutionManagerResult, +}; + +pub use backend::{LocalExecutionBackend, LocalExecutionHandle, LocalExecutionObservation}; +use record::{build_managed_record, status_from_record}; +use store::RuntimeUpdate; +#[cfg(feature = "vm")] +pub use vm_backend::VmLocalExecutionBackend; + +use crate::{BoxRecord, ManagedExecutionOperation, ManagedExecutionState, ManagedExecutionStore}; + +/// Local lifecycle facade shared by service, CLI, and SDK adapters. +#[derive(Clone)] +pub struct LocalExecutionManager { + store: ManagedExecutionStore, + home_dir: PathBuf, + backend: Arc, +} + +impl LocalExecutionManager { + pub fn new( + state_path: impl Into, + home_dir: impl Into, + backend: Arc, + ) -> Self { + Self { + store: ManagedExecutionStore::new(state_path), + home_dir: home_dir.into(), + backend, + } + } + + pub fn state_path(&self) -> &std::path::Path { + self.store.path() + } + + pub(super) async fn require_running_record( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + support::require_generation(&record, execution_id, generation)?; + if support::managed_state(&record)? != ManagedExecutionState::Running { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "execution is not running".to_string(), + }); + } + if record.exec_socket_path.as_os_str().is_empty() { + return Err(ExecutionManagerError::Internal(format!( + "execution {execution_id} has no exec endpoint" + ))); + } + #[cfg(target_os = "linux")] + { + let pid = record + .pid + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if !crate::process::is_process_alive_with_identity(pid, record.pid_start_time) { + return Err(ExecutionManagerError::NotFound(execution_id.clone())); + } + } + Ok(record) + } + + #[cfg(feature = "vm")] + pub fn with_vm_backend(state_path: impl Into, home_dir: impl Into) -> Self { + let home_dir = home_dir.into(); + Self::new( + state_path, + home_dir.clone(), + Arc::new(VmLocalExecutionBackend::new(home_dir)), + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/runtime/src/local_execution/operations.rs b/src/runtime/src/local_execution/operations.rs new file mode 100644 index 00000000..3c44315d --- /dev/null +++ b/src/runtime/src/local_execution/operations.rs @@ -0,0 +1,197 @@ +use a3s_box_core::{ + ExecutionLease, ExecutionManagerError, ExecutionManagerResult, ExecutionState, KillOutcome, +}; + +use super::record::{execution_id, lease_from_record}; +use super::store::RuntimeUpdate; +use super::support::{pending_pause_policy, required_handle}; +use super::{BoxRecord, LocalExecutionManager, ManagedExecutionState}; + +impl LocalExecutionManager { + pub(super) async fn finish_pause( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + let id = execution_id(&record)?; + let keep_memory = pending_pause_policy(&record, &id)?; + match self.backend.pause(&record, keep_memory).await { + Ok(handle) => { + handle.validate(&id)?; + let paused = self + .complete_with_handle( + &record, + ManagedExecutionState::Pausing, + ManagedExecutionState::Paused, + handle, + ) + .await?; + lease_from_record(&paused) + } + Err(error) => match self.resolve_pause_error(record).await { + Some(lease) => Ok(lease), + None => Err(error), + }, + } + } + + async fn resolve_pause_error(&self, record: BoxRecord) -> Option { + let Ok(id) = execution_id(&record) else { + return None; + }; + match self.backend.inspect(&record).await { + Ok(observation) if observation.state == ExecutionState::Paused => { + if observation.validate(&id).is_ok() { + if let Ok(handle) = required_handle(&observation, &id) { + let paused = self + .complete_with_handle( + &record, + ManagedExecutionState::Pausing, + ManagedExecutionState::Paused, + handle, + ) + .await + .ok()?; + return lease_from_record(&paused).ok(); + } + } + } + Ok(observation) if observation.state == ExecutionState::Running => { + let _ = self + .transition( + &record, + ManagedExecutionState::Pausing, + ManagedExecutionState::Running, + RuntimeUpdate::None, + ) + .await; + } + _ => {} + } + None + } + + pub(super) async fn finish_resume( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + let id = execution_id(&record)?; + match self.backend.resume(&record).await { + Ok(handle) => { + handle.validate(&id)?; + let running = self + .complete_with_handle( + &record, + ManagedExecutionState::Resuming, + ManagedExecutionState::Running, + handle, + ) + .await?; + lease_from_record(&running) + } + Err(error) => match self.resolve_resume_error(record).await { + Some(lease) => Ok(lease), + None => Err(error), + }, + } + } + + async fn resolve_resume_error(&self, record: BoxRecord) -> Option { + let Ok(id) = execution_id(&record) else { + return None; + }; + match self.backend.inspect(&record).await { + Ok(observation) if observation.state == ExecutionState::Running => { + if observation.validate(&id).is_ok() { + if let Ok(handle) = required_handle(&observation, &id) { + let running = self + .complete_with_handle( + &record, + ManagedExecutionState::Resuming, + ManagedExecutionState::Running, + handle, + ) + .await + .ok()?; + return lease_from_record(&running).ok(); + } + } + } + Ok(observation) if observation.state == ExecutionState::Paused => { + let _ = self + .transition( + &record, + ManagedExecutionState::Resuming, + ManagedExecutionState::Paused, + RuntimeUpdate::None, + ) + .await; + } + _ => {} + } + None + } + + pub(super) async fn finish_kill( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + match self.backend.kill(&record).await { + Ok(outcome) => { + self.release_execution_resources(&record).await?; + self.transition( + &record, + ManagedExecutionState::Killing, + ManagedExecutionState::Stopped, + RuntimeUpdate::Terminal(None), + ) + .await?; + Ok(outcome) + } + Err(ExecutionManagerError::NotFound(_)) => { + self.release_execution_resources(&record).await?; + self.transition( + &record, + ManagedExecutionState::Killing, + ManagedExecutionState::Stopped, + RuntimeUpdate::Terminal(None), + ) + .await?; + Ok(KillOutcome::AlreadyStopped) + } + Err(error) => match self.resolve_kill_error(record).await { + Some(outcome) => Ok(outcome), + None => Err(error), + }, + } + } + + async fn resolve_kill_error(&self, record: BoxRecord) -> Option { + let terminal = match self.backend.inspect(&record).await { + Err(ExecutionManagerError::NotFound(_)) => true, + Ok(observation) + if matches!( + observation.state, + ExecutionState::Stopped | ExecutionState::Failed + ) => + { + true + } + _ => false, + }; + if !terminal { + return None; + } + if self.release_execution_resources(&record).await.is_err() { + return None; + } + self.transition( + &record, + ManagedExecutionState::Killing, + ManagedExecutionState::Stopped, + RuntimeUpdate::Terminal(None), + ) + .await + .ok()?; + Some(KillOutcome::Killed) + } +} diff --git a/src/runtime/src/local_execution/port.rs b/src/runtime/src/local_execution/port.rs new file mode 100644 index 00000000..3bfcbcec --- /dev/null +++ b/src/runtime/src/local_execution/port.rs @@ -0,0 +1,190 @@ +use std::num::NonZeroU16; +use std::time::Duration; + +#[cfg(target_os = "linux")] +use a3s_box_core::ExecutionBackend; +use a3s_box_core::{ + ExecutionGeneration, ExecutionId, ExecutionManagerError, ExecutionManagerResult, + ExecutionPortConnector, ExecutionPortStream, +}; +use async_trait::async_trait; + +use super::LocalExecutionManager; +#[cfg(target_os = "linux")] +use crate::BoxRecord; + +#[async_trait] +impl ExecutionPortConnector for LocalExecutionManager { + async fn connect_port( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + port: NonZeroU16, + timeout: Duration, + ) -> ExecutionManagerResult { + if timeout.is_zero() { + return Err(ExecutionManagerError::InvalidRequest( + "port connection timeout must be non-zero".to_string(), + )); + } + + #[cfg(target_os = "linux")] + { + let record = self.require_connectable(execution_id, generation).await?; + let pid = record + .pid + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + let pid_start_time = record.pid_start_time; + if !crate::process::is_process_alive_with_identity(pid, pid_start_time) { + return Err(ExecutionManagerError::NotFound(execution_id.clone())); + } + + let stream = connect_in_network_namespace( + execution_id.clone(), + pid, + pid_start_time, + port, + timeout, + ) + .await?; + + // The lifecycle may have advanced while the blocking connect was in + // flight. Re-read the canonical record before publishing the stream. + let current = self.require_connectable(execution_id, generation).await?; + if current.pid != Some(pid) + || current.pid_start_time != pid_start_time + || !crate::process::is_process_alive_with_identity(pid, pid_start_time) + { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "runtime generation changed while connecting its data plane" + .to_string(), + }); + } + return Ok(Box::pin(stream)); + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (execution_id, generation, port, timeout); + Err(ExecutionManagerError::Unavailable( + "Sandbox port connections require Linux network namespaces".to_string(), + )) + } + } +} + +#[cfg(target_os = "linux")] +impl LocalExecutionManager { + async fn require_connectable( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult { + let record = self + .require_running_record(execution_id, generation) + .await?; + let backend = record + .managed_execution + .as_ref() + .map(|metadata| metadata.plan.backend) + .ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "execution {execution_id} has no managed execution plan" + )) + })?; + if backend != ExecutionBackend::Crun { + return Err(ExecutionManagerError::Unavailable(format!( + "execution {execution_id} does not expose a Sandbox network namespace" + ))); + } + Ok(record) + } +} + +#[cfg(target_os = "linux")] +async fn connect_in_network_namespace( + execution_id: ExecutionId, + pid: u32, + pid_start_time: Option, + port: NonZeroU16, + timeout: Duration, +) -> ExecutionManagerResult { + let (sender, receiver) = tokio::sync::oneshot::channel(); + std::thread::Builder::new() + .name(format!("a3s-port-{pid}-{}", port.get())) + .spawn(move || { + let result = connect_in_network_namespace_blocking( + &execution_id, + pid, + pid_start_time, + port, + timeout, + ); + let _ = sender.send(result); + }) + .map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to start Sandbox port connector: {error}" + )) + })?; + + let stream = receiver.await.map_err(|_| { + ExecutionManagerError::Internal( + "Sandbox port connector exited without a result".to_string(), + ) + })??; + tokio::net::TcpStream::from_std(stream).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to register Sandbox port stream with Tokio: {error}" + )) + }) +} + +#[cfg(target_os = "linux")] +fn connect_in_network_namespace_blocking( + execution_id: &ExecutionId, + pid: u32, + pid_start_time: Option, + port: NonZeroU16, + timeout: Duration, +) -> ExecutionManagerResult { + use std::fs::File; + use std::os::fd::AsRawFd; + + if !crate::process::is_process_alive_with_identity(pid, pid_start_time) { + return Err(ExecutionManagerError::NotFound(execution_id.clone())); + } + let namespace_path = format!("/proc/{pid}/ns/net"); + let namespace = File::open(&namespace_path).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to open Sandbox network namespace {namespace_path}: {error}" + )) + })?; + let result = unsafe { libc::setns(namespace.as_raw_fd(), libc::CLONE_NEWNET) }; + if result != 0 { + return Err(ExecutionManagerError::Unavailable(format!( + "failed to enter Sandbox network namespace for PID {pid}: {}", + std::io::Error::last_os_error() + ))); + } + if !crate::process::is_process_alive_with_identity(pid, pid_start_time) { + return Err(ExecutionManagerError::Unavailable( + "Sandbox runtime exited while entering its network namespace".to_string(), + )); + } + + let address = std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port.get())); + let stream = std::net::TcpStream::connect_timeout(&address, timeout).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to connect to Sandbox loopback port {}: {error}", + port.get() + )) + })?; + stream.set_nonblocking(true).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to configure Sandbox port stream: {error}" + )) + })?; + Ok(stream) +} diff --git a/src/runtime/src/local_execution/record.rs b/src/runtime/src/local_execution/record.rs new file mode 100644 index 00000000..66107960 --- /dev/null +++ b/src/runtime/src/local_execution/record.rs @@ -0,0 +1,206 @@ +//! Canonical record mapping for managed local executions. + +use std::collections::HashMap; +use std::path::Path; + +use a3s_box_core::{ + CreateExecutionRequest, ExecutionGeneration, ExecutionId, ExecutionLease, + ExecutionManagerError, ExecutionManagerResult, ExecutionReservation, ExecutionState, + ExecutionStatus, NetworkMode, OperationId, +}; +use chrono::{DateTime, Utc}; + +use super::LocalExecutionHandle; +use crate::{BoxRecord, ManagedExecutionMetadata, ManagedExecutionState}; + +pub(crate) fn build_managed_record( + home_dir: &Path, + execution_id: &ExecutionId, + operation_id: OperationId, + request: CreateExecutionRequest, + now: DateTime, +) -> ExecutionManagerResult { + let metadata = + ManagedExecutionMetadata::new(operation_id, ExecutionGeneration::INITIAL, request.clone()) + .map_err(|error| ExecutionManagerError::InvalidRequest(error.to_string()))?; + let config = &request.config; + let policy = &request.policy; + let short_id = BoxRecord::make_short_id(execution_id.as_str()); + let box_dir = home_dir.join("boxes").join(execution_id.as_str()); + let network_name = match &config.network { + NetworkMode::Bridge { network } => Some(network.clone()), + NetworkMode::Tsi | NetworkMode::None => None, + }; + let env = config.extra_env.iter().cloned().collect::>(); + let labels = request + .labels + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + + Ok(BoxRecord { + id: execution_id.to_string(), + short_id: short_id.clone(), + name: policy + .name + .clone() + .unwrap_or_else(|| format!("managed-{short_id}")), + image: config.image.clone(), + isolation: config.isolation, + managed_execution: Some(metadata), + status: ManagedExecutionState::Created.as_status().to_string(), + pid: None, + pid_start_time: None, + cpus: config.resources.vcpus, + memory_mb: config.resources.memory_mb, + volumes: config.volumes.clone(), + virtiofs_cache: config.virtiofs_cache.clone(), + env, + cmd: config.cmd.clone(), + entrypoint: config.entrypoint_override.clone(), + box_dir: box_dir.clone(), + exec_socket_path: box_dir.join("sockets/exec.sock"), + console_log: box_dir.join("logs/console.log"), + created_at: now, + started_at: None, + auto_remove: policy.auto_remove, + hostname: config.hostname.clone(), + user: config.user.clone(), + workdir: config.workdir.clone(), + restart_policy: policy.restart_policy.as_str().to_string(), + port_map: config.port_map.clone(), + labels, + stopped_by_user: false, + restart_count: 0, + max_restart_count: policy.max_restart_count, + exit_code: None, + health_check: policy.health_check.clone(), + healthcheck_disabled: policy.healthcheck_disabled, + health_status: "none".to_string(), + health_retries: 0, + health_last_check: None, + network_mode: config.network.clone(), + network_name, + volume_names: policy.volume_names.clone(), + tmpfs: config.tmpfs.clone(), + anonymous_volumes: Vec::new(), + resource_limits: config.resource_limits.clone(), + log_config: policy.log_config.clone(), + add_host: config.add_hosts.clone(), + platform: policy.platform.clone(), + init: policy.init, + read_only: config.read_only, + cap_add: config.cap_add.clone(), + cap_drop: config.cap_drop.clone(), + security_opt: config.security_opt.clone(), + privileged: config.privileged, + devices: policy.devices.clone(), + gpus: policy.gpus.clone(), + shm_size: policy.shm_size, + stop_signal: policy.stop_signal.clone(), + stop_timeout: policy.stop_timeout, + oom_kill_disable: policy.oom_kill_disable, + oom_score_adj: policy.oom_score_adj, + }) +} + +pub(crate) fn apply_handle(record: &mut BoxRecord, handle: &LocalExecutionHandle) { + record.pid = handle.pid; + record.pid_start_time = handle.pid_start_time; + record.exec_socket_path = handle.exec_socket_path.clone(); + record.console_log = handle.console_log.clone(); + record.started_at = Some(handle.started_at); + record.anonymous_volumes = handle.anonymous_volumes.clone(); + record.exit_code = None; +} + +pub(crate) fn apply_start_handle(record: &mut BoxRecord, handle: &LocalExecutionHandle) { + apply_handle(record, handle); + initialize_health(record); + record.restart_count = 0; +} + +pub(crate) fn apply_restart_handle(record: &mut BoxRecord, handle: &LocalExecutionHandle) { + apply_handle(record, handle); + initialize_health(record); +} + +fn initialize_health(record: &mut BoxRecord) { + record.health_status = if record.health_check.is_some() { + "starting".to_string() + } else { + "none".to_string() + }; + record.health_retries = 0; + record.health_last_check = None; + record.stopped_by_user = false; +} + +pub(crate) fn clear_live_runtime(record: &mut BoxRecord, exit_code: Option) { + record.pid = None; + record.pid_start_time = None; + record.exit_code = exit_code; + record.health_status = "none".to_string(); + record.health_retries = 0; +} + +pub(crate) fn reservation_from_record( + record: &BoxRecord, +) -> ExecutionManagerResult { + let execution_id = execution_id(record)?; + let metadata = metadata(record, &execution_id)?; + Ok(ExecutionReservation { + execution_id, + generation: metadata.generation, + plan: metadata.plan.clone(), + resources: metadata.request.config.resources.clone(), + created_at: record.created_at, + }) +} + +pub(crate) fn lease_from_record(record: &BoxRecord) -> ExecutionManagerResult { + let execution_id = execution_id(record)?; + let metadata = metadata(record, &execution_id)?; + let started_at = record.started_at.ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "managed execution {execution_id} is ready without a start timestamp" + )) + })?; + Ok(ExecutionLease { + execution_id, + generation: metadata.generation, + plan: metadata.plan.clone(), + resources: metadata.request.config.resources.clone(), + started_at, + }) +} + +pub(crate) fn status_from_record( + record: &BoxRecord, + state: ExecutionState, +) -> ExecutionManagerResult { + let execution_id = execution_id(record)?; + let metadata = metadata(record, &execution_id)?; + Ok(ExecutionStatus { + execution_id, + generation: metadata.generation, + state, + plan: metadata.plan.clone(), + }) +} + +pub(crate) fn execution_id(record: &BoxRecord) -> ExecutionManagerResult { + ExecutionId::new(record.id.clone()) + .map_err(|error| ExecutionManagerError::Internal(error.to_string())) +} + +fn metadata<'a>( + record: &'a BoxRecord, + execution_id: &ExecutionId, +) -> ExecutionManagerResult<&'a ManagedExecutionMetadata> { + record.managed_execution.as_ref().ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "execution {execution_id} lost managed lifecycle metadata" + )) + }) +} diff --git a/src/runtime/src/local_execution/recovery.rs b/src/runtime/src/local_execution/recovery.rs new file mode 100644 index 00000000..9e0e338d --- /dev/null +++ b/src/runtime/src/local_execution/recovery.rs @@ -0,0 +1,242 @@ +use a3s_box_core::{ + ExecutionManagerError, ExecutionManagerResult, ExecutionState, ReconcileOutcome, +}; + +use super::record::{execution_id, lease_from_record}; +use super::support::{ + managed_state, outcome_from_record, pending_restart_source_state, required_handle, +}; +use super::{BoxRecord, LocalExecutionManager, ManagedExecutionState, RuntimeUpdate}; + +impl LocalExecutionManager { + pub(super) async fn observe_record( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult<(BoxRecord, ExecutionState)> { + let internal = managed_state(&record)?; + match internal { + ManagedExecutionState::Creating | ManagedExecutionState::Created => { + return Ok((record, ExecutionState::Created)); + } + ManagedExecutionState::Stopped => return Ok((record, ExecutionState::Stopped)), + ManagedExecutionState::Failed => return Ok((record, ExecutionState::Failed)), + ManagedExecutionState::RestartStopping => { + let id = execution_id(&record)?; + if matches!( + pending_restart_source_state(&record, &id)?, + ManagedExecutionState::Created + | ManagedExecutionState::Stopped + | ManagedExecutionState::Failed + ) { + return Ok((record, ExecutionState::Creating)); + } + } + _ => {} + } + let id = execution_id(&record)?; + let observation = match self.backend.inspect(&record).await { + Ok(observation) => observation, + Err(ExecutionManagerError::NotFound(_)) => { + if matches!( + internal, + ManagedExecutionState::Starting + | ManagedExecutionState::RestartStopping + | ManagedExecutionState::RestartStarting + ) { + return Ok((record, ExecutionState::Creating)); + } + let terminal = if internal == ManagedExecutionState::Killing { + ManagedExecutionState::Stopped + } else { + ManagedExecutionState::Failed + }; + self.release_execution_resources(&record).await?; + let record = self + .transition(&record, internal, terminal, RuntimeUpdate::Terminal(None)) + .await?; + let state = if terminal == ManagedExecutionState::Stopped { + ExecutionState::Stopped + } else { + ExecutionState::Failed + }; + return Ok((record, state)); + } + Err(error) => return Err(error), + }; + observation.validate(&id)?; + + match (internal, observation.state) { + (ManagedExecutionState::Starting, ExecutionState::Running) => { + let record = self + .complete_with_handle( + &record, + internal, + ManagedExecutionState::Running, + required_handle(&observation, &id)?, + ) + .await?; + Ok((record, ExecutionState::Running)) + } + (ManagedExecutionState::Starting, ExecutionState::Creating) => { + Ok((record, ExecutionState::Creating)) + } + (ManagedExecutionState::Pausing, ExecutionState::Paused) => { + let record = self + .complete_with_handle( + &record, + internal, + ManagedExecutionState::Paused, + required_handle(&observation, &id)?, + ) + .await?; + Ok((record, ExecutionState::Paused)) + } + (ManagedExecutionState::Pausing, ExecutionState::Running) + | (ManagedExecutionState::Pausing, ExecutionState::Creating) => { + Ok((record, ExecutionState::Running)) + } + (ManagedExecutionState::Resuming, ExecutionState::Running) => { + let record = self + .complete_with_handle( + &record, + internal, + ManagedExecutionState::Running, + required_handle(&observation, &id)?, + ) + .await?; + Ok((record, ExecutionState::Running)) + } + (ManagedExecutionState::Resuming, ExecutionState::Paused) + | (ManagedExecutionState::Resuming, ExecutionState::Creating) => { + Ok((record, ExecutionState::Paused)) + } + (ManagedExecutionState::Killing, ExecutionState::Running) => { + Ok((record, ExecutionState::Running)) + } + (ManagedExecutionState::Killing, ExecutionState::Paused) => { + Ok((record, ExecutionState::Paused)) + } + (ManagedExecutionState::Killing, ExecutionState::Creating) => { + Ok((record, ExecutionState::Creating)) + } + ( + ManagedExecutionState::RestartStopping, + state @ (ExecutionState::Running | ExecutionState::Paused), + ) => Ok((record, state)), + ( + ManagedExecutionState::RestartStopping, + ExecutionState::Created + | ExecutionState::Creating + | ExecutionState::Stopped + | ExecutionState::Failed, + ) => Ok((record, ExecutionState::Creating)), + (ManagedExecutionState::RestartStarting, ExecutionState::Running) => { + self.complete_restart_with_handle(&record, required_handle(&observation, &id)?) + .await?; + let current = self + .get(&id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(id.clone()))?; + Ok((current, ExecutionState::Running)) + } + ( + ManagedExecutionState::RestartStarting, + ExecutionState::Created | ExecutionState::Creating, + ) => Ok((record, ExecutionState::Creating)), + ( + ManagedExecutionState::RestartStarting, + ExecutionState::Stopped | ExecutionState::Failed, + ) => { + let record = self + .transition( + &record, + ManagedExecutionState::RestartStarting, + ManagedExecutionState::Failed, + RuntimeUpdate::RestartFailed(observation.exit_code), + ) + .await?; + Ok((record, ExecutionState::Failed)) + } + (ManagedExecutionState::Running, ExecutionState::Running) => { + Ok((record, ExecutionState::Running)) + } + (ManagedExecutionState::Paused, ExecutionState::Paused) => { + Ok((record, ExecutionState::Paused)) + } + (_, ExecutionState::Stopped) | (_, ExecutionState::Failed) => { + let target = if observation.state == ExecutionState::Stopped { + ManagedExecutionState::Stopped + } else { + ManagedExecutionState::Failed + }; + self.release_execution_resources(&record).await?; + let record = self + .transition( + &record, + internal, + target, + RuntimeUpdate::Terminal(observation.exit_code), + ) + .await?; + Ok((record, observation.state)) + } + _ => Err(ExecutionManagerError::Internal(format!( + "persisted state {internal} disagrees with backend state {:?} for {id}", + observation.state + ))), + } + } + + pub(super) async fn recover_start( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + let state = managed_state(&record)?; + let record = if state == ManagedExecutionState::Starting { + match self.backend.inspect(&record).await { + Err(ExecutionManagerError::NotFound(_)) => { + self.transition( + &record, + ManagedExecutionState::Starting, + ManagedExecutionState::Created, + RuntimeUpdate::None, + ) + .await? + } + Ok(observation) => { + let id = execution_id(&record)?; + observation.validate(&id)?; + if observation.state == ExecutionState::Running { + let running = self + .complete_with_handle( + &record, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + required_handle(&observation, &id)?, + ) + .await?; + return Ok(ReconcileOutcome::Ready(lease_from_record(&running)?)); + } + if observation.state == ExecutionState::Creating { + return Ok(ReconcileOutcome::Creating); + } + let failed = self + .transition( + &record, + ManagedExecutionState::Starting, + ManagedExecutionState::Failed, + RuntimeUpdate::Terminal(observation.exit_code), + ) + .await?; + return outcome_from_record(failed, ExecutionState::Failed); + } + Err(error) => return Err(error), + } + } else { + record + }; + self.claim_and_start(record, ManagedExecutionState::Created) + .await + .map(ReconcileOutcome::Ready) + } +} diff --git a/src/runtime/src/local_execution/resources.rs b/src/runtime/src/local_execution/resources.rs new file mode 100644 index 00000000..efbbedae --- /dev/null +++ b/src/runtime/src/local_execution/resources.rs @@ -0,0 +1,696 @@ +//! Host resource preparation for managed local execution startup. + +use std::path::{Path, PathBuf}; + +use a3s_box_core::{BoxError, ExecutionManagerError, ExecutionManagerResult, NetworkMode}; + +use crate::{BoxRecord, LocalExecutionManager, NetworkStore, VolumeStore}; + +/// Rolls back only the resource ownership acquired by one start attempt. +pub(super) struct ExecutionResourceGuard { + home_dir: PathBuf, + execution_id: String, + attached_volumes: Vec, + connected_network: Option, + snapshot_marker_created: bool, + armed: bool, +} + +impl ExecutionResourceGuard { + pub(super) fn prepare(home_dir: &Path, record: &BoxRecord) -> ExecutionManagerResult { + let mut guard = Self { + home_dir: home_dir.to_path_buf(), + execution_id: record.id.clone(), + attached_volumes: Vec::new(), + connected_network: None, + snapshot_marker_created: false, + armed: true, + }; + + guard.prepare_snapshot_lower(record)?; + + let volume_store = + VolumeStore::new(home_dir.join("volumes.json"), home_dir.join("volumes")); + for volume_name in &record.volume_names { + let volume = volume_store + .get(volume_name) + .map_err(|error| resource_error(record, "load named volume", error))? + .ok_or_else(|| { + ExecutionManagerError::Unavailable(format!( + "named volume '{volume_name}' required by execution {} was not found", + record.id + )) + })?; + if volume.in_use_by.iter().any(|id| id == &record.id) { + continue; + } + let attached = volume_store + .modify(volume_name, |volume| volume.attach(&record.id)) + .map_err(|error| resource_error(record, "attach named volume", error))?; + if !attached { + return Err(ExecutionManagerError::Unavailable(format!( + "named volume '{volume_name}' required by execution {} disappeared during startup", + record.id + ))); + } + guard.attached_volumes.push(volume_name.clone()); + } + + if let Some(network_name) = network_name(record) { + let network_store = NetworkStore::new(home_dir.join("networks.json")); + let connected = network_store + .with_write_lock(|networks| -> Result { + let network = networks.get_mut(network_name).ok_or_else(|| { + BoxError::NetworkError(format!("network '{network_name}' not found")) + })?; + network.validate_runtime().map_err(BoxError::NetworkError)?; + if network.endpoints.contains_key(&record.id) { + return Ok(false); + } + network + .connect(&record.id, &record.name) + .map_err(BoxError::NetworkError)?; + Ok(true) + }) + .map_err(|error| resource_error(record, "connect network", error))?; + if connected { + guard.connected_network = Some(network_name.to_string()); + } + } + + Ok(guard) + } + + pub(super) fn disarm(mut self) { + self.armed = false; + } + + pub(super) fn rollback(mut self) { + self.rollback_inner(); + } + + fn rollback_inner(&mut self) { + if !self.armed { + return; + } + + let volume_store = VolumeStore::new( + self.home_dir.join("volumes.json"), + self.home_dir.join("volumes"), + ); + for volume_name in &self.attached_volumes { + if let Err(error) = volume_store.modify(volume_name, |volume| { + volume.detach(&self.execution_id); + }) { + tracing::warn!( + execution_id = %self.execution_id, + volume = %volume_name, + %error, + "Failed to roll back managed volume attachment" + ); + } + } + + if let Some(network_name) = self.connected_network.as_deref() { + let network_store = NetworkStore::new(self.home_dir.join("networks.json")); + if let Err(error) = network_store.with_write_lock(|networks| -> Result<(), BoxError> { + if let Some(network) = networks.get_mut(network_name) { + let _ = network.disconnect(&self.execution_id); + } + Ok(()) + }) { + tracing::warn!( + execution_id = %self.execution_id, + network = %network_name, + %error, + "Failed to roll back managed network attachment" + ); + } + } + + if self.snapshot_marker_created { + let marker = self + .home_dir + .join("boxes") + .join(&self.execution_id) + .join(".snapshot-lower"); + if let Err(error) = std::fs::remove_file(&marker) { + if error.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + execution_id = %self.execution_id, + path = %marker.display(), + %error, + "Failed to roll back managed snapshot marker" + ); + } + } + } + + self.armed = false; + } +} + +impl ExecutionResourceGuard { + fn prepare_snapshot_lower(&mut self, record: &BoxRecord) -> ExecutionManagerResult<()> { + let Some(snapshot_id) = record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.request.rootfs_snapshot_id.as_ref()) + else { + return Ok(()); + }; + let snapshots_root = self.home_dir.join("snapshots"); + let canonical_root = snapshots_root + .canonicalize() + .map_err(|error| resource_error(record, "canonicalize managed snapshot root", error))?; + let snapshot_dir = snapshots_root.join(snapshot_id.as_str()); + let canonical_snapshot = snapshot_dir + .canonicalize() + .map_err(|error| resource_error(record, "resolve managed snapshot", error))?; + if canonical_snapshot.parent() != Some(canonical_root.as_path()) { + return Err(ExecutionManagerError::Unavailable(format!( + "filesystem snapshot '{snapshot_id}' is not a published managed snapshot" + ))); + } + let metadata_path = canonical_snapshot.join("metadata.json"); + if std::fs::symlink_metadata(&metadata_path) + .map_err(|error| resource_error(record, "inspect managed snapshot metadata", error))? + .file_type() + .is_symlink() + { + return Err(ExecutionManagerError::Unavailable(format!( + "filesystem snapshot '{snapshot_id}' has unsafe metadata" + ))); + } + let metadata_file = metadata_path + .canonicalize() + .map_err(|error| resource_error(record, "resolve managed snapshot metadata", error))?; + let metadata: a3s_box_core::snapshot::SnapshotMetadata = + serde_json::from_slice(&std::fs::read(&metadata_file).map_err(|error| { + resource_error(record, "read managed snapshot metadata", error) + })?) + .map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "filesystem snapshot '{snapshot_id}' has invalid metadata: {error}" + )) + })?; + if metadata_file.parent() != Some(canonical_snapshot.as_path()) + || metadata.id != snapshot_id.as_str() + { + return Err(ExecutionManagerError::Unavailable(format!( + "filesystem snapshot '{snapshot_id}' has inconsistent metadata" + ))); + } + let rootfs_path = canonical_snapshot.join("rootfs"); + if std::fs::symlink_metadata(&rootfs_path) + .map_err(|error| resource_error(record, "inspect managed snapshot rootfs", error))? + .file_type() + .is_symlink() + { + return Err(ExecutionManagerError::Unavailable(format!( + "filesystem snapshot '{snapshot_id}' has an unsafe rootfs" + ))); + } + let rootfs = rootfs_path + .canonicalize() + .map_err(|error| resource_error(record, "resolve managed snapshot rootfs", error))?; + if rootfs.parent() != Some(canonical_snapshot.as_path()) || !rootfs.is_dir() { + return Err(ExecutionManagerError::Unavailable(format!( + "filesystem snapshot '{snapshot_id}' has no rootfs" + ))); + } + + let boxes_root = self.home_dir.join("boxes"); + std::fs::create_dir_all(&boxes_root) + .map_err(|error| resource_error(record, "create managed boxes root", error))?; + let canonical_boxes_root = boxes_root + .canonicalize() + .map_err(|error| resource_error(record, "resolve managed boxes root", error))?; + let box_dir = boxes_root.join(&record.id); + std::fs::create_dir_all(&box_dir) + .map_err(|error| resource_error(record, "create managed box directory", error))?; + let canonical_box_dir = box_dir + .canonicalize() + .map_err(|error| resource_error(record, "resolve managed box directory", error))?; + if canonical_box_dir.parent() != Some(canonical_boxes_root.as_path()) { + return Err(ExecutionManagerError::Unavailable(format!( + "execution {} has an unsafe managed box directory", + record.id + ))); + } + let marker = box_dir.join(".snapshot-lower"); + let expected = rootfs.to_string_lossy().into_owned(); + if marker.exists() { + let marker_type = std::fs::symlink_metadata(&marker) + .map_err(|error| resource_error(record, "inspect snapshot marker", error))? + .file_type(); + if !marker_type.is_file() || marker_type.is_symlink() { + return Err(ExecutionManagerError::Unavailable(format!( + "execution {} has an unsafe filesystem snapshot marker", + record.id + ))); + } + let current = std::fs::read_to_string(&marker) + .map_err(|error| resource_error(record, "read snapshot marker", error))?; + if current.trim() != expected { + return Err(ExecutionManagerError::Unavailable(format!( + "execution {} has a conflicting filesystem snapshot marker", + record.id + ))); + } + return Ok(()); + } + let temporary = box_dir.join(format!( + ".snapshot-lower.{}.tmp", + uuid::Uuid::new_v4().simple() + )); + a3s_box_core::fs_atomic::write_durable(&temporary, &marker, expected.as_bytes()) + .map_err(|error| resource_error(record, "write snapshot marker", error))?; + self.snapshot_marker_created = true; + Ok(()) + } +} + +impl Drop for ExecutionResourceGuard { + fn drop(&mut self) { + self.rollback_inner(); + } +} + +impl LocalExecutionManager { + pub(super) async fn release_execution_resources( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult<()> { + let home_dir = self.home_dir.clone(); + let execution_id = record.id.clone(); + let record = record.clone(); + tokio::task::spawn_blocking(move || release_resources(&home_dir, &record)) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!( + "managed resource cleanup task failed for {}: {error}", + execution_id + )) + })? + } +} + +fn release_resources(home_dir: &Path, record: &BoxRecord) -> ExecutionManagerResult<()> { + let volume_store = VolumeStore::new(home_dir.join("volumes.json"), home_dir.join("volumes")); + for volume_name in &record.volume_names { + volume_store + .modify(volume_name, |volume| volume.detach(&record.id)) + .map_err(|error| resource_error(record, "detach named volume", error))?; + } + + if let Some(network_name) = network_name(record) { + let network_store = NetworkStore::new(home_dir.join("networks.json")); + network_store + .with_write_lock(|networks| -> Result<(), BoxError> { + if let Some(network) = networks.get_mut(network_name) { + let _ = network.disconnect(&record.id); + } + Ok(()) + }) + .map_err(|error| resource_error(record, "disconnect network", error))?; + } + Ok(()) +} + +fn network_name(record: &BoxRecord) -> Option<&str> { + record + .network_name + .as_deref() + .or(match &record.network_mode { + NetworkMode::Bridge { network } => Some(network.as_str()), + NetworkMode::Tsi | NetworkMode::None => None, + }) +} + +fn resource_error( + record: &BoxRecord, + operation: &str, + error: impl std::fmt::Display, +) -> ExecutionManagerError { + ExecutionManagerError::Unavailable(format!( + "failed to {operation} for execution {}: {error}", + record.id + )) +} + +#[cfg(test)] +mod tests { + use a3s_box_core::{ + network::NetworkConfig, snapshot::SnapshotMetadata, volume::VolumeConfig, + CreateExecutionRequest, ExecutionGeneration, ExecutionIsolation, ExecutionSnapshotId, + OperationId, + }; + + use super::*; + + fn record(home_dir: &Path) -> BoxRecord { + let id = "11111111-1111-4111-8111-111111111111"; + let mut record: BoxRecord = serde_json::from_value(serde_json::json!({ + "id": id, + "short_id": "11111111", + "name": "managed-resources", + "image": "alpine:latest", + "status": "created", + "pid": null, + "cpus": 1, + "memory_mb": 128, + "volumes": [], + "env": {}, + "cmd": ["sleep", "60"], + "box_dir": home_dir.join("boxes").join(id), + "console_log": home_dir.join("boxes").join(id).join("logs/console.log"), + "created_at": "2026-07-15T00:00:00Z", + "started_at": null, + "auto_remove": false + })) + .unwrap(); + record.volume_names = vec!["workspace".to_string()]; + record.network_mode = NetworkMode::Bridge { + network: "dev".to_string(), + }; + record.network_name = Some("dev".to_string()); + record + } + + fn stores(home_dir: &Path) -> (VolumeStore, NetworkStore) { + let volumes = VolumeStore::new(home_dir.join("volumes.json"), home_dir.join("volumes")); + volumes.create(VolumeConfig::new("workspace", "")).unwrap(); + let networks = NetworkStore::new(home_dir.join("networks.json")); + networks + .create(NetworkConfig::new("dev", "10.88.0.0/24").unwrap()) + .unwrap(); + (volumes, networks) + } + + fn snapshot_record(home_dir: &Path, snapshot_id: &str) -> BoxRecord { + let mut record = record(home_dir); + record.volume_names.clear(); + record.network_mode = NetworkMode::None; + record.network_name = None; + let config = a3s_box_core::BoxConfig { + image: record.image.clone(), + isolation: ExecutionIsolation::Sandbox, + ..Default::default() + }; + record.isolation = ExecutionIsolation::Sandbox; + record.managed_execution = Some( + crate::ManagedExecutionMetadata::new( + OperationId::new("snapshot-restore-operation").unwrap(), + ExecutionGeneration::INITIAL, + CreateExecutionRequest { + external_sandbox_id: "snapshot-restore".to_string(), + config, + labels: Default::default(), + policy: Default::default(), + rootfs_snapshot_id: Some(ExecutionSnapshotId::new(snapshot_id).unwrap()), + }, + ) + .unwrap(), + ); + record + } + + fn create_snapshot(home_dir: &Path, snapshot_id: &str) -> PathBuf { + let source = home_dir.join("snapshot-source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("state.txt"), "safe-state").unwrap(); + let metadata = SnapshotMetadata::new( + snapshot_id.to_string(), + snapshot_id.to_string(), + "source-execution".to_string(), + "alpine:latest".to_string(), + ); + crate::SnapshotStore::new(&home_dir.join("snapshots")) + .unwrap() + .save(metadata, &source) + .unwrap(); + home_dir.join("snapshots").join(snapshot_id).join("rootfs") + } + + #[test] + fn failed_start_rolls_back_only_resources_acquired_by_that_attempt() { + let temporary = tempfile::tempdir().unwrap(); + let record = record(temporary.path()); + let (volumes, networks) = stores(temporary.path()); + + let guard = ExecutionResourceGuard::prepare(temporary.path(), &record).unwrap(); + assert_eq!( + volumes.get("workspace").unwrap().unwrap().in_use_by, + vec![record.id.clone()] + ); + assert!(networks + .get("dev") + .unwrap() + .unwrap() + .endpoints + .contains_key(&record.id)); + + drop(guard); + + assert!(volumes + .get("workspace") + .unwrap() + .unwrap() + .in_use_by + .is_empty()); + assert!(!networks + .get("dev") + .unwrap() + .unwrap() + .endpoints + .contains_key(&record.id)); + } + + #[test] + fn preexisting_resource_ownership_survives_start_rollback() { + let temporary = tempfile::tempdir().unwrap(); + let record = record(temporary.path()); + let (volumes, networks) = stores(temporary.path()); + volumes + .modify("workspace", |volume| volume.attach(&record.id)) + .unwrap(); + networks + .with_write_lock(|entries| -> Result<(), BoxError> { + entries + .get_mut("dev") + .unwrap() + .connect(&record.id, &record.name) + .map_err(BoxError::NetworkError)?; + Ok(()) + }) + .unwrap(); + + drop(ExecutionResourceGuard::prepare(temporary.path(), &record).unwrap()); + + assert_eq!( + volumes.get("workspace").unwrap().unwrap().in_use_by, + vec![record.id.clone()] + ); + assert!(networks + .get("dev") + .unwrap() + .unwrap() + .endpoints + .contains_key(&record.id)); + } + + #[test] + fn successful_start_keeps_prepared_resources() { + let temporary = tempfile::tempdir().unwrap(); + let record = record(temporary.path()); + let (volumes, networks) = stores(temporary.path()); + + ExecutionResourceGuard::prepare(temporary.path(), &record) + .unwrap() + .disarm(); + + assert_eq!( + volumes.get("workspace").unwrap().unwrap().in_use_by, + vec![record.id.clone()] + ); + assert!(networks + .get("dev") + .unwrap() + .unwrap() + .endpoints + .contains_key(&record.id)); + } + + #[test] + fn snapshot_marker_is_canonical_atomic_and_rolled_back_with_the_start_attempt() { + let temporary = tempfile::tempdir().unwrap(); + let snapshot_id = "managed-snapshot"; + let expected = create_snapshot(temporary.path(), snapshot_id) + .canonicalize() + .unwrap(); + let record = snapshot_record(temporary.path(), snapshot_id); + let marker = record.box_dir.join(".snapshot-lower"); + + let guard = ExecutionResourceGuard::prepare(temporary.path(), &record).unwrap(); + assert_eq!( + PathBuf::from(std::fs::read_to_string(&marker).unwrap()), + expected + ); + assert!(std::fs::symlink_metadata(&marker) + .unwrap() + .file_type() + .is_file()); + drop(guard); + assert!(!marker.exists()); + + ExecutionResourceGuard::prepare(temporary.path(), &record) + .unwrap() + .disarm(); + assert_eq!( + PathBuf::from(std::fs::read_to_string(marker).unwrap()), + expected + ); + } + + #[test] + fn snapshot_restore_rejects_a_conflicting_existing_marker() { + let temporary = tempfile::tempdir().unwrap(); + let snapshot_id = "managed-snapshot"; + create_snapshot(temporary.path(), snapshot_id); + let record = snapshot_record(temporary.path(), snapshot_id); + std::fs::create_dir_all(&record.box_dir).unwrap(); + std::fs::write(record.box_dir.join(".snapshot-lower"), "/tmp/untrusted").unwrap(); + + assert!(matches!( + ExecutionResourceGuard::prepare(temporary.path(), &record), + Err(ExecutionManagerError::Unavailable(_)) + )); + } + + #[cfg(unix)] + #[test] + fn snapshot_restore_rejects_a_symlinked_rootfs_escape() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().unwrap(); + let snapshot_id = "managed-snapshot"; + let snapshot_dir = temporary.path().join("snapshots").join(snapshot_id); + let outside = temporary.path().join("outside-rootfs"); + std::fs::create_dir_all(&snapshot_dir).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let metadata = SnapshotMetadata::new( + snapshot_id.to_string(), + snapshot_id.to_string(), + "source-execution".to_string(), + "alpine:latest".to_string(), + ); + std::fs::write( + snapshot_dir.join("metadata.json"), + serde_json::to_vec(&metadata).unwrap(), + ) + .unwrap(); + symlink(&outside, snapshot_dir.join("rootfs")).unwrap(); + let record = snapshot_record(temporary.path(), snapshot_id); + + assert!(matches!( + ExecutionResourceGuard::prepare(temporary.path(), &record), + Err(ExecutionManagerError::Unavailable(_)) + )); + assert!(!record.box_dir.join(".snapshot-lower").exists()); + } + + #[test] + fn terminal_release_detaches_all_record_resources() { + let temporary = tempfile::tempdir().unwrap(); + let record = record(temporary.path()); + let (volumes, networks) = stores(temporary.path()); + ExecutionResourceGuard::prepare(temporary.path(), &record) + .unwrap() + .disarm(); + + release_resources(temporary.path(), &record).unwrap(); + + assert!(volumes + .get("workspace") + .unwrap() + .unwrap() + .in_use_by + .is_empty()); + assert!(!networks + .get("dev") + .unwrap() + .unwrap() + .endpoints + .contains_key(&record.id)); + } + + #[test] + fn restart_release_and_prepare_rebind_resources_exactly_once() { + let temporary = tempfile::tempdir().unwrap(); + let record = record(temporary.path()); + let (volumes, networks) = stores(temporary.path()); + ExecutionResourceGuard::prepare(temporary.path(), &record) + .unwrap() + .disarm(); + + release_resources(temporary.path(), &record).unwrap(); + ExecutionResourceGuard::prepare(temporary.path(), &record) + .unwrap() + .disarm(); + + assert_eq!( + volumes.get("workspace").unwrap().unwrap().in_use_by, + vec![record.id.clone()] + ); + let network = networks.get("dev").unwrap().unwrap(); + assert_eq!( + network + .endpoints + .keys() + .filter(|execution_id| execution_id.as_str() == record.id) + .count(), + 1 + ); + } + + #[test] + fn concurrent_preparation_allocates_distinct_network_endpoints() { + use std::collections::HashSet; + use std::sync::{Arc, Barrier}; + + let temporary = tempfile::tempdir().unwrap(); + let home_dir = temporary.path().to_path_buf(); + let (_volumes, networks) = stores(&home_dir); + let barrier = Arc::new(Barrier::new(16)); + let handles = (0..16) + .map(|index| { + let home_dir = home_dir.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + let mut record = record(&home_dir); + record.id = format!("00000000-0000-4000-8000-{index:012}"); + record.name = format!("worker-{index}"); + record.volume_names.clear(); + barrier.wait(); + ExecutionResourceGuard::prepare(&home_dir, &record) + .unwrap() + .disarm(); + }) + }) + .collect::>(); + + for handle in handles { + handle.join().unwrap(); + } + + let network = networks.get("dev").unwrap().unwrap(); + let addresses = network + .endpoints + .values() + .map(|endpoint| endpoint.ip_address) + .collect::>(); + assert_eq!(network.endpoints.len(), 16); + assert_eq!(addresses.len(), 16); + } +} diff --git a/src/runtime/src/local_execution/restart.rs b/src/runtime/src/local_execution/restart.rs new file mode 100644 index 00000000..df79d76d --- /dev/null +++ b/src/runtime/src/local_execution/restart.rs @@ -0,0 +1,480 @@ +use a3s_box_core::{ + ExecutionGeneration, ExecutionLease, ExecutionManagerError, ExecutionManagerResult, + ExecutionState, OperationId, RestartExecutionOptions, +}; + +use super::record::{execution_id, lease_from_record}; +use super::store::RuntimeUpdate; +use super::support::{generation, managed_state, require_generation, required_handle}; +use super::{ + BoxRecord, LocalExecutionHandle, LocalExecutionManager, ManagedExecutionOperation, + ManagedExecutionState, +}; +use crate::ManagedRestartOutcome; + +#[derive(Clone)] +struct RestartIntent { + operation_id: OperationId, + source_generation: ExecutionGeneration, + source_state: ManagedExecutionState, + stop_timeout_secs: Option, +} + +impl RestartIntent { + const fn options(&self) -> RestartExecutionOptions { + RestartExecutionOptions { + stop_timeout_secs: self.stop_timeout_secs, + } + } +} + +impl LocalExecutionManager { + pub(super) async fn restart_record( + &self, + record: BoxRecord, + expected_generation: ExecutionGeneration, + operation_id: &OperationId, + options: RestartExecutionOptions, + ) -> ExecutionManagerResult { + let record = self.stabilize_snapshot(record).await?; + if let Some(result) = + completed_restart_result(&record, expected_generation, operation_id, options)? + { + return result; + } + if record + .managed_execution + .as_ref() + .is_some_and(|metadata| metadata.operation_id == *operation_id) + { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id(&record)?, + message: format!( + "operation {operation_id} is already the execution creation identity" + ), + }); + } + + match managed_state(&record)? { + ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting => { + require_matching_restart(&record, expected_generation, operation_id, options)?; + self.continue_restart(record).await + } + state @ (ManagedExecutionState::Created + | ManagedExecutionState::Running + | ManagedExecutionState::Paused + | ManagedExecutionState::Stopped + | ManagedExecutionState::Failed) => { + let id = execution_id(&record)?; + require_generation(&record, &id, expected_generation)?; + ensure_restart_generation_available(&id, expected_generation)?; + ensure_restart_timeout_valid( + &id, + options.stop_timeout_secs.or(record.stop_timeout), + )?; + let claimed = match self + .transition( + &record, + state, + ManagedExecutionState::RestartStopping, + RuntimeUpdate::RestartClaim { + operation_id: operation_id.clone(), + options, + }, + ) + .await + { + Ok(claimed) => claimed, + Err(ExecutionManagerError::Conflict { .. }) => { + let current = self + .get(&id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(id.clone()))?; + if let Some(result) = completed_restart_result( + ¤t, + expected_generation, + operation_id, + options, + )? { + return result; + } + require_matching_restart( + ¤t, + expected_generation, + operation_id, + options, + )?; + return self.continue_restart(current).await; + } + Err(error) => return Err(error), + }; + self.continue_restart(claimed).await + } + state => Err(ExecutionManagerError::Conflict { + execution_id: execution_id(&record)?, + message: format!("cannot restart execution in state {state}"), + }), + } + } + + pub(super) async fn resume_restart( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + restart_intent(&record)?; + self.continue_restart(record).await + } + + async fn continue_restart(&self, record: BoxRecord) -> ExecutionManagerResult { + match managed_state(&record)? { + ManagedExecutionState::RestartStopping => self.finish_restart_stop(record).await, + ManagedExecutionState::RestartStarting => self.finish_restart_start(record).await, + state => Err(ExecutionManagerError::Internal(format!( + "restart continuation reached state {state} for {}", + record.id + ))), + } + } + + async fn finish_restart_stop( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + let intent = restart_intent(&record)?; + if matches!( + intent.source_state, + ManagedExecutionState::Running | ManagedExecutionState::Paused + ) { + self.confirm_restart_kill(&record, intent.stop_timeout_secs) + .await?; + } + self.release_execution_resources(&record).await?; + + let id = execution_id(&record)?; + let starting = match self + .transition( + &record, + ManagedExecutionState::RestartStopping, + ManagedExecutionState::RestartStarting, + RuntimeUpdate::RestartAdvance, + ) + .await + { + Ok(starting) => starting, + Err(ExecutionManagerError::Conflict { .. }) => { + let current = self + .get(&id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(id.clone()))?; + if let Some(result) = completed_restart_result( + ¤t, + intent.source_generation, + &intent.operation_id, + intent.options(), + )? { + return result; + } + require_matching_restart( + ¤t, + intent.source_generation, + &intent.operation_id, + intent.options(), + )?; + if managed_state(¤t)? != ManagedExecutionState::RestartStarting { + return Err(ExecutionManagerError::Unavailable(format!( + "restart teardown for {id} is owned by another caller" + ))); + } + current + } + Err(error) => return Err(error), + }; + self.finish_restart_start(starting).await + } + + async fn confirm_restart_kill( + &self, + record: &BoxRecord, + stop_timeout_secs: Option, + ) -> ExecutionManagerResult<()> { + match self + .backend + .stop_for_restart(record, stop_timeout_secs) + .await + { + Ok(_) | Err(ExecutionManagerError::NotFound(_)) => Ok(()), + Err(error) => { + let terminal = match self.backend.inspect(record).await { + Err(ExecutionManagerError::NotFound(_)) => true, + Ok(observation) => matches!( + observation.state, + ExecutionState::Stopped | ExecutionState::Failed + ), + Err(_) => false, + }; + if terminal { + Ok(()) + } else { + Err(error) + } + } + } + } + + async fn finish_restart_start( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + let id = execution_id(&record)?; + match self.backend.start(&record).await { + Ok(handle) => { + handle.validate(&id)?; + self.complete_restart_with_handle(&record, handle).await + } + Err(error) => self.resolve_restart_start_error(record, error).await, + } + } + + pub(super) async fn complete_restart_with_handle( + &self, + record: &BoxRecord, + handle: LocalExecutionHandle, + ) -> ExecutionManagerResult { + let intent = restart_intent(record)?; + let id = execution_id(record)?; + match self + .transition( + record, + ManagedExecutionState::RestartStarting, + ManagedExecutionState::Running, + RuntimeUpdate::RestartHandle(handle), + ) + .await + { + Ok(running) => lease_from_record(&running), + Err(error @ ExecutionManagerError::Conflict { .. }) => { + let current = self + .get(&id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(id.clone()))?; + match completed_restart_result( + ¤t, + intent.source_generation, + &intent.operation_id, + intent.options(), + )? { + Some(result) => result, + None => Err(error), + } + } + Err(error) => Err(error), + } + } + + async fn resolve_restart_start_error( + &self, + record: BoxRecord, + start_error: ExecutionManagerError, + ) -> ExecutionManagerResult { + let id = execution_id(&record)?; + match self.backend.inspect(&record).await { + Ok(observation) => { + observation.validate(&id)?; + match observation.state { + ExecutionState::Running => { + self.complete_restart_with_handle( + &record, + required_handle(&observation, &id)?, + ) + .await + } + ExecutionState::Stopped | ExecutionState::Failed => { + self.publish_restart_failure(&record, observation.exit_code) + .await?; + Err(start_error) + } + ExecutionState::Created | ExecutionState::Creating | ExecutionState::Paused => { + Err(start_error) + } + } + } + Err(ExecutionManagerError::NotFound(_)) => { + self.publish_restart_failure(&record, None).await?; + Err(start_error) + } + Err(_) => Err(start_error), + } + } + + async fn publish_restart_failure( + &self, + record: &BoxRecord, + exit_code: Option, + ) -> ExecutionManagerResult<()> { + self.release_execution_resources(record).await?; + let intent = restart_intent(record)?; + let id = execution_id(record)?; + match self + .transition( + record, + ManagedExecutionState::RestartStarting, + ManagedExecutionState::Failed, + RuntimeUpdate::RestartFailed(exit_code), + ) + .await + { + Ok(_) => Ok(()), + Err(error @ ExecutionManagerError::Conflict { .. }) => { + let current = self + .get(&id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(id.clone()))?; + if completed_restart_result( + ¤t, + intent.source_generation, + &intent.operation_id, + intent.options(), + )? + .is_some() + { + Ok(()) + } else { + Err(error) + } + } + Err(error) => Err(error), + } + } +} + +fn ensure_restart_generation_available( + execution_id: &a3s_box_core::ExecutionId, + generation: ExecutionGeneration, +) -> ExecutionManagerResult<()> { + if generation.get().checked_add(1).is_some() { + Ok(()) + } else { + Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "execution generation is exhausted".to_string(), + }) + } +} + +fn ensure_restart_timeout_valid( + execution_id: &a3s_box_core::ExecutionId, + timeout_secs: Option, +) -> ExecutionManagerResult<()> { + if timeout_secs.is_some_and(|timeout| timeout.checked_mul(1_000).is_none()) { + Err(ExecutionManagerError::InvalidRequest(format!( + "restart timeout is too large for execution {execution_id}" + ))) + } else { + Ok(()) + } +} + +fn restart_intent(record: &BoxRecord) -> ExecutionManagerResult { + let id = execution_id(record)?; + match record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.pending_operation.as_ref()) + { + Some(ManagedExecutionOperation::Restart { + operation_id, + source_generation, + source_state, + stop_timeout_secs, + }) => Ok(RestartIntent { + operation_id: operation_id.clone(), + source_generation: *source_generation, + source_state: *source_state, + stop_timeout_secs: *stop_timeout_secs, + }), + _ => Err(ExecutionManagerError::Internal(format!( + "restarting execution {id} has no persisted restart intent" + ))), + } +} + +fn require_matching_restart( + record: &BoxRecord, + expected_generation: ExecutionGeneration, + operation_id: &OperationId, + options: RestartExecutionOptions, +) -> ExecutionManagerResult<()> { + let id = execution_id(record)?; + let intent = restart_intent(record)?; + if intent.operation_id == *operation_id + && intent.source_generation == expected_generation + && intent.stop_timeout_secs == options.stop_timeout_secs + { + Ok(()) + } else { + Err(ExecutionManagerError::Conflict { + execution_id: id, + message: format!( + "restart is already owned by operation {} from generation {}", + intent.operation_id, + intent.source_generation.get() + ), + }) + } +} + +fn completed_restart_result( + record: &BoxRecord, + expected_generation: ExecutionGeneration, + operation_id: &OperationId, + options: RestartExecutionOptions, +) -> ExecutionManagerResult>> { + let id = execution_id(record)?; + let Some(completed) = record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.last_restart.as_ref()) + .filter(|completed| completed.operation_id == *operation_id) + else { + return Ok(None); + }; + if completed.source_generation != expected_generation { + return Ok(Some(Err(ExecutionManagerError::Conflict { + execution_id: id, + message: format!( + "restart operation {operation_id} belongs to generation {}, not {}", + completed.source_generation.get(), + expected_generation.get() + ), + }))); + } + if completed.stop_timeout_secs != options.stop_timeout_secs { + return Ok(Some(Err(ExecutionManagerError::Conflict { + execution_id: id, + message: format!( + "restart operation {operation_id} was retried with different stop options" + ), + }))); + } + if completed.outcome == ManagedRestartOutcome::Failed { + return Ok(Some(Err(ExecutionManagerError::Conflict { + execution_id: id, + message: format!( + "restart operation {operation_id} failed at generation {}", + completed.target_generation.get() + ), + }))); + } + let current_generation = generation(record, &id)?; + if managed_state(record)? == ManagedExecutionState::Running + && current_generation == completed.target_generation + { + return Ok(Some(lease_from_record(record))); + } + Ok(Some(Err(ExecutionManagerError::Conflict { + execution_id: id, + message: format!( + "restart operation {operation_id} completed, but the execution has since moved" + ), + }))) +} diff --git a/src/runtime/src/local_execution/session.rs b/src/runtime/src/local_execution/session.rs new file mode 100644 index 00000000..b7f767ab --- /dev/null +++ b/src/runtime/src/local_execution/session.rs @@ -0,0 +1,361 @@ +//! Generation-fenced command, PTY, and file sessions. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use a3s_box_core::pty::PtyRequest; +use a3s_box_core::{ + BoxError, ExecEvent, ExecOutput, ExecRequest, ExecutionGeneration, ExecutionId, + ExecutionManagerError, ExecutionManagerResult, ExecutionProcess, ExecutionProcessInput, + ExecutionProcessStream, ExecutionSessionManager, FileRequest, FileResponse, +}; +use async_trait::async_trait; + +use super::LocalExecutionManager; +use crate::{ + BoxRecord, ExecClient, PtyClient, StreamingExec, StreamingExecInput, StreamingPty, + StreamingPtyInput, +}; + +#[async_trait] +impl ExecutionSessionManager for LocalExecutionManager { + async fn execute( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + mut request: ExecRequest, + ) -> ExecutionManagerResult { + request.streaming = false; + let (record, client, stream) = self.bind_exec(execution_id, generation).await?; + inherit_container_environment(&record.env, &mut request.env); + debug_session_environment( + execution_id, + generation, + "execute", + &record.env, + &request.env, + ); + client + .exec_command_on_stream(stream, &request) + .await + .map_err(|error| session_error(execution_id, "execute command", error)) + } + + async fn start_process( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + mut request: ExecRequest, + ) -> ExecutionManagerResult { + let (record, client, stream) = self.bind_exec(execution_id, generation).await?; + inherit_container_environment(&record.env, &mut request.env); + debug_session_environment( + execution_id, + generation, + "start_process", + &record.env, + &request.env, + ); + let stream = client + .exec_stream_on_stream(stream, &request) + .await + .map_err(|error| session_error(execution_id, "start command", error))?; + let input: Arc = Arc::new(ExecInput { + execution_id: execution_id.clone(), + input: stream.input(), + }); + Ok(Box::new(ExecStream { stream, input })) + } + + async fn start_pty( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + mut request: PtyRequest, + ) -> ExecutionManagerResult { + let record = self + .require_running_record(execution_id, generation) + .await?; + inherit_container_environment(&record.env, &mut request.env); + debug_session_environment( + execution_id, + generation, + "start_pty", + &record.env, + &request.env, + ); + let socket_path = record.exec_socket_path.with_file_name("pty.sock"); + let client = PtyClient::connect(&socket_path) + .await + .map_err(|error| session_error(execution_id, "connect PTY", error))?; + self.require_same_runtime(&record, execution_id, generation) + .await?; + let stream = client + .start_stream(&request) + .await + .map_err(|error| session_error(execution_id, "start PTY", error))?; + let input: Arc = Arc::new(PtyInput { + execution_id: execution_id.clone(), + input: stream.input(), + }); + Ok(Box::new(PtyStream { stream, input })) + } + + async fn transfer_file( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + request: FileRequest, + ) -> ExecutionManagerResult { + let (_record, client, stream) = self.bind_exec(execution_id, generation).await?; + client + .file_transfer_on_stream(stream, &request) + .await + .map_err(|error| session_error(execution_id, "transfer file", error)) + } +} + +impl LocalExecutionManager { + async fn bind_exec( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult<(BoxRecord, ExecClient, tokio::net::UnixStream)> { + let record = self + .require_running_record(execution_id, generation) + .await?; + let client = ExecClient::for_socket(&record.exec_socket_path); + let stream = client + .open_stream() + .await + .map_err(|error| session_error(execution_id, "connect exec", error))?; + self.require_same_runtime(&record, execution_id, generation) + .await?; + Ok((record, client, stream)) + } + + async fn require_same_runtime( + &self, + bound: &BoxRecord, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> ExecutionManagerResult<()> { + let current = self + .require_running_record(execution_id, generation) + .await?; + if current.pid != bound.pid + || current.pid_start_time != bound.pid_start_time + || current.exec_socket_path != bound.exec_socket_path + { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "runtime generation changed while binding its execution session" + .to_string(), + }); + } + Ok(()) + } +} + +/// Merge the environment fixed at Sandbox creation into an exec/PTY request. +/// +/// The guest normally inherits these values from guest-init, but the execution +/// contract must not depend on which user a later request selects. Per-request +/// values remain authoritative, matching OCI/Docker exec environment semantics. +fn inherit_container_environment(container: &HashMap, request: &mut Vec) { + let mut merged: BTreeMap = container + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + let mut malformed = Vec::new(); + for entry in std::mem::take(request) { + if let Some((key, value)) = entry.split_once('=') { + merged.insert(key.to_string(), value.to_string()); + } else { + malformed.push(entry); + } + } + request.extend( + merged + .into_iter() + .map(|(key, value)| format!("{key}={value}")), + ); + request.extend(malformed); +} + +/// Record only environment key names at the final host-to-runtime boundary. +/// +/// Values are deliberately never included because creation and per-request +/// environments may contain credentials. This log is useful when diagnosing a +/// persisted lifecycle record that reaches a later exec or PTY generation. +fn debug_session_environment( + execution_id: &ExecutionId, + generation: ExecutionGeneration, + operation: &str, + container: &HashMap, + request: &[String], +) { + let mut container_keys: Vec<&str> = container.keys().map(String::as_str).collect(); + container_keys.sort_unstable(); + let mut request_keys: Vec<&str> = request + .iter() + .filter_map(|entry| entry.split_once('=').map(|(key, _)| key)) + .collect(); + request_keys.sort_unstable(); + request_keys.dedup(); + let malformed_request_entries = request.iter().filter(|entry| !entry.contains('=')).count(); + + tracing::debug!( + %execution_id, + generation = generation.get(), + operation, + container_env_count = container.len(), + container_env_keys = ?container_keys, + merged_request_env_count = request.len(), + merged_request_env_keys = ?request_keys, + malformed_request_entries, + "Prepared managed execution session environment" + ); +} + +struct ExecInput { + execution_id: ExecutionId, + input: StreamingExecInput, +} + +#[async_trait] +impl ExecutionProcessInput for ExecInput { + async fn write_stdin(&self, data: &[u8]) -> ExecutionManagerResult<()> { + self.input + .write_stdin(data) + .await + .map_err(|error| session_error(&self.execution_id, "write command stdin", error)) + } + + async fn close_stdin(&self) -> ExecutionManagerResult<()> { + self.input + .close_stdin() + .await + .map_err(|error| session_error(&self.execution_id, "close command stdin", error)) + } + + async fn cancel(&self) -> ExecutionManagerResult<()> { + self.input + .cancel() + .await + .map_err(|error| session_error(&self.execution_id, "cancel command", error)) + } +} + +struct ExecStream { + stream: StreamingExec, + input: Arc, +} + +#[async_trait] +impl ExecutionProcessStream for ExecStream { + fn input(&self) -> Arc { + self.input.clone() + } + + async fn next_event(&mut self) -> ExecutionManagerResult> { + self.stream + .next_event() + .await + .map_err(|error| ExecutionManagerError::Unavailable(error.to_string())) + } +} + +struct PtyInput { + execution_id: ExecutionId, + input: StreamingPtyInput, +} + +#[async_trait] +impl ExecutionProcessInput for PtyInput { + async fn write_stdin(&self, data: &[u8]) -> ExecutionManagerResult<()> { + self.input + .write_stdin(data) + .await + .map_err(|error| session_error(&self.execution_id, "write PTY stdin", error)) + } + + async fn close_stdin(&self) -> ExecutionManagerResult<()> { + self.cancel().await + } + + async fn cancel(&self) -> ExecutionManagerResult<()> { + self.input + .close() + .await + .map_err(|error| session_error(&self.execution_id, "close PTY", error)) + } + + async fn resize_pty(&self, cols: u16, rows: u16) -> ExecutionManagerResult<()> { + self.input + .resize(cols, rows) + .await + .map_err(|error| session_error(&self.execution_id, "resize PTY", error)) + } +} + +struct PtyStream { + stream: StreamingPty, + input: Arc, +} + +#[async_trait] +impl ExecutionProcessStream for PtyStream { + fn input(&self) -> Arc { + self.input.clone() + } + + async fn next_event(&mut self) -> ExecutionManagerResult> { + self.stream + .next_event() + .await + .map_err(|error| ExecutionManagerError::Unavailable(error.to_string())) + } +} + +fn session_error( + execution_id: &ExecutionId, + operation: &str, + error: BoxError, +) -> ExecutionManagerError { + ExecutionManagerError::Unavailable(format!( + "failed to {operation} for execution {execution_id}: {error}" + )) +} + +#[cfg(test)] +mod tests { + use super::inherit_container_environment; + use std::collections::HashMap; + + #[test] + fn request_environment_overrides_inherited_container_values() { + let container = HashMap::from([ + ("ALPHA".to_string(), "container".to_string()), + ("BETA".to_string(), "container".to_string()), + ]); + let mut request = vec!["BETA=request".to_string(), "GAMMA=request".to_string()]; + + inherit_container_environment(&container, &mut request); + + assert_eq!( + request, + ["ALPHA=container", "BETA=request", "GAMMA=request"] + ); + } + + #[test] + fn malformed_request_entries_are_preserved_after_inherited_values() { + let container = HashMap::from([("ALPHA".to_string(), "container".to_string())]); + let mut request = vec!["MALFORMED".to_string()]; + + inherit_container_environment(&container, &mut request); + + assert_eq!(request, ["ALPHA=container", "MALFORMED"]); + } +} diff --git a/src/runtime/src/local_execution/snapshot.rs b/src/runtime/src/local_execution/snapshot.rs new file mode 100644 index 00000000..86159eab --- /dev/null +++ b/src/runtime/src/local_execution/snapshot.rs @@ -0,0 +1,649 @@ +//! Crash-recoverable filesystem snapshots for managed executions. + +use std::path::{Path, PathBuf}; + +use a3s_box_core::snapshot::SnapshotMetadata; +use a3s_box_core::{ + ExecutionBackend, ExecutionGeneration, ExecutionId, ExecutionLease, ExecutionManagerError, + ExecutionManagerResult, ExecutionSnapshot, ExecutionSnapshotId, ExecutionState, +}; + +use super::record::lease_from_record; +use super::support::{managed_state, require_generation, required_handle, state_conflict}; +use super::{LocalExecutionHandle, LocalExecutionManager, ManagedExecutionState, RuntimeUpdate}; +use crate::{BoxRecord, BoxStateStore, ManagedExecutionOperation, SnapshotStore}; + +impl LocalExecutionManager { + pub(super) async fn create_snapshot( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + let record = self + .get(execution_id) + .await? + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + require_generation(&record, execution_id, expected_generation)?; + let source_state = managed_state(&record)?; + if source_state == ManagedExecutionState::Snapshotting { + let (pending_snapshot_id, _) = snapshot_operation(&record)?; + if &pending_snapshot_id != snapshot_id { + return Err(state_conflict(&record, execution_id, "snapshot")); + } + return self.drive_snapshot(record).await; + } + if !matches!( + source_state, + ManagedExecutionState::Running | ManagedExecutionState::Paused + ) { + return Err(state_conflict(&record, execution_id, "snapshot")); + } + let metadata = record.managed_execution.as_ref().ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "execution {execution_id} lost managed lifecycle metadata" + )) + })?; + if metadata.plan.backend != ExecutionBackend::Crun { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: "filesystem snapshots currently require the Sandbox backend".to_string(), + }); + } + if let Some(existing) = self.load_snapshot(snapshot_id).await? { + if existing.source_box_id != record.id { + return Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: format!( + "filesystem snapshot {snapshot_id} belongs to another execution" + ), + }); + } + return Ok(ExecutionSnapshot { + snapshot_id: snapshot_id.clone(), + size_bytes: existing.size_bytes, + state: execution_state(source_state)?, + lease: lease_from_record(&record)?, + }); + } + + let claimed = self + .transition( + &record, + source_state, + ManagedExecutionState::Snapshotting, + RuntimeUpdate::SnapshotClaim { + snapshot_id: snapshot_id.clone(), + source_state, + }, + ) + .await?; + self.drive_snapshot(claimed).await + } + + pub(super) async fn recover_snapshot( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + self.drive_snapshot(record) + .await + .map(|snapshot| snapshot.lease) + } + + pub(super) async fn stabilize_snapshot( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + if managed_state(&record)? != ManagedExecutionState::Snapshotting { + return Ok(record); + } + let execution_id = ExecutionId::new(record.id.clone())?; + self.drive_snapshot(record).await?; + self.get(&execution_id) + .await? + .ok_or(ExecutionManagerError::NotFound(execution_id)) + } + + async fn drive_snapshot(&self, record: BoxRecord) -> ExecutionManagerResult { + let (snapshot_id, source_state) = snapshot_operation(&record)?; + let execution_id = ExecutionId::new(record.id.clone())?; + let observation = self.backend.inspect(&record).await?; + observation.validate(&execution_id)?; + let mut handle = required_handle(&observation, &execution_id)?; + + match (source_state, observation.state) { + (ManagedExecutionState::Running, ExecutionState::Running) => { + handle = self.pause_for_snapshot(&record, &execution_id).await?; + } + (ManagedExecutionState::Running, ExecutionState::Paused) + | (ManagedExecutionState::Paused, ExecutionState::Paused) => {} + (ManagedExecutionState::Paused, ExecutionState::Running) => { + return Err(ExecutionManagerError::Conflict { + execution_id, + message: "a paused execution resumed while its filesystem snapshot was pending" + .to_string(), + }) + } + (_, state) => { + return Err(ExecutionManagerError::Conflict { + execution_id, + message: format!( + "execution entered {state:?} while its filesystem snapshot was pending" + ), + }) + } + } + + let capture = self + .capture_snapshot_rootfs(record.clone(), snapshot_id.clone()) + .await; + let size_bytes = match capture { + Ok(size_bytes) => size_bytes, + Err(capture_error) => { + let restored = self + .restore_snapshot_source_state(&record, source_state, handle) + .await; + return match restored { + Ok(_) => Err(capture_error), + Err(restore_error) => Err(ExecutionManagerError::Internal(format!( + "{capture_error}; failed to restore execution {} after snapshot failure: {restore_error}", + record.id + ))), + }; + } + }; + + let completed = self + .restore_snapshot_source_state(&record, source_state, handle) + .await?; + Ok(ExecutionSnapshot { + snapshot_id, + size_bytes, + state: execution_state(source_state)?, + lease: lease_from_record(&completed)?, + }) + } + + async fn pause_for_snapshot( + &self, + record: &BoxRecord, + execution_id: &ExecutionId, + ) -> ExecutionManagerResult { + match self.backend.pause(record, true).await { + Ok(handle) => { + handle.validate(execution_id)?; + Ok(handle) + } + Err(pause_error) => match self.backend.inspect(record).await { + Ok(observation) if observation.state == ExecutionState::Paused => { + observation.validate(execution_id)?; + required_handle(&observation, execution_id) + } + _ => Err(pause_error), + }, + } + } + + async fn restore_snapshot_source_state( + &self, + record: &BoxRecord, + source_state: ManagedExecutionState, + mut handle: LocalExecutionHandle, + ) -> ExecutionManagerResult { + if source_state == ManagedExecutionState::Running { + let execution_id = ExecutionId::new(record.id.clone())?; + handle = match self.backend.resume(record).await { + Ok(handle) => { + handle.validate(&execution_id)?; + handle + } + Err(resume_error) => match self.backend.inspect(record).await { + Ok(observation) if observation.state == ExecutionState::Running => { + observation.validate(&execution_id)?; + required_handle(&observation, &execution_id)? + } + _ => return Err(resume_error), + }, + }; + } + self.complete_with_handle( + record, + ManagedExecutionState::Snapshotting, + source_state, + handle, + ) + .await + } + + async fn capture_snapshot_rootfs( + &self, + record: BoxRecord, + snapshot_id: ExecutionSnapshotId, + ) -> ExecutionManagerResult { + let home_dir = self.home_dir.clone(); + let execution_id = record.id.clone(); + tokio::task::spawn_blocking(move || { + let store = SnapshotStore::new(&home_dir.join("snapshots")) + .map_err(|error| snapshot_error(&record, "open snapshot store", error))?; + if let Some(existing) = store + .get(snapshot_id.as_str()) + .map_err(|error| snapshot_error(&record, "load snapshot", error))? + { + if existing.id != snapshot_id.as_str() + || existing.source_box_id != record.id + || !store.rootfs_path(snapshot_id.as_str()).is_dir() + { + return Err(ExecutionManagerError::Unavailable(format!( + "filesystem snapshot {snapshot_id} has inconsistent persisted metadata" + ))); + } + return Ok(existing.size_bytes); + } + let rootfs = resolve_managed_rootfs(&record.box_dir).ok_or_else(|| { + ExecutionManagerError::Unavailable(format!( + "execution {} has no populated managed rootfs to snapshot", + record.id + )) + })?; + let metadata = build_snapshot_metadata(&record, &snapshot_id)?; + #[cfg(target_os = "linux")] + let rootfs_metadata = capture_sandbox_rootfs_metadata(&record, &rootfs)?; + #[cfg(target_os = "linux")] + let saved = store.save_managed(metadata, &rootfs, &rootfs_metadata); + #[cfg(not(target_os = "linux"))] + let saved = store.save(metadata, &rootfs); + match saved { + Ok(saved) => Ok(saved.size_bytes), + Err(save_error) => match store.get(snapshot_id.as_str()) { + Ok(Some(existing)) + if existing.id == snapshot_id.as_str() + && existing.source_box_id == record.id + && store.rootfs_path(snapshot_id.as_str()).is_dir() => + { + Ok(existing.size_bytes) + } + _ => Err(snapshot_error(&record, "capture rootfs", save_error)), + }, + } + }) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!( + "filesystem snapshot task failed for {}: {error}", + execution_id + )) + })? + } + + pub(super) async fn snapshot_size( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult> { + Ok(self + .load_snapshot(snapshot_id) + .await? + .map(|snapshot| snapshot.size_bytes)) + } + + async fn load_snapshot( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult> { + let home_dir = self.home_dir.clone(); + let snapshot_id = snapshot_id.clone(); + tokio::task::spawn_blocking(move || { + let store = SnapshotStore::new(&home_dir.join("snapshots")).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to open filesystem snapshot store: {error}" + )) + })?; + let Some(metadata) = store.get(snapshot_id.as_str()).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to inspect filesystem snapshot {snapshot_id}: {error}" + )) + })? + else { + return Ok(None); + }; + if metadata.id != snapshot_id.as_str() + || !store.rootfs_path(snapshot_id.as_str()).is_dir() + { + return Err(ExecutionManagerError::Unavailable(format!( + "filesystem snapshot {snapshot_id} is not a valid published snapshot" + ))); + } + Ok(Some(metadata)) + }) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!("filesystem snapshot task failed: {error}")) + })? + } + + pub(super) async fn delete_snapshot( + &self, + snapshot_id: &ExecutionSnapshotId, + ) -> ExecutionManagerResult { + let home_dir = self.home_dir.clone(); + let state_path = self.store.path().to_path_buf(); + let snapshot_id = snapshot_id.clone(); + tokio::task::spawn_blocking(move || { + let store = SnapshotStore::new(&home_dir.join("snapshots")).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to open filesystem snapshot store: {error}" + )) + })?; + let _snapshot_lock = store.acquire_exclusive_lock().map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to lock filesystem snapshot store: {error}" + )) + })?; + let state = BoxStateStore::load_readonly(state_path).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to inspect snapshot users: {error}" + )) + })?; + let rootfs = store.rootfs_path(snapshot_id.as_str()); + let in_use = state.records().iter().any(|record| { + let requested = record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.request.rootfs_snapshot_id.as_ref()) + == Some(&snapshot_id); + let request_is_live = requested + && !record + .managed_state() + .is_ok_and(|state| state.is_some_and(ManagedExecutionState::is_terminal)); + let marker_uses_snapshot = + std::fs::read_to_string(record.box_dir.join(".snapshot-lower")) + .is_ok_and(|value| Path::new(value.trim()) == rootfs.as_path()); + request_is_live || marker_uses_snapshot + }); + if in_use { + return Err(ExecutionManagerError::Conflict { + execution_id: ExecutionId::new(format!("snapshot-{snapshot_id}"))?, + message: "filesystem snapshot is in use by an active execution".to_string(), + }); + } + store.delete_locked(snapshot_id.as_str()).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to delete filesystem snapshot {snapshot_id}: {error}" + )) + }) + }) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!("filesystem snapshot task failed: {error}")) + })? + } +} + +fn snapshot_operation( + record: &BoxRecord, +) -> ExecutionManagerResult<(ExecutionSnapshotId, ManagedExecutionState)> { + match record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.pending_operation.as_ref()) + { + Some(ManagedExecutionOperation::Snapshot { + snapshot_id, + source_state, + }) if matches!( + source_state, + ManagedExecutionState::Running | ManagedExecutionState::Paused + ) => + { + Ok((snapshot_id.clone(), *source_state)) + } + _ => Err(ExecutionManagerError::Internal(format!( + "execution {} has invalid snapshot recovery metadata", + record.id + ))), + } +} + +fn execution_state(state: ManagedExecutionState) -> ExecutionManagerResult { + match state { + ManagedExecutionState::Running => Ok(ExecutionState::Running), + ManagedExecutionState::Paused => Ok(ExecutionState::Paused), + _ => Err(ExecutionManagerError::Internal(format!( + "invalid stable snapshot state {state}" + ))), + } +} + +fn resolve_managed_rootfs(box_dir: &Path) -> Option { + let populated = |path: &Path| { + path.is_dir() + && std::fs::read_dir(path) + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false) + }; + let merged = box_dir.join("merged"); + if populated(&merged) { + return Some(merged); + } + let rootfs = box_dir.join("rootfs"); + let apfs_data = rootfs.join(".a3s-rootfs"); + if populated(&apfs_data) { + return Some(apfs_data); + } + populated(&rootfs).then_some(rootfs) +} + +#[cfg(target_os = "linux")] +fn capture_sandbox_rootfs_metadata( + record: &BoxRecord, + rootfs: &Path, +) -> ExecutionManagerResult { + let config_path = record.box_dir.join("sandbox/bundle/config.json"); + let spec = oci_spec::runtime::Spec::load(&config_path).map_err(|error| { + snapshot_error( + record, + "load Sandbox OCI mappings", + format!("{}: {error}", config_path.display()), + ) + })?; + let configured_rootfs = spec + .root() + .as_ref() + .map(|root| root.path()) + .ok_or_else(|| { + snapshot_error( + record, + "validate Sandbox OCI mappings", + "OCI specification has no rootfs", + ) + })?; + if configured_rootfs != rootfs { + return Err(snapshot_error( + record, + "validate Sandbox OCI mappings", + format!( + "OCI rootfs {} does not match the active rootfs {}", + configured_rootfs.display(), + rootfs.display() + ), + )); + } + let linux = spec.linux().as_ref().ok_or_else(|| { + snapshot_error( + record, + "validate Sandbox OCI mappings", + "OCI specification has no Linux section", + ) + })?; + let uid_mappings = convert_id_mappings(record, "UID", linux.uid_mappings())?; + let gid_mappings = convert_id_mappings(record, "GID", linux.gid_mappings())?; + let maximum_container_uid = maximum_container_id(record, "UID", &uid_mappings)?; + let maximum_container_gid = maximum_container_id(record, "GID", &gid_mappings)?; + let plan = crate::sandbox::SandboxIdMappingPlan { + uid_mappings, + gid_mappings, + maximum_container_uid, + maximum_container_gid, + }; + crate::sandbox::rootfs::capture_snapshot_rootfs_metadata(rootfs, &plan) + .map_err(|error| snapshot_error(record, "capture Sandbox rootfs metadata", error)) +} + +#[cfg(target_os = "linux")] +fn convert_id_mappings( + record: &BoxRecord, + kind: &str, + mappings: &Option>, +) -> ExecutionManagerResult> { + let mappings = mappings.as_ref().ok_or_else(|| { + snapshot_error( + record, + "validate Sandbox OCI mappings", + format!("OCI specification has no {kind} mappings"), + ) + })?; + if mappings.is_empty() { + return Err(snapshot_error( + record, + "validate Sandbox OCI mappings", + format!("OCI specification has empty {kind} mappings"), + )); + } + let converted: Vec<_> = mappings + .iter() + .map(|mapping| crate::sandbox::IdMapping { + container_id: mapping.container_id(), + host_id: mapping.host_id(), + size: mapping.size(), + }) + .collect(); + validate_id_mappings(record, kind, &converted)?; + Ok(converted) +} + +#[cfg(target_os = "linux")] +fn validate_id_mappings( + record: &BoxRecord, + kind: &str, + mappings: &[crate::sandbox::IdMapping], +) -> ExecutionManagerResult<()> { + for (index, mapping) in mappings.iter().enumerate() { + let (Some(container_end), Some(host_end)) = ( + mapping.container_id.checked_add(mapping.size), + mapping.host_id.checked_add(mapping.size), + ) else { + return Err(snapshot_error( + record, + "validate Sandbox OCI mappings", + format!("OCI {kind} mapping {index} is empty or overflows"), + )); + }; + if mapping.size == 0 { + return Err(snapshot_error( + record, + "validate Sandbox OCI mappings", + format!("OCI {kind} mapping {index} is empty or overflows"), + )); + } + for previous in &mappings[..index] { + let previous_container_end = previous + .container_id + .checked_add(previous.size) + .ok_or_else(|| { + snapshot_error( + record, + "validate Sandbox OCI mappings", + format!("OCI {kind} mappings overflow"), + ) + })?; + let previous_host_end = + previous.host_id.checked_add(previous.size).ok_or_else(|| { + snapshot_error( + record, + "validate Sandbox OCI mappings", + format!("OCI {kind} mappings overflow"), + ) + })?; + if (mapping.container_id < previous_container_end + && previous.container_id < container_end) + || (mapping.host_id < previous_host_end && previous.host_id < host_end) + { + return Err(snapshot_error( + record, + "validate Sandbox OCI mappings", + format!("OCI {kind} mappings overlap"), + )); + } + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn maximum_container_id( + record: &BoxRecord, + kind: &str, + mappings: &[crate::sandbox::IdMapping], +) -> ExecutionManagerResult { + mappings + .iter() + .filter_map(|mapping| { + mapping + .container_id + .checked_add(mapping.size) + .and_then(|end| end.checked_sub(1)) + }) + .max() + .ok_or_else(|| { + snapshot_error( + record, + "validate Sandbox OCI mappings", + format!("OCI {kind} mappings have no covered IDs"), + ) + }) +} + +fn build_snapshot_metadata( + record: &BoxRecord, + snapshot_id: &ExecutionSnapshotId, +) -> ExecutionManagerResult { + let mut metadata = SnapshotMetadata::new( + snapshot_id.to_string(), + snapshot_id.to_string(), + record.id.clone(), + record.image.clone(), + ); + metadata.vcpus = record.cpus; + metadata.memory_mb = record.memory_mb; + metadata.env = record.env.clone(); + metadata.cmd = record.cmd.clone(); + metadata.entrypoint = record.entrypoint.clone(); + metadata.workdir = record.workdir.clone(); + metadata.labels = record.labels.clone(); + metadata.image_config = crate::load_resolved_image_config(&record.box_dir) + .map_err(|error| snapshot_error(record, "load resolved image configuration", error))?; + if metadata.image_config.is_none() { + return Err(snapshot_error( + record, + "load resolved image configuration", + format!( + "{} is missing", + record + .box_dir + .join(crate::RESOLVED_IMAGE_CONFIG_FILE) + .display() + ), + )); + } + Ok(metadata) +} + +fn snapshot_error( + record: &BoxRecord, + operation: &str, + error: impl std::fmt::Display, +) -> ExecutionManagerError { + ExecutionManagerError::Unavailable(format!( + "failed to {operation} for execution {}: {error}", + record.id + )) +} diff --git a/src/runtime/src/local_execution/store.rs b/src/runtime/src/local_execution/store.rs new file mode 100644 index 00000000..c4423e4c --- /dev/null +++ b/src/runtime/src/local_execution/store.rs @@ -0,0 +1,259 @@ +use a3s_box_core::{ + ExecutionGeneration, ExecutionId, ExecutionManagerError, ExecutionManagerResult, + ExecutionSnapshotId, OperationId, RestartExecutionOptions, +}; + +use super::record::{ + apply_handle, apply_restart_handle, apply_start_handle, clear_live_runtime, execution_id, +}; +use super::support::{generation, managed_state}; +use super::{LocalExecutionHandle, LocalExecutionManager}; +use crate::{ + BoxRecord, ManagedExecutionOperation, ManagedExecutionReservation, ManagedExecutionState, + ManagedExecutionStoreError, SnapshotStore, +}; + +impl LocalExecutionManager { + pub(super) async fn reserve( + &self, + record: BoxRecord, + ) -> ExecutionManagerResult { + let store = self.store.clone(); + let Some(snapshot_id) = record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.request.rootfs_snapshot_id.clone()) + else { + return run_store(move || store.reserve(record)).await; + }; + let home_dir = self.home_dir.clone(); + tokio::task::spawn_blocking(move || { + let snapshots = SnapshotStore::new(&home_dir.join("snapshots")).map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to open filesystem snapshot store: {error}" + )) + })?; + let _snapshot_lock = snapshots.acquire_exclusive_lock().map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to lock filesystem snapshot store: {error}" + )) + })?; + let metadata = snapshots + .get(snapshot_id.as_str()) + .map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "failed to inspect filesystem snapshot {snapshot_id}: {error}" + )) + })? + .ok_or_else(|| { + ExecutionManagerError::Unavailable(format!( + "filesystem snapshot {snapshot_id} is unavailable" + )) + })?; + if metadata.id != snapshot_id.as_str() + || !snapshots.rootfs_path(snapshot_id.as_str()).is_dir() + { + return Err(ExecutionManagerError::Unavailable(format!( + "filesystem snapshot {snapshot_id} is not a valid published snapshot" + ))); + } + metadata.require_image_config().map_err(|error| { + ExecutionManagerError::Unavailable(format!( + "filesystem snapshot {snapshot_id} cannot be restored: {error}" + )) + })?; + store.reserve(record).map_err(map_store_error) + }) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!("managed state task failed: {error}")) + })? + } + + pub(super) async fn get( + &self, + execution_id: &ExecutionId, + ) -> ExecutionManagerResult> { + let store = self.store.clone(); + let execution_id = execution_id.clone(); + run_store(move || store.get(&execution_id)).await + } + + pub(super) async fn get_by_operation( + &self, + operation_id: &OperationId, + ) -> ExecutionManagerResult> { + let store = self.store.clone(); + let operation_id = operation_id.clone(); + run_store(move || store.get_by_operation_id(&operation_id)).await + } + + pub(super) async fn transition( + &self, + record: &BoxRecord, + from: ManagedExecutionState, + to: ManagedExecutionState, + update: RuntimeUpdate, + ) -> ExecutionManagerResult { + let store = self.store.clone(); + let execution_id = execution_id(record)?; + let generation = generation(record, &execution_id)?; + run_store(move || { + store.transition_with(&execution_id, generation, from, to, |record| match update { + RuntimeUpdate::None => {} + RuntimeUpdate::Handle(handle) => apply_handle(record, &handle), + RuntimeUpdate::StartHandle(handle) => apply_start_handle(record, &handle), + RuntimeUpdate::Terminal(exit_code) => clear_live_runtime(record, exit_code), + RuntimeUpdate::PauseClaim(keep_memory) => { + if let Some(metadata) = record.managed_execution.as_mut() { + metadata.pending_operation = + Some(ManagedExecutionOperation::Pause { keep_memory }); + } + } + RuntimeUpdate::SnapshotClaim { + snapshot_id, + source_state, + } => { + if let Some(metadata) = record.managed_execution.as_mut() { + metadata.pending_operation = Some(ManagedExecutionOperation::Snapshot { + snapshot_id, + source_state, + }); + } + } + RuntimeUpdate::RestartClaim { + operation_id, + options, + } => { + if let Some(metadata) = record.managed_execution.as_mut() { + metadata.pending_operation = Some(ManagedExecutionOperation::Restart { + operation_id, + source_generation: metadata.generation, + source_state: from, + stop_timeout_secs: options.stop_timeout_secs, + }); + } + } + RuntimeUpdate::RestartAdvance => clear_live_runtime(record, None), + RuntimeUpdate::RestartHandle(handle) => apply_restart_handle(record, &handle), + RuntimeUpdate::RestartFailed(exit_code) => clear_live_runtime(record, exit_code), + }) + }) + .await + } + + pub(super) async fn complete_with_handle( + &self, + record: &BoxRecord, + from: ManagedExecutionState, + to: ManagedExecutionState, + handle: LocalExecutionHandle, + ) -> ExecutionManagerResult { + let execution_id = execution_id(record)?; + let current_generation = generation(record, &execution_id)?; + let expected_generation = if matches!( + (from, to), + ( + ManagedExecutionState::Pausing, + ManagedExecutionState::Paused + ) | ( + ManagedExecutionState::Resuming, + ManagedExecutionState::Running + ) + ) { + ExecutionGeneration::new(current_generation.get().checked_add(1).ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "execution {execution_id} generation is exhausted" + )) + })?)? + } else { + current_generation + }; + let update = + if from == ManagedExecutionState::Starting && to == ManagedExecutionState::Running { + RuntimeUpdate::StartHandle(handle) + } else { + RuntimeUpdate::Handle(handle) + }; + match self.transition(record, from, to, update).await { + Ok(record) => Ok(record), + Err(error @ ExecutionManagerError::Conflict { .. }) => { + let Some(current) = self.get(&execution_id).await? else { + return Err(ExecutionManagerError::NotFound(execution_id)); + }; + if managed_state(¤t)? == to + && generation(¤t, &execution_id)? == expected_generation + { + Ok(current) + } else { + Err(error) + } + } + Err(error) => Err(error), + } + } +} + +pub(super) enum RuntimeUpdate { + None, + Handle(LocalExecutionHandle), + StartHandle(LocalExecutionHandle), + Terminal(Option), + PauseClaim(bool), + SnapshotClaim { + snapshot_id: ExecutionSnapshotId, + source_state: ManagedExecutionState, + }, + RestartClaim { + operation_id: OperationId, + options: RestartExecutionOptions, + }, + RestartAdvance, + RestartHandle(LocalExecutionHandle), + RestartFailed(Option), +} + +async fn run_store( + operation: impl FnOnce() -> Result + Send + 'static, +) -> ExecutionManagerResult +where + T: Send + 'static, +{ + tokio::task::spawn_blocking(operation) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!("managed state task failed: {error}")) + })? + .map_err(map_store_error) +} + +fn map_store_error(error: ManagedExecutionStoreError) -> ExecutionManagerError { + match error { + ManagedExecutionStoreError::Io(error) => { + ExecutionManagerError::Unavailable(error.to_string()) + } + ManagedExecutionStoreError::NotFound(execution_id) => { + ExecutionManagerError::NotFound(execution_id) + } + ManagedExecutionStoreError::Conflict { + execution_id, + message, + } => ExecutionManagerError::Conflict { + execution_id, + message, + }, + ManagedExecutionStoreError::Unmanaged(execution_id) => ExecutionManagerError::Internal( + format!("execution record is not managed: {execution_id}"), + ), + ManagedExecutionStoreError::InvalidRecord(message) => { + ExecutionManagerError::Internal(message) + } + ManagedExecutionStoreError::InvalidTransition { + execution_id, + from, + to, + } => ExecutionManagerError::Internal(format!( + "invalid managed transition for {execution_id}: {from} -> {to}" + )), + } +} diff --git a/src/runtime/src/local_execution/support.rs b/src/runtime/src/local_execution/support.rs new file mode 100644 index 00000000..041a7226 --- /dev/null +++ b/src/runtime/src/local_execution/support.rs @@ -0,0 +1,125 @@ +use a3s_box_core::{ + ExecutionGeneration, ExecutionId, ExecutionManagerError, ExecutionManagerResult, + ExecutionState, ReconcileOutcome, +}; + +use super::record::{lease_from_record, reservation_from_record}; +use super::{ + BoxRecord, LocalExecutionHandle, LocalExecutionObservation, ManagedExecutionOperation, + ManagedExecutionState, +}; + +pub(super) fn managed_state(record: &BoxRecord) -> ExecutionManagerResult { + record + .managed_state() + .map_err(|error| ExecutionManagerError::Internal(error.to_string()))? + .ok_or_else(|| { + ExecutionManagerError::Internal(format!("execution {} is not managed", record.id)) + }) +} + +pub(super) fn generation( + record: &BoxRecord, + execution_id: &ExecutionId, +) -> ExecutionManagerResult { + record + .managed_execution + .as_ref() + .map(|metadata| metadata.generation) + .ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "execution {execution_id} has no managed generation" + )) + }) +} + +pub(super) fn require_generation( + record: &BoxRecord, + execution_id: &ExecutionId, + expected: ExecutionGeneration, +) -> ExecutionManagerResult<()> { + let actual = generation(record, execution_id)?; + if actual == expected { + Ok(()) + } else { + Err(ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: format!( + "expected generation {}, found {}", + expected.get(), + actual.get() + ), + }) + } +} + +pub(super) fn state_conflict( + record: &BoxRecord, + execution_id: &ExecutionId, + operation: &str, +) -> ExecutionManagerError { + let state = managed_state(record) + .map(|state| state.to_string()) + .unwrap_or_else(|error| error.to_string()); + ExecutionManagerError::Conflict { + execution_id: execution_id.clone(), + message: format!("cannot {operation} execution in state {state}"), + } +} + +pub(super) fn required_handle( + observation: &LocalExecutionObservation, + execution_id: &ExecutionId, +) -> ExecutionManagerResult { + observation.handle.clone().ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "backend returned no runtime evidence for {execution_id}" + )) + }) +} + +pub(super) fn pending_pause_policy( + record: &BoxRecord, + execution_id: &ExecutionId, +) -> ExecutionManagerResult { + match record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.pending_operation.as_ref()) + { + Some(ManagedExecutionOperation::Pause { keep_memory }) => Ok(*keep_memory), + _ => Err(ExecutionManagerError::Internal(format!( + "pausing execution {execution_id} has no persisted pause policy" + ))), + } +} + +pub(super) fn pending_restart_source_state( + record: &BoxRecord, + execution_id: &ExecutionId, +) -> ExecutionManagerResult { + match record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.pending_operation.as_ref()) + { + Some(ManagedExecutionOperation::Restart { source_state, .. }) => Ok(*source_state), + _ => Err(ExecutionManagerError::Internal(format!( + "restarting execution {execution_id} has no persisted restart source state" + ))), + } +} + +pub(super) fn outcome_from_record( + record: BoxRecord, + state: ExecutionState, +) -> ExecutionManagerResult { + match state { + ExecutionState::Created => Ok(ReconcileOutcome::Created(reservation_from_record(&record)?)), + ExecutionState::Creating => Ok(ReconcileOutcome::Creating), + ExecutionState::Running | ExecutionState::Paused => { + Ok(ReconcileOutcome::Ready(lease_from_record(&record)?)) + } + ExecutionState::Stopped | ExecutionState::Failed => Ok(ReconcileOutcome::Failed), + } +} diff --git a/src/runtime/src/local_execution/tests.rs b/src/runtime/src/local_execution/tests.rs new file mode 100644 index 00000000..7f96c2fb --- /dev/null +++ b/src/runtime/src/local_execution/tests.rs @@ -0,0 +1,1928 @@ +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use a3s_box_core::{ + BoxConfig, CreateExecutionRequest, ExecEvent, ExecRequest, ExecutionGeneration, + ExecutionHealthCheck, ExecutionId, ExecutionIsolation, ExecutionManager, ExecutionManagerError, + ExecutionManagerResult, ExecutionRecordPolicy, ExecutionRestartPolicy, ExecutionSessionManager, + ExecutionSnapshotId, ExecutionState, KillOutcome, NetworkMode, OperationId, ReconcileOutcome, + RestartExecutionOptions, SnapshotImageConfig, +}; +use async_trait::async_trait; +use chrono::{TimeZone, Utc}; + +use super::*; +use crate::{ManagedExecutionState, ManagedExecutionStore}; + +#[derive(Clone)] +struct FakeExecution { + state: ExecutionState, + handle: LocalExecutionHandle, + exit_code: Option, +} + +#[derive(Default)] +struct FakeBackend { + executions: Mutex>, + starts: AtomicUsize, + pauses: AtomicUsize, + resumes: AtomicUsize, + kills: AtomicUsize, + fail_start: AtomicBool, + fail_start_after_effect: AtomicBool, + fail_kill_after_effect: AtomicBool, + fail_pause: AtomicBool, + fail_pause_after_effect: AtomicBool, + last_keep_memory: Mutex>, + last_restart_timeout: Mutex>>, +} + +impl FakeBackend { + fn handle(record: &BoxRecord) -> LocalExecutionHandle { + LocalExecutionHandle { + started_at: Utc.with_ymd_and_hms(2026, 7, 14, 12, 30, 0).unwrap(), + pid: Some(4242), + pid_start_time: Some(777), + exec_socket_path: record.box_dir.join("sockets/exec.sock"), + console_log: record.box_dir.join("logs/console.log"), + anonymous_volumes: vec!["anonymous-1".to_string()], + } + } + + fn execution_id(record: &BoxRecord) -> ExecutionId { + ExecutionId::new(record.id.clone()).unwrap() + } + + fn stop_externally(&self, execution_id: &ExecutionId, exit_code: i32) { + let mut executions = self.executions.lock().unwrap(); + let execution = executions.get_mut(execution_id.as_str()).unwrap(); + execution.state = ExecutionState::Stopped; + execution.exit_code = Some(exit_code); + } + + fn fail_externally(&self, execution_id: &ExecutionId, exit_code: i32) { + let mut executions = self.executions.lock().unwrap(); + let execution = executions.get_mut(execution_id.as_str()).unwrap(); + execution.state = ExecutionState::Failed; + execution.exit_code = Some(exit_code); + } +} + +#[async_trait] +impl LocalExecutionBackend for FakeBackend { + async fn start(&self, record: &BoxRecord) -> ExecutionManagerResult { + let mut executions = self.executions.lock().unwrap(); + if let Some(execution) = executions.get(&record.id) { + if matches!( + execution.state, + ExecutionState::Running | ExecutionState::Paused + ) { + return Ok(execution.handle.clone()); + } + } + self.starts.fetch_add(1, Ordering::Relaxed); + if self.fail_start.load(Ordering::Relaxed) { + executions.remove(&record.id); + return Err(ExecutionManagerError::Unavailable( + "fake start is unavailable".to_string(), + )); + } + #[cfg(target_os = "linux")] + write_fake_sandbox_bundle(record)?; + write_fake_resolved_image_config(record)?; + let handle = Self::handle(record); + executions.insert( + record.id.clone(), + FakeExecution { + state: ExecutionState::Running, + handle: handle.clone(), + exit_code: None, + }, + ); + if self.fail_start_after_effect.load(Ordering::Relaxed) { + return Err(ExecutionManagerError::Unavailable( + "fake start response was lost".to_string(), + )); + } + Ok(handle) + } + + async fn inspect( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult { + let executions = self.executions.lock().unwrap(); + let execution = executions + .get(&record.id) + .ok_or_else(|| ExecutionManagerError::NotFound(Self::execution_id(record)))?; + Ok(LocalExecutionObservation { + state: execution.state, + handle: matches!( + execution.state, + ExecutionState::Running | ExecutionState::Paused + ) + .then(|| execution.handle.clone()), + exit_code: execution.exit_code, + }) + } + + async fn pause( + &self, + record: &BoxRecord, + keep_memory: bool, + ) -> ExecutionManagerResult { + self.pauses.fetch_add(1, Ordering::Relaxed); + *self.last_keep_memory.lock().unwrap() = Some(keep_memory); + if self.fail_pause.load(Ordering::Relaxed) { + return Err(ExecutionManagerError::Unavailable( + "fake pause is unavailable".to_string(), + )); + } + let mut executions = self.executions.lock().unwrap(); + let execution = executions + .get_mut(&record.id) + .ok_or_else(|| ExecutionManagerError::NotFound(Self::execution_id(record)))?; + if execution.state != ExecutionState::Running { + return Err(ExecutionManagerError::Conflict { + execution_id: Self::execution_id(record), + message: "fake execution is not running".to_string(), + }); + } + execution.state = ExecutionState::Paused; + if self.fail_pause_after_effect.load(Ordering::Relaxed) { + return Err(ExecutionManagerError::Unavailable( + "fake pause response was lost".to_string(), + )); + } + Ok(execution.handle.clone()) + } + + async fn resume(&self, record: &BoxRecord) -> ExecutionManagerResult { + self.resumes.fetch_add(1, Ordering::Relaxed); + let mut executions = self.executions.lock().unwrap(); + let execution = executions + .get_mut(&record.id) + .ok_or_else(|| ExecutionManagerError::NotFound(Self::execution_id(record)))?; + if execution.state != ExecutionState::Paused { + return Err(ExecutionManagerError::Conflict { + execution_id: Self::execution_id(record), + message: "fake execution is not paused".to_string(), + }); + } + execution.state = ExecutionState::Running; + Ok(execution.handle.clone()) + } + + async fn stop_for_restart( + &self, + record: &BoxRecord, + timeout_secs: Option, + ) -> ExecutionManagerResult { + *self.last_restart_timeout.lock().unwrap() = Some(timeout_secs); + self.kill(record).await + } + + async fn kill(&self, record: &BoxRecord) -> ExecutionManagerResult { + self.kills.fetch_add(1, Ordering::Relaxed); + let mut executions = self.executions.lock().unwrap(); + let execution = executions + .get_mut(&record.id) + .ok_or_else(|| ExecutionManagerError::NotFound(Self::execution_id(record)))?; + if execution.state == ExecutionState::Stopped { + return Ok(KillOutcome::AlreadyStopped); + } + execution.state = ExecutionState::Stopped; + if self.fail_kill_after_effect.load(Ordering::Relaxed) { + return Err(ExecutionManagerError::Unavailable( + "fake kill response was lost".to_string(), + )); + } + Ok(KillOutcome::Killed) + } +} + +#[cfg(target_os = "linux")] +fn write_fake_sandbox_bundle(record: &BoxRecord) -> ExecutionManagerResult<()> { + let bundle = record.box_dir.join("sandbox/bundle"); + std::fs::create_dir_all(&bundle).map_err(|error| { + ExecutionManagerError::Internal(format!("failed to create fake OCI bundle: {error}")) + })?; + let config = serde_json::json!({ + "ociVersion": "1.1.0", + "root": { + "path": record.box_dir.join("rootfs"), + "readonly": false + }, + "linux": { + "uidMappings": [{ + "containerID": 0, + "hostID": unsafe { libc::geteuid() }, + "size": 1 + }], + "gidMappings": [{ + "containerID": 0, + "hostID": unsafe { libc::getegid() }, + "size": 1 + }] + } + }); + std::fs::write( + bundle.join("config.json"), + serde_json::to_vec(&config).map_err(|error| { + ExecutionManagerError::Internal(format!("failed to encode fake OCI bundle: {error}")) + })?, + ) + .map_err(|error| { + ExecutionManagerError::Internal(format!("failed to write fake OCI bundle: {error}")) + }) +} + +fn write_fake_resolved_image_config(record: &BoxRecord) -> ExecutionManagerResult<()> { + let config = SnapshotImageConfig { + entrypoint: Some(vec!["/usr/local/bin/envd".to_string()]), + cmd: Some(vec!["--port".to_string(), "49983".to_string()]), + env: vec![("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string())], + working_dir: Some("/home/user".to_string()), + user: Some("1000:1000".to_string()), + ..Default::default() + }; + std::fs::create_dir_all(&record.box_dir).map_err(|error| { + ExecutionManagerError::Internal(format!( + "failed to create fake resolved image configuration directory: {error}" + )) + })?; + std::fs::write( + record.box_dir.join(crate::RESOLVED_IMAGE_CONFIG_FILE), + serde_json::to_vec_pretty(&config).map_err(|error| { + ExecutionManagerError::Internal(format!( + "failed to encode fake resolved image configuration: {error}" + )) + })?, + ) + .map_err(|error| { + ExecutionManagerError::Internal(format!( + "failed to write fake resolved image configuration: {error}" + )) + }) +} + +fn harness() -> (tempfile::TempDir, LocalExecutionManager, Arc) { + let directory = tempfile::tempdir().unwrap(); + let backend = Arc::new(FakeBackend::default()); + let manager = LocalExecutionManager::new( + directory.path().join("boxes.json"), + directory.path().join("home"), + backend.clone(), + ); + (directory, manager, backend) +} + +fn request(external_id: &str) -> CreateExecutionRequest { + let mut labels = BTreeMap::new(); + labels.insert("purpose".to_string(), "test".to_string()); + CreateExecutionRequest { + external_sandbox_id: external_id.to_string(), + config: BoxConfig { + image: "alpine:3.20".to_string(), + isolation: ExecutionIsolation::Sandbox, + network: NetworkMode::None, + resources: a3s_box_core::ResourceConfig { + vcpus: 1, + memory_mb: 128, + disk_mb: 512, + timeout: 300, + }, + ..Default::default() + }, + labels, + policy: Default::default(), + rootfs_snapshot_id: None, + } +} + +fn operation(value: &str) -> OperationId { + OperationId::new(value).unwrap() +} + +fn persisted(manager: &LocalExecutionManager, execution_id: &ExecutionId) -> BoxRecord { + ManagedExecutionStore::new(manager.state_path().to_path_buf()) + .get(execution_id) + .unwrap() + .unwrap() +} + +async fn reserve_starting( + manager: &LocalExecutionManager, + execution_id: &ExecutionId, + operation_id: &OperationId, +) -> BoxRecord { + let record = build_managed_record( + &manager.home_dir, + execution_id, + operation_id.clone(), + request("external-recovery-id"), + Utc::now(), + ) + .unwrap(); + let record = manager.reserve(record).await.unwrap().into_record(); + manager + .transition( + &record, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + RuntimeUpdate::None, + ) + .await + .unwrap() +} + +#[tokio::test] +async fn create_persists_trusted_identity_and_returns_running_lease() { + let (directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + + let lease = manager + .create_and_start(request("external/../../sandbox"), &operation_id) + .await + .unwrap(); + let status = manager.inspect(&lease.execution_id).await.unwrap(); + let record = persisted(&manager, &lease.execution_id); + + assert_eq!(status.state, ExecutionState::Running); + assert_eq!(status.generation, ExecutionGeneration::INITIAL); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); + assert_eq!( + record + .managed_execution + .as_ref() + .unwrap() + .request + .external_sandbox_id, + "external/../../sandbox" + ); + assert!(!record.name.contains("external")); + assert!(record + .box_dir + .starts_with(directory.path().join("home/boxes"))); + assert_eq!(record.pid, Some(4242)); + assert_eq!(record.anonymous_volumes, vec!["anonymous-1"]); +} + +#[cfg(unix)] +#[tokio::test] +async fn process_session_inherits_environment_from_persisted_record() { + let (directory, manager, _backend) = harness(); + let execution_id = ExecutionId::new("execution-session-environment").unwrap(); + let operation_id = operation("operation-session-environment"); + let mut create_request = request("external-session-environment"); + create_request.config.extra_env = vec![ + ("OFFICIAL_CLIENT".to_string(), "python-sync".to_string()), + ("OVERRIDE".to_string(), "container".to_string()), + ]; + let record = build_managed_record( + &manager.home_dir, + &execution_id, + operation_id, + create_request, + Utc::now(), + ) + .unwrap(); + let record = manager.reserve(record).await.unwrap().into_record(); + let record = manager + .transition( + &record, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + RuntimeUpdate::None, + ) + .await + .unwrap(); + + // Keep the path below Darwin's shorter SUN_LEN while retaining automatic + // tempdir cleanup on every test outcome. + let socket_path = directory.path().join("exec.sock"); + std::fs::create_dir_all(socket_path.parent().unwrap()).unwrap(); + let process_id = std::process::id(); + let record = manager + .complete_with_handle( + &record, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + LocalExecutionHandle { + started_at: Utc::now(), + pid: Some(process_id), + pid_start_time: crate::process::pid_start_time(process_id), + exec_socket_path: socket_path.clone(), + console_log: record.box_dir.join("logs/console.log"), + anonymous_volumes: Vec::new(), + }, + ) + .await + .unwrap(); + assert_eq!( + persisted(&manager, &execution_id).env, + HashMap::from([ + ("OFFICIAL_CLIENT".to_string(), "python-sync".to_string()), + ("OVERRIDE".to_string(), "container".to_string()), + ]) + ); + + let listener = tokio::net::UnixListener::bind(&socket_path).unwrap(); + let (request_sender, request_receiver) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (read, write) = tokio::io::split(stream); + let mut reader = a3s_transport::FrameReader::new(read); + let mut writer = a3s_transport::FrameWriter::new(write); + let frame = reader.read_frame().await.unwrap().unwrap(); + assert_eq!(frame.frame_type, a3s_transport::FrameType::Data); + let request: ExecRequest = serde_json::from_slice(&frame.payload).unwrap(); + request_sender.send(request).unwrap(); + writer + .write_control( + &serde_json::to_vec(&a3s_box_core::exec::ExecExit { + exit_code: 0, + oom_killed: false, + }) + .unwrap(), + ) + .await + .unwrap(); + }); + + let mut process = manager + .start_process( + &execution_id, + record.managed_execution.as_ref().unwrap().generation, + ExecRequest { + cmd: vec!["env".to_string()], + timeout_ns: 1_000_000_000, + env: vec!["OVERRIDE=request".to_string()], + working_dir: None, + rootfs: None, + stdin: None, + stdin_streaming: false, + user: None, + streaming: false, + }, + ) + .await + .unwrap(); + let forwarded = request_receiver.await.unwrap(); + assert_eq!( + forwarded.env, + ["OFFICIAL_CLIENT=python-sync", "OVERRIDE=request"] + ); + assert!(forwarded.streaming); + assert!(matches!( + process.next_event().await.unwrap(), + Some(ExecEvent::Exit(exit)) if exit.exit_code == 0 + )); + server.await.unwrap(); +} + +#[tokio::test] +async fn create_reserves_without_start_and_start_is_generation_fenced() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let create_request = request("sandbox-1"); + + let reservation = manager + .create(create_request.clone(), &operation_id) + .await + .unwrap(); + let retry = manager.create(create_request, &operation_id).await.unwrap(); + let status = manager.inspect(&reservation.execution_id).await.unwrap(); + let record = persisted(&manager, &reservation.execution_id); + + assert_eq!(retry.execution_id, reservation.execution_id); + assert_eq!(status.state, ExecutionState::Created); + assert_eq!(record.status, "created"); + assert_eq!( + record.managed_state().unwrap(), + Some(ManagedExecutionState::Created) + ); + assert!(!record.is_active()); + assert_eq!(backend.starts.load(Ordering::Relaxed), 0); + + let stale = manager + .start( + &reservation.execution_id, + ExecutionGeneration::new(2).unwrap(), + ) + .await; + assert!(matches!(stale, Err(ExecutionManagerError::Conflict { .. }))); + + let lease = manager + .start(&reservation.execution_id, reservation.generation) + .await + .unwrap(); + let repeated = manager + .start(&reservation.execution_id, reservation.generation) + .await + .unwrap(); + + assert_eq!(lease.execution_id, reservation.execution_id); + assert_eq!(repeated.execution_id, reservation.execution_id); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); + assert_eq!( + manager + .inspect(&reservation.execution_id) + .await + .unwrap() + .state, + ExecutionState::Running + ); +} + +#[tokio::test] +async fn create_preserves_complete_caller_record_policy() { + let (_directory, manager, backend) = harness(); + let mut create_request = request("sandbox-1"); + create_request.policy = ExecutionRecordPolicy { + name: Some("sdk-worker".to_string()), + auto_remove: true, + restart_policy: ExecutionRestartPolicy::OnFailure, + max_restart_count: 4, + health_check: Some(ExecutionHealthCheck { + cmd: vec!["test".to_string(), "-f".to_string(), "/ready".to_string()], + interval_secs: 11, + timeout_secs: 3, + retries: 7, + start_period_secs: 5, + }), + healthcheck_disabled: false, + log_config: a3s_box_core::log::LogConfig { + driver: a3s_box_core::log::LogDriver::None, + options: HashMap::from([("tag".to_string(), "worker".to_string())]), + }, + volume_names: vec!["workspace".to_string()], + platform: Some("linux/arm64".to_string()), + init: true, + devices: vec!["/dev/fuse:/dev/fuse".to_string()], + gpus: Some("all".to_string()), + shm_size: Some(64 * 1024 * 1024), + stop_signal: Some("SIGINT".to_string()), + stop_timeout: Some(12), + oom_kill_disable: true, + oom_score_adj: Some(100), + }; + + let reservation = manager + .create(create_request.clone(), &operation("operation-policy")) + .await + .unwrap(); + let record = persisted(&manager, &reservation.execution_id); + + assert_eq!(backend.starts.load(Ordering::Relaxed), 0); + assert_eq!(record.name, "sdk-worker"); + assert!(record.auto_remove); + assert_eq!(record.restart_policy, "on-failure"); + assert_eq!(record.max_restart_count, 4); + assert_eq!(record.health_check, create_request.policy.health_check); + assert_eq!(record.log_config, create_request.policy.log_config); + assert_eq!(record.volume_names, vec!["workspace"]); + assert_eq!(record.platform.as_deref(), Some("linux/arm64")); + assert!(record.init); + assert_eq!(record.devices, vec!["/dev/fuse:/dev/fuse"]); + assert_eq!(record.gpus.as_deref(), Some("all")); + assert_eq!(record.shm_size, Some(64 * 1024 * 1024)); + assert_eq!(record.stop_signal.as_deref(), Some("SIGINT")); + assert_eq!(record.stop_timeout, Some(12)); + assert!(record.oom_kill_disable); + assert_eq!(record.oom_score_adj, Some(100)); + assert_eq!( + record.managed_execution.as_ref().unwrap().request.policy, + create_request.policy + ); +} + +#[tokio::test] +async fn first_start_initializes_health_state_from_persisted_policy() { + let (_directory, manager, _backend) = harness(); + let mut create_request = request("sandbox-1"); + create_request.policy.health_check = Some(ExecutionHealthCheck { + cmd: vec!["test".to_string(), "-f".to_string(), "/ready".to_string()], + interval_secs: 11, + timeout_secs: 3, + retries: 7, + start_period_secs: 5, + }); + let reservation = manager + .create(create_request, &operation("operation-health")) + .await + .unwrap(); + + manager + .start(&reservation.execution_id, reservation.generation) + .await + .unwrap(); + + let record = persisted(&manager, &reservation.execution_id); + assert_eq!(record.health_status, "starting"); + assert_eq!(record.health_retries, 0); + assert!(record.health_last_check.is_none()); +} + +#[tokio::test] +async fn ordinary_start_never_revives_a_terminal_managed_execution() { + let (_directory, manager, backend) = harness(); + let reservation = manager + .create(request("sandbox-1"), &operation("operation-terminal")) + .await + .unwrap(); + let lease = manager + .start(&reservation.execution_id, reservation.generation) + .await + .unwrap(); + manager + .kill(&lease.execution_id, lease.generation) + .await + .unwrap(); + + let error = manager + .start(&lease.execution_id, lease.generation) + .await + .unwrap_err(); + + assert!(matches!(error, ExecutionManagerError::Conflict { .. })); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); + assert_eq!( + persisted(&manager, &lease.execution_id) + .managed_state() + .unwrap(), + Some(ManagedExecutionState::Stopped) + ); +} + +#[tokio::test] +async fn repeated_create_rejects_caller_policy_drift() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-policy-drift"); + let create_request = request("sandbox-1"); + manager + .create(create_request.clone(), &operation_id) + .await + .unwrap(); + let mut drifted = create_request; + drifted.policy.stop_timeout = Some(30); + + let error = manager.create(drifted, &operation_id).await.unwrap_err(); + + assert!(matches!(error, ExecutionManagerError::Conflict { .. })); + assert_eq!(backend.starts.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn reconciliation_reports_created_reservation_without_starting_it() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let reservation = manager + .create(request("sandbox-1"), &operation_id) + .await + .unwrap(); + + let outcome = manager.reconcile(&operation_id).await.unwrap(); + + let ReconcileOutcome::Created(recovered) = outcome else { + panic!("expected created reconciliation"); + }; + assert_eq!(recovered.execution_id, reservation.execution_id); + assert_eq!(recovered.generation, reservation.generation); + assert_eq!(backend.starts.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn legacy_creating_reservation_remains_recoverable_after_upgrade() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let reservation = manager + .create(request("sandbox-1"), &operation_id) + .await + .unwrap(); + let created = persisted(&manager, &reservation.execution_id); + let starting = manager + .transition( + &created, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + RuntimeUpdate::None, + ) + .await + .unwrap(); + manager + .transition( + &starting, + ManagedExecutionState::Starting, + ManagedExecutionState::Creating, + RuntimeUpdate::None, + ) + .await + .unwrap(); + + let outcome = manager.reconcile(&operation_id).await.unwrap(); + let ReconcileOutcome::Created(recovered) = outcome else { + panic!("expected legacy creating reservation to reconcile as created"); + }; + assert_eq!(recovered.execution_id, reservation.execution_id); + assert_eq!(backend.starts.load(Ordering::Relaxed), 0); + + manager + .start(&recovered.execution_id, recovered.generation) + .await + .unwrap(); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn repeated_create_is_idempotent_and_request_drift_conflicts() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let create_request = request("sandbox-1"); + + let first = manager + .create_and_start(create_request.clone(), &operation_id) + .await + .unwrap(); + let retry = manager + .create_and_start(create_request, &operation_id) + .await + .unwrap(); + let drift = manager + .create_and_start(request("sandbox-2"), &operation_id) + .await; + + assert_eq!(retry.execution_id, first.execution_id); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); + assert!(matches!(drift, Err(ExecutionManagerError::Conflict { .. }))); +} + +#[tokio::test] +async fn concurrent_create_calls_start_one_internal_execution() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let create_request = request("sandbox-1"); + + let (left, right) = tokio::join!( + manager.create_and_start(create_request.clone(), &operation_id), + manager.create_and_start(create_request.clone(), &operation_id), + ); + let retry = manager + .create_and_start(create_request, &operation_id) + .await + .unwrap(); + + let successes: Vec<_> = [left, right].into_iter().filter_map(Result::ok).collect(); + assert!(!successes.is_empty()); + assert!(successes + .iter() + .all(|lease| lease.execution_id == retry.execution_id)); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn pause_and_resume_are_generation_fenced_and_persist_policy() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let running = manager + .create_and_start(request("sandbox-1"), &operation_id) + .await + .unwrap(); + + let paused = manager + .pause(&running.execution_id, running.generation, true) + .await + .unwrap(); + let stale = manager + .resume(&running.execution_id, running.generation) + .await; + let resumed = manager + .resume(&paused.execution_id, paused.generation) + .await + .unwrap(); + + assert_eq!(paused.generation, ExecutionGeneration::new(2).unwrap()); + assert_eq!(resumed.generation, ExecutionGeneration::new(3).unwrap()); + assert!(matches!(stale, Err(ExecutionManagerError::Conflict { .. }))); + assert_eq!(*backend.last_keep_memory.lock().unwrap(), Some(true)); + assert_eq!(backend.pauses.load(Ordering::Relaxed), 1); + assert_eq!(backend.resumes.load(Ordering::Relaxed), 1); + let record = persisted(&manager, &running.execution_id); + assert_eq!( + record.managed_state().unwrap(), + Some(ManagedExecutionState::Running) + ); + assert!(record + .managed_execution + .unwrap() + .pending_operation + .is_none()); +} + +#[tokio::test] +async fn failed_pause_rolls_back_without_changing_backend_or_generation() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-1")) + .await + .unwrap(); + backend.fail_pause.store(true, Ordering::Relaxed); + + let error = manager + .pause(&running.execution_id, running.generation, false) + .await + .unwrap_err(); + + assert!(matches!(error, ExecutionManagerError::Unavailable(_))); + let record = persisted(&manager, &running.execution_id); + assert_eq!( + record.managed_state().unwrap(), + Some(ManagedExecutionState::Running) + ); + assert_eq!( + record.managed_execution.unwrap().generation, + ExecutionGeneration::INITIAL + ); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn ambiguous_pause_error_uses_backend_evidence_and_publishes_success() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-1")) + .await + .unwrap(); + backend + .fail_pause_after_effect + .store(true, Ordering::Relaxed); + + let paused = manager + .pause(&running.execution_id, running.generation, true) + .await + .unwrap(); + + assert_eq!(paused.generation, ExecutionGeneration::new(2).unwrap()); + assert_eq!( + persisted(&manager, &running.execution_id) + .managed_state() + .unwrap(), + Some(ManagedExecutionState::Paused) + ); +} + +#[tokio::test] +async fn kill_is_generation_fenced_and_idempotent() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-1")) + .await + .unwrap(); + + let killed = manager + .kill(&running.execution_id, running.generation) + .await + .unwrap(); + let repeated = manager + .kill(&running.execution_id, running.generation) + .await + .unwrap(); + + assert_eq!(killed, KillOutcome::Killed); + assert_eq!(repeated, KillOutcome::AlreadyStopped); + assert_eq!(backend.kills.load(Ordering::Relaxed), 1); + assert_eq!( + manager.inspect(&running.execution_id).await.unwrap().state, + ExecutionState::Stopped + ); +} + +#[tokio::test] +async fn startup_reconciliation_restarts_a_claim_without_backend_evidence() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let execution_id = ExecutionId::new("execution-recovery-1").unwrap(); + reserve_starting(&manager, &execution_id, &operation_id).await; + + let outcome = manager.reconcile(&operation_id).await.unwrap(); + + let ReconcileOutcome::Ready(lease) = outcome else { + panic!("expected ready reconciliation"); + }; + assert_eq!(lease.execution_id, execution_id); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn startup_reconciliation_publishes_an_already_started_backend_once() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let execution_id = ExecutionId::new("execution-recovery-1").unwrap(); + let starting = reserve_starting(&manager, &execution_id, &operation_id).await; + backend.start(&starting).await.unwrap(); + + let outcome = manager.reconcile(&operation_id).await.unwrap(); + + let ReconcileOutcome::Ready(lease) = outcome else { + panic!("expected ready reconciliation"); + }; + assert_eq!(lease.execution_id, execution_id); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn reconciliation_completes_pause_after_backend_side_effect() { + let (_directory, manager, backend) = harness(); + let operation_id = operation("operation-1"); + let running = manager + .create_and_start(request("sandbox-1"), &operation_id) + .await + .unwrap(); + let record = persisted(&manager, &running.execution_id); + let pausing = manager + .transition( + &record, + ManagedExecutionState::Running, + ManagedExecutionState::Pausing, + RuntimeUpdate::PauseClaim(true), + ) + .await + .unwrap(); + backend.pause(&pausing, true).await.unwrap(); + + let outcome = manager.reconcile(&operation_id).await.unwrap(); + + let ReconcileOutcome::Ready(lease) = outcome else { + panic!("expected ready reconciliation"); + }; + assert_eq!(lease.generation, ExecutionGeneration::new(2).unwrap()); + assert_eq!(backend.pauses.load(Ordering::Relaxed), 1); + assert_eq!( + manager.inspect(&running.execution_id).await.unwrap().state, + ExecutionState::Paused + ); +} + +#[tokio::test] +async fn inspection_persists_an_external_terminal_observation() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-1")) + .await + .unwrap(); + backend.stop_externally(&running.execution_id, 7); + + let status = manager.inspect(&running.execution_id).await.unwrap(); + let record = persisted(&manager, &running.execution_id); + + assert_eq!(status.state, ExecutionState::Stopped); + assert_eq!(record.exit_code, Some(7)); + assert_eq!(record.pid, None); + assert_eq!( + record.managed_state().unwrap(), + Some(ManagedExecutionState::Stopped) + ); +} + +#[tokio::test] +async fn inspection_releases_resources_after_an_external_terminal_observation() { + use a3s_box_core::{network::NetworkConfig, volume::VolumeConfig}; + + let (_directory, manager, backend) = harness(); + let volumes = crate::VolumeStore::new( + manager.home_dir.join("volumes.json"), + manager.home_dir.join("volumes"), + ); + volumes.create(VolumeConfig::new("workspace", "")).unwrap(); + let networks = crate::NetworkStore::new(manager.home_dir.join("networks.json")); + networks + .create(NetworkConfig::new("dev", "10.88.0.0/24").unwrap()) + .unwrap(); + + let mut create_request = request("sandbox-1"); + create_request.config.isolation = ExecutionIsolation::Microvm; + create_request.config.network = NetworkMode::Bridge { + network: "dev".to_string(), + }; + create_request.policy.name = Some("terminal-resources".to_string()); + create_request.policy.volume_names = vec!["workspace".to_string()]; + let running = manager + .create_and_start(create_request, &operation("operation-terminal-resources")) + .await + .unwrap(); + volumes + .modify("workspace", |volume| { + volume.attach(running.execution_id.as_str()) + }) + .unwrap(); + networks + .with_write_lock(|entries| -> Result<(), a3s_box_core::BoxError> { + entries + .get_mut("dev") + .unwrap() + .connect(running.execution_id.as_str(), "terminal-resources") + .map_err(a3s_box_core::BoxError::NetworkError)?; + Ok(()) + }) + .unwrap(); + + backend.stop_externally(&running.execution_id, 0); + manager.inspect(&running.execution_id).await.unwrap(); + + assert!(volumes + .get("workspace") + .unwrap() + .unwrap() + .in_use_by + .is_empty()); + assert!(!networks + .get("dev") + .unwrap() + .unwrap() + .endpoints + .contains_key(running.execution_id.as_str())); +} + +#[tokio::test] +async fn restart_running_execution_advances_generation_once_and_is_idempotent() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-create")) + .await + .unwrap(); + let restart_operation = operation("operation-restart"); + + let restarted = manager + .restart( + &running.execution_id, + running.generation, + &restart_operation, + ) + .await + .unwrap(); + let retry = manager + .restart( + &running.execution_id, + running.generation, + &restart_operation, + ) + .await + .unwrap(); + + assert_eq!(restarted.generation, ExecutionGeneration::new(2).unwrap()); + assert_eq!(retry.generation, restarted.generation); + assert_eq!(backend.kills.load(Ordering::Relaxed), 1); + assert_eq!(backend.starts.load(Ordering::Relaxed), 2); + let record = persisted(&manager, &running.execution_id); + assert_eq!( + record.managed_state().unwrap(), + Some(ManagedExecutionState::Running) + ); + let completed = record + .managed_execution + .unwrap() + .last_restart + .expect("completed restart must remain durable for idempotent retries"); + assert_eq!(completed.operation_id, restart_operation); + assert_eq!(completed.source_generation, running.generation); + assert_eq!(completed.target_generation, restarted.generation); +} + +#[tokio::test] +async fn stale_restart_generation_has_no_backend_side_effects() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-create")) + .await + .unwrap(); + + let error = manager + .restart( + &running.execution_id, + ExecutionGeneration::new(2).unwrap(), + &operation("operation-stale-restart"), + ) + .await + .unwrap_err(); + + assert!(matches!(error, ExecutionManagerError::Conflict { .. })); + assert_eq!(backend.kills.load(Ordering::Relaxed), 0); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn restart_persists_stop_options_and_rejects_retry_drift() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-create")) + .await + .unwrap(); + let restart_operation = operation("operation-restart"); + let options = RestartExecutionOptions { + stop_timeout_secs: Some(7), + }; + + manager + .restart_with_options( + &running.execution_id, + running.generation, + &restart_operation, + options, + ) + .await + .unwrap(); + let starts_after_restart = backend.starts.load(Ordering::Relaxed); + let drift = manager + .restart_with_options( + &running.execution_id, + running.generation, + &restart_operation, + RestartExecutionOptions { + stop_timeout_secs: Some(8), + }, + ) + .await + .unwrap_err(); + + assert!(matches!(drift, ExecutionManagerError::Conflict { .. })); + assert_eq!(backend.starts.load(Ordering::Relaxed), starts_after_restart); + assert_eq!(*backend.last_restart_timeout.lock().unwrap(), Some(Some(7))); + assert_eq!( + persisted(&manager, &running.execution_id) + .managed_execution + .unwrap() + .last_restart + .unwrap() + .stop_timeout_secs, + Some(7) + ); +} + +#[tokio::test] +async fn restart_supports_paused_stopped_and_failed_executions() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("paused"), &operation("create-paused")) + .await + .unwrap(); + let paused = manager + .pause(&running.execution_id, running.generation, true) + .await + .unwrap(); + let restarted_paused = manager + .restart( + &paused.execution_id, + paused.generation, + &operation("restart-paused"), + ) + .await + .unwrap(); + assert_eq!( + restarted_paused.generation, + ExecutionGeneration::new(3).unwrap() + ); + + let stopped = manager + .create_and_start(request("stopped"), &operation("create-stopped")) + .await + .unwrap(); + manager + .kill(&stopped.execution_id, stopped.generation) + .await + .unwrap(); + let kills_before_stopped_restart = backend.kills.load(Ordering::Relaxed); + let restarted_stopped = manager + .restart( + &stopped.execution_id, + stopped.generation, + &operation("restart-stopped"), + ) + .await + .unwrap(); + assert_eq!( + restarted_stopped.generation, + ExecutionGeneration::new(2).unwrap() + ); + assert_eq!( + backend.kills.load(Ordering::Relaxed), + kills_before_stopped_restart + ); + + let failed = manager + .create_and_start(request("failed"), &operation("create-failed")) + .await + .unwrap(); + backend.fail_externally(&failed.execution_id, 17); + assert_eq!( + manager.inspect(&failed.execution_id).await.unwrap().state, + ExecutionState::Failed + ); + let kills_before_failed_restart = backend.kills.load(Ordering::Relaxed); + let restarted_failed = manager + .restart( + &failed.execution_id, + failed.generation, + &operation("restart-failed"), + ) + .await + .unwrap(); + assert_eq!( + restarted_failed.generation, + ExecutionGeneration::new(2).unwrap() + ); + assert_eq!( + backend.kills.load(Ordering::Relaxed), + kills_before_failed_restart + ); +} + +#[tokio::test] +async fn restart_of_an_unstarted_reservation_starts_generation_two_without_kill() { + let (_directory, manager, backend) = harness(); + let created = manager + .create(request("created"), &operation("create-created")) + .await + .unwrap(); + + let restarted = manager + .restart( + &created.execution_id, + created.generation, + &operation("restart-created"), + ) + .await + .unwrap(); + + assert_eq!(restarted.generation, ExecutionGeneration::new(2).unwrap()); + assert_eq!(backend.kills.load(Ordering::Relaxed), 0); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn restart_recovers_when_kill_succeeded_but_its_response_was_lost() { + let (directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-create")) + .await + .unwrap(); + let restart_operation = operation("operation-restart"); + let record = persisted(&manager, &running.execution_id); + let claimed = manager + .transition( + &record, + ManagedExecutionState::Running, + ManagedExecutionState::RestartStopping, + RuntimeUpdate::RestartClaim { + operation_id: restart_operation.clone(), + options: Default::default(), + }, + ) + .await + .unwrap(); + backend + .fail_kill_after_effect + .store(true, Ordering::Relaxed); + assert!(backend.kill(&claimed).await.is_err()); + backend + .fail_kill_after_effect + .store(false, Ordering::Relaxed); + + let restarted_manager = LocalExecutionManager::new( + directory.path().join("boxes.json"), + directory.path().join("home"), + backend.clone(), + ); + let restarted = restarted_manager + .restart( + &running.execution_id, + running.generation, + &restart_operation, + ) + .await + .unwrap(); + + assert_eq!(restarted.generation, ExecutionGeneration::new(2).unwrap()); + assert_eq!(backend.starts.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn restart_recovers_after_generation_advance_before_backend_start() { + let (directory, manager, backend) = harness(); + let create_operation = operation("operation-create"); + let running = manager + .create_and_start(request("sandbox-1"), &create_operation) + .await + .unwrap(); + let restart_operation = operation("operation-restart"); + let record = persisted(&manager, &running.execution_id); + let claimed = manager + .transition( + &record, + ManagedExecutionState::Running, + ManagedExecutionState::RestartStopping, + RuntimeUpdate::RestartClaim { + operation_id: restart_operation.clone(), + options: Default::default(), + }, + ) + .await + .unwrap(); + backend.kill(&claimed).await.unwrap(); + let restarting = manager + .transition( + &claimed, + ManagedExecutionState::RestartStopping, + ManagedExecutionState::RestartStarting, + RuntimeUpdate::RestartAdvance, + ) + .await + .unwrap(); + assert_eq!( + restarting.managed_execution.as_ref().unwrap().generation, + ExecutionGeneration::new(2).unwrap() + ); + + let restarted_manager = LocalExecutionManager::new( + directory.path().join("boxes.json"), + directory.path().join("home"), + backend.clone(), + ); + let ReconcileOutcome::Ready(restarted) = restarted_manager + .reconcile(&create_operation) + .await + .unwrap() + else { + panic!("expected restart reconciliation to return a ready lease"); + }; + + assert_eq!(restarted.generation, ExecutionGeneration::new(2).unwrap()); + assert_eq!(backend.starts.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn concurrent_retries_of_one_restart_start_the_new_generation_once() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-create")) + .await + .unwrap(); + let restart_operation = operation("operation-restart"); + + let (left, right) = tokio::join!( + manager.restart( + &running.execution_id, + running.generation, + &restart_operation, + ), + manager.restart( + &running.execution_id, + running.generation, + &restart_operation, + ), + ); + + let successes = [left, right] + .into_iter() + .filter_map(Result::ok) + .collect::>(); + assert_eq!(successes.len(), 2); + assert!(successes + .iter() + .all(|lease| lease.generation == ExecutionGeneration::new(2).unwrap())); + assert_eq!(backend.starts.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn a_different_operation_cannot_take_over_an_in_progress_restart() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-create")) + .await + .unwrap(); + let record = persisted(&manager, &running.execution_id); + manager + .transition( + &record, + ManagedExecutionState::Running, + ManagedExecutionState::RestartStopping, + RuntimeUpdate::RestartClaim { + operation_id: operation("restart-owner"), + options: Default::default(), + }, + ) + .await + .unwrap(); + + let error = manager + .restart( + &running.execution_id, + running.generation, + &operation("restart-contender"), + ) + .await + .unwrap_err(); + + assert!(matches!(error, ExecutionManagerError::Conflict { .. })); + assert_eq!(backend.kills.load(Ordering::Relaxed), 0); + assert_eq!(backend.starts.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn ambiguous_restart_start_error_uses_backend_evidence() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-create")) + .await + .unwrap(); + backend + .fail_start_after_effect + .store(true, Ordering::Relaxed); + + let restarted = manager + .restart( + &running.execution_id, + running.generation, + &operation("operation-restart"), + ) + .await + .unwrap(); + + assert_eq!(restarted.generation, ExecutionGeneration::new(2).unwrap()); + assert_eq!(backend.starts.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn failed_restart_start_is_terminal_at_the_new_generation() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("sandbox-1"), &operation("operation-create")) + .await + .unwrap(); + manager + .kill(&running.execution_id, running.generation) + .await + .unwrap(); + backend.fail_start.store(true, Ordering::Relaxed); + let restart_operation = operation("operation-restart"); + + let error = manager + .restart( + &running.execution_id, + running.generation, + &restart_operation, + ) + .await + .unwrap_err(); + let attempts_after_failure = backend.starts.load(Ordering::Relaxed); + let retry = manager + .restart( + &running.execution_id, + running.generation, + &restart_operation, + ) + .await + .unwrap_err(); + + assert!(matches!(error, ExecutionManagerError::Unavailable(_))); + assert!(matches!(retry, ExecutionManagerError::Conflict { .. })); + assert_eq!( + backend.starts.load(Ordering::Relaxed), + attempts_after_failure + ); + let record = persisted(&manager, &running.execution_id); + assert_eq!( + record.managed_state().unwrap(), + Some(ManagedExecutionState::Failed) + ); + assert_eq!( + record.managed_execution.as_ref().unwrap().generation, + ExecutionGeneration::new(2).unwrap() + ); + assert_eq!( + record + .managed_execution + .unwrap() + .last_restart + .unwrap() + .outcome, + crate::ManagedRestartOutcome::Failed + ); +} + +fn populate_rootfs(manager: &LocalExecutionManager, execution_id: &ExecutionId, value: &str) { + let rootfs = persisted(manager, execution_id).box_dir.join("rootfs"); + std::fs::create_dir_all(rootfs.join("workspace")).unwrap(); + std::fs::write(rootfs.join("workspace/state.txt"), value).unwrap(); +} + +#[tokio::test] +async fn filesystem_snapshot_quiesces_and_restores_without_changing_generation() { + let (directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("snapshot-source"), &operation("snapshot-create")) + .await + .unwrap(); + populate_rootfs(&manager, &running.execution_id, "captured-state"); + let snapshot_id = ExecutionSnapshotId::new("managed-snapshot-1").unwrap(); + + let snapshot = manager + .create_filesystem_snapshot(&running.execution_id, running.generation, &snapshot_id) + .await + .unwrap(); + + assert_eq!(snapshot.lease.generation, running.generation); + assert_eq!(snapshot.state, ExecutionState::Running); + assert!(snapshot.size_bytes > 0); + assert_eq!(backend.pauses.load(Ordering::Relaxed), 1); + assert_eq!(backend.resumes.load(Ordering::Relaxed), 1); + assert_eq!(*backend.last_keep_memory.lock().unwrap(), Some(true)); + assert_eq!( + std::fs::read_to_string( + directory + .path() + .join("home/snapshots/managed-snapshot-1/rootfs/workspace/state.txt") + ) + .unwrap(), + "captured-state" + ); + assert_eq!( + persisted(&manager, &running.execution_id) + .managed_execution + .unwrap() + .generation, + running.generation + ); + + let retry = manager + .create_filesystem_snapshot(&running.execution_id, running.generation, &snapshot_id) + .await + .unwrap(); + assert_eq!(retry.size_bytes, snapshot.size_bytes); + assert_eq!(backend.pauses.load(Ordering::Relaxed), 1); + assert_eq!(backend.resumes.load(Ordering::Relaxed), 1); + assert!(manager + .delete_filesystem_snapshot(&snapshot_id) + .await + .unwrap()); + assert_eq!( + manager + .filesystem_snapshot_size(&snapshot_id) + .await + .unwrap(), + None + ); +} + +#[tokio::test] +async fn filesystem_snapshot_after_manager_restart_keeps_resolved_image_config() { + let (directory, manager, backend) = harness(); + let running = manager + .create_and_start( + request("snapshot-image-config"), + &operation("snapshot-image-config-create"), + ) + .await + .unwrap(); + populate_rootfs(&manager, &running.execution_id, "captured-state"); + + let restarted = LocalExecutionManager::new( + directory.path().join("boxes.json"), + directory.path().join("home"), + backend, + ); + let snapshot_id = ExecutionSnapshotId::new("snapshot-image-config").unwrap(); + restarted + .create_filesystem_snapshot(&running.execution_id, running.generation, &snapshot_id) + .await + .unwrap(); + + let metadata = crate::SnapshotStore::new(&directory.path().join("home/snapshots")) + .unwrap() + .get(snapshot_id.as_str()) + .unwrap() + .unwrap(); + let image_config = metadata + .image_config + .expect("resolved image configuration must survive a control-plane restart"); + assert_eq!( + image_config.entrypoint, + Some(vec!["/usr/local/bin/envd".to_string()]) + ); + assert_eq!( + image_config.cmd, + Some(vec!["--port".to_string(), "49983".to_string()]) + ); + assert_eq!(image_config.working_dir.as_deref(), Some("/home/user")); + assert_eq!(image_config.user.as_deref(), Some("1000:1000")); +} + +#[tokio::test] +async fn paused_snapshot_remains_paused_and_does_not_resume() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start(request("paused-source"), &operation("paused-create")) + .await + .unwrap(); + let paused = manager + .pause(&running.execution_id, running.generation, true) + .await + .unwrap(); + populate_rootfs(&manager, &running.execution_id, "paused-state"); + let pauses_before = backend.pauses.load(Ordering::Relaxed); + let resumes_before = backend.resumes.load(Ordering::Relaxed); + + let snapshot = manager + .create_filesystem_snapshot( + &running.execution_id, + paused.generation, + &ExecutionSnapshotId::new("paused-snapshot").unwrap(), + ) + .await + .unwrap(); + + assert_eq!(snapshot.state, ExecutionState::Paused); + assert_eq!(snapshot.lease.generation, paused.generation); + assert_eq!(backend.pauses.load(Ordering::Relaxed), pauses_before); + assert_eq!(backend.resumes.load(Ordering::Relaxed), resumes_before); +} + +#[tokio::test] +async fn snapshot_failure_restores_running_state_at_the_same_generation() { + let (_directory, manager, backend) = harness(); + let running = manager + .create_and_start( + request("missing-rootfs"), + &operation("missing-rootfs-create"), + ) + .await + .unwrap(); + let snapshot_id = ExecutionSnapshotId::new("missing-rootfs-snapshot").unwrap(); + + let error = manager + .create_filesystem_snapshot(&running.execution_id, running.generation, &snapshot_id) + .await + .unwrap_err(); + + assert!(matches!(error, ExecutionManagerError::Unavailable(_))); + assert_eq!(backend.pauses.load(Ordering::Relaxed), 1); + assert_eq!(backend.resumes.load(Ordering::Relaxed), 1); + let record = persisted(&manager, &running.execution_id); + assert_eq!( + record.managed_state().unwrap(), + Some(ManagedExecutionState::Running) + ); + assert_eq!( + record.managed_execution.unwrap().generation, + running.generation + ); + assert_eq!( + manager + .filesystem_snapshot_size(&snapshot_id) + .await + .unwrap(), + None + ); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn special_file_snapshot_failure_resumes_running_source() { + use std::os::unix::ffi::OsStrExt; + + let (directory, manager, backend) = harness(); + let running = manager + .create_and_start( + request("special-file-source"), + &operation("special-file-create"), + ) + .await + .unwrap(); + populate_rootfs(&manager, &running.execution_id, "still-running"); + let rootfs = persisted(&manager, &running.execution_id) + .box_dir + .join("rootfs"); + let fifo = rootfs.join("workspace/blocking-fifo"); + let fifo_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }, 0); + let snapshot_id = ExecutionSnapshotId::new("special-file-snapshot").unwrap(); + + let error = manager + .create_filesystem_snapshot(&running.execution_id, running.generation, &snapshot_id) + .await + .unwrap_err(); + + assert!(matches!( + &error, + ExecutionManagerError::Unavailable(message) + if message.contains("unsupported special file") && message.contains("fifo") + )); + assert_eq!(backend.pauses.load(Ordering::Relaxed), 1); + assert_eq!(backend.resumes.load(Ordering::Relaxed), 1); + assert_eq!( + manager.inspect(&running.execution_id).await.unwrap().state, + ExecutionState::Running + ); + assert_eq!( + manager + .filesystem_snapshot_size(&snapshot_id) + .await + .unwrap(), + None + ); + assert!(std::fs::read_dir(directory.path().join("home/snapshots")) + .unwrap() + .flatten() + .all(|entry| !entry.file_name().to_string_lossy().starts_with(".staging-"))); +} + +#[tokio::test] +async fn reconcile_recovers_a_crash_after_snapshot_pause() { + let (directory, manager, backend) = harness(); + let create_operation = operation("recovered-snapshot-create"); + let running = manager + .create_and_start(request("recovered-source"), &create_operation) + .await + .unwrap(); + populate_rootfs(&manager, &running.execution_id, "recovered-state"); + let snapshot_id = ExecutionSnapshotId::new("recovered-snapshot").unwrap(); + let record = persisted(&manager, &running.execution_id); + let claimed = manager + .transition( + &record, + ManagedExecutionState::Running, + ManagedExecutionState::Snapshotting, + RuntimeUpdate::SnapshotClaim { + snapshot_id: snapshot_id.clone(), + source_state: ManagedExecutionState::Running, + }, + ) + .await + .unwrap(); + backend.pause(&claimed, true).await.unwrap(); + + let restarted = LocalExecutionManager::new( + directory.path().join("boxes.json"), + directory.path().join("home"), + backend.clone(), + ); + let ReconcileOutcome::Ready(lease) = restarted.reconcile(&create_operation).await.unwrap() + else { + panic!("expected snapshot reconciliation to return a ready lease"); + }; + + assert_eq!(lease.generation, running.generation); + assert_eq!(backend.resumes.load(Ordering::Relaxed), 1); + assert_eq!( + persisted(&restarted, &running.execution_id) + .managed_state() + .unwrap(), + Some(ManagedExecutionState::Running) + ); + assert!(restarted + .filesystem_snapshot_size(&snapshot_id) + .await + .unwrap() + .is_some()); +} + +#[tokio::test] +async fn legacy_snapshot_without_image_config_is_rejected_before_reservation() { + let (directory, manager, _backend) = harness(); + let source = directory.path().join("legacy-snapshot-source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("state.txt"), "captured").unwrap(); + let snapshot_id = ExecutionSnapshotId::new("legacy-snapshot").unwrap(); + crate::SnapshotStore::new(&directory.path().join("home/snapshots")) + .unwrap() + .save( + a3s_box_core::SnapshotMetadata::new( + snapshot_id.to_string(), + snapshot_id.to_string(), + "source-execution".to_string(), + "alpine:3.20".to_string(), + ), + &source, + ) + .unwrap(); + let mut restore = request("legacy-snapshot-restore"); + restore.rootfs_snapshot_id = Some(snapshot_id); + let operation_id = operation("legacy-snapshot-restore-create"); + + let error = manager.create(restore, &operation_id).await.unwrap_err(); + + assert!(matches!( + &error, + ExecutionManagerError::Unavailable(message) + if message.contains("resolved OCI image configuration") + )); + assert!(manager + .get_by_operation(&operation_id) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn snapshot_delete_refuses_an_unstarted_restored_execution() { + let (_directory, manager, _backend) = harness(); + let running = manager + .create_and_start(request("delete-source"), &operation("delete-source-create")) + .await + .unwrap(); + populate_rootfs(&manager, &running.execution_id, "delete-state"); + let snapshot_id = ExecutionSnapshotId::new("delete-protected-snapshot").unwrap(); + manager + .create_filesystem_snapshot(&running.execution_id, running.generation, &snapshot_id) + .await + .unwrap(); + let mut restored_request = request("restored-reservation"); + restored_request.rootfs_snapshot_id = Some(snapshot_id.clone()); + let restored = manager + .create(restored_request, &operation("restored-reservation-create")) + .await + .unwrap(); + + assert!(matches!( + manager.delete_filesystem_snapshot(&snapshot_id).await, + Err(ExecutionManagerError::Conflict { .. }) + )); + let record = persisted(&manager, &restored.execution_id); + manager + .transition( + &record, + ManagedExecutionState::Created, + ManagedExecutionState::Stopped, + RuntimeUpdate::Terminal(None), + ) + .await + .unwrap(); + assert!(manager + .delete_filesystem_snapshot(&snapshot_id) + .await + .unwrap()); +} + +#[tokio::test] +async fn snapshot_delete_and_restored_reservation_are_atomic() { + let (_directory, manager, _backend) = harness(); + let running = manager + .create_and_start( + request("atomic-delete-source"), + &operation("atomic-delete-source-create"), + ) + .await + .unwrap(); + populate_rootfs(&manager, &running.execution_id, "atomic-delete-state"); + + for index in 0..16 { + let snapshot_id = + ExecutionSnapshotId::new(format!("atomic-delete-snapshot-{index}")).unwrap(); + manager + .create_filesystem_snapshot(&running.execution_id, running.generation, &snapshot_id) + .await + .unwrap(); + let mut restored_request = request(&format!("atomic-restored-{index}")); + restored_request.rootfs_snapshot_id = Some(snapshot_id.clone()); + let create_operation = operation(&format!("atomic-restored-create-{index}")); + let create_manager = manager.clone(); + let delete_manager = manager.clone(); + let delete_snapshot_id = snapshot_id.clone(); + + let (created, deleted) = tokio::join!( + create_manager.create(restored_request, &create_operation), + delete_manager.delete_filesystem_snapshot(&delete_snapshot_id), + ); + + match (created, deleted) { + (Ok(restored), Err(ExecutionManagerError::Conflict { .. })) => { + let record = persisted(&manager, &restored.execution_id); + manager + .transition( + &record, + ManagedExecutionState::Created, + ManagedExecutionState::Stopped, + RuntimeUpdate::Terminal(None), + ) + .await + .unwrap(); + assert!(manager + .delete_filesystem_snapshot(&snapshot_id) + .await + .unwrap()); + } + (Err(ExecutionManagerError::Unavailable(_)), Ok(true)) => { + assert!(matches!( + manager.reconcile(&create_operation).await.unwrap(), + ReconcileOutcome::Absent + )); + } + (created, deleted) => { + panic!( + "restored reservation and Snapshot deletion were not atomic: \ + create={created:?}, delete={deleted:?}" + ); + } + } + } +} diff --git a/src/runtime/src/local_execution/vm_backend.rs b/src/runtime/src/local_execution/vm_backend.rs new file mode 100644 index 00000000..70a17d23 --- /dev/null +++ b/src/runtime/src/local_execution/vm_backend.rs @@ -0,0 +1,758 @@ +//! Production local execution backend backed by [`crate::VmManager`]. + +#[path = "vm_sandbox.rs"] +mod sandbox; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +#[cfg(unix)] +use std::time::Duration; + +use a3s_box_core::{ + EventEmitter, ExecutionBackend, ExecutionId, ExecutionManagerError, ExecutionManagerResult, + ExecutionState, KillOutcome, DEFAULT_SHUTDOWN_TIMEOUT_MS, +}; +use async_trait::async_trait; +use dashmap::mapref::entry::Entry; +use dashmap::DashMap; +use tokio::sync::Mutex; + +use super::resources::ExecutionResourceGuard; +use super::vm_process::{locate_microvm_process, LocatedProcess}; +use super::{LocalExecutionBackend, LocalExecutionHandle, LocalExecutionObservation}; +use crate::{ + BoxRecord, ManagedExecutionMetadata, ManagedExecutionOperation, ManagedExecutionState, + VmManager, +}; + +type SharedVm = Arc>; + +/// Runtime adapter that owns live [`VmManager`] handles and reconstructs them +/// from durable runtime evidence after a control-plane restart. +#[derive(Clone)] +pub struct VmLocalExecutionBackend { + home_dir: PathBuf, + managers: Arc>, + pull_progress_fn: Option, +} + +impl VmLocalExecutionBackend { + pub fn new(home_dir: impl Into) -> Self { + Self { + home_dir: home_dir.into(), + managers: Arc::new(DashMap::new()), + pull_progress_fn: None, + } + } + + pub fn with_pull_progress_fn(mut self, pull_progress_fn: crate::PullProgressFn) -> Self { + self.pull_progress_fn = Some(pull_progress_fn); + self + } + + pub fn home_dir(&self) -> &Path { + &self.home_dir + } + + fn metadata<'a>( + &self, + record: &'a BoxRecord, + ) -> ExecutionManagerResult<&'a ManagedExecutionMetadata> { + uuid::Uuid::parse_str(&record.id).map_err(|error| { + ExecutionManagerError::Internal(format!( + "managed execution has an invalid internal ID {}: {error}", + record.id + )) + })?; + let expected_box_dir = self.home_dir.join("boxes").join(&record.id); + if record.box_dir != expected_box_dir { + return Err(ExecutionManagerError::Internal(format!( + "managed execution {} has an unexpected host directory {}", + record.id, + record.box_dir.display() + ))); + } + let metadata = record.managed_execution.as_ref().ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "execution {} lost managed lifecycle metadata", + record.id + )) + })?; + metadata + .validate() + .map_err(|error| ExecutionManagerError::Internal(error.to_string()))?; + if record.isolation != metadata.request.config.isolation { + return Err(ExecutionManagerError::Internal(format!( + "managed execution {} has inconsistent isolation metadata", + record.id + ))); + } + Ok(metadata) + } + + fn new_manager(&self, record: &BoxRecord) -> ExecutionManagerResult { + let metadata = self.metadata(record)?; + let mut config = metadata.request.config.clone(); + if let Some(shm_size) = metadata.request.policy.shm_size { + let has_shared_memory_mount = config + .tmpfs + .iter() + .any(|entry| entry.split(':').next() == Some("/dev/shm")); + if !has_shared_memory_mount { + config.tmpfs.push(format!("/dev/shm:size={shm_size}")); + } + } + let mut manager = VmManager::with_box_id(config, EventEmitter::new(256), record.id.clone()); + manager.home_dir = self.home_dir.clone(); + if let Some(pull_progress_fn) = self.pull_progress_fn.clone() { + manager.set_pull_progress_fn(pull_progress_fn); + } + manager.anonymous_volumes = record.anonymous_volumes.clone(); + manager.set_log_config(record.log_config.clone()); + manager.resolved_execution_plan = Some(metadata.plan.clone()); + Ok(manager) + } + + fn manager(&self, execution_id: &str) -> Option { + self.managers + .get(execution_id) + .map(|entry| Arc::clone(entry.value())) + } + + fn remove_manager(&self, execution_id: &str, expected: &SharedVm) { + if let Entry::Occupied(entry) = self.managers.entry(execution_id.to_string()) { + if Arc::ptr_eq(entry.get(), expected) { + entry.remove(); + } + } + } + + async fn handle_from_manager( + &self, + record: &BoxRecord, + manager: &VmManager, + ) -> ExecutionManagerResult { + let execution_id = execution_id(record)?; + let pid = manager.pid().await.ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "runtime returned no host PID for {execution_id}" + )) + })?; + let pid_start_time = crate::process::pid_start_time(pid); + #[cfg(target_os = "linux")] + if pid_start_time.is_none() { + return Err(ExecutionManagerError::NotFound(execution_id)); + } + if !crate::process::is_process_alive_with_identity(pid, pid_start_time) { + return Err(ExecutionManagerError::NotFound(execution_id)); + } + let exec_socket_path = manager + .exec_socket_path() + .map(Path::to_path_buf) + .ok_or_else(|| { + ExecutionManagerError::Internal(format!( + "runtime returned no exec socket for {}", + record.id + )) + })?; + let anonymous_volumes = if manager.anonymous_volumes().is_empty() { + self.anonymous_volumes_for_record(record).await + } else { + manager.anonymous_volumes().to_vec() + }; + Ok(LocalExecutionHandle { + started_at: record.started_at.unwrap_or_else(chrono::Utc::now), + pid: Some(pid), + pid_start_time, + exec_socket_path, + console_log: record.box_dir.join("logs/console.log"), + anonymous_volumes, + }) + } + + async fn inspect_registered( + &self, + record: &BoxRecord, + shared: SharedVm, + ) -> ExecutionManagerResult { + let mut manager = shared.lock().await; + let exit_code = manager + .try_wait_exit() + .await + .map_err(|error| runtime_error("inspect", record, error))?; + let mut state = manager.state().await; + let terminal = exit_code.is_some() || state == crate::BoxState::Stopped; + if terminal { + let cleanup = manager.destroy().await; + let exit_code = manager.exit_code().or(exit_code); + drop(manager); + self.remove_manager(&record.id, &shared); + cleanup.map_err(|error| runtime_error("clean up", record, error))?; + return Ok(LocalExecutionObservation { + state: ExecutionState::Stopped, + handle: None, + exit_code, + }); + } + + if state == crate::BoxState::Created { + if manager.has_exited().await { + let cleanup = manager.destroy().await; + let exit_code = manager.exit_code(); + drop(manager); + self.remove_manager(&record.id, &shared); + cleanup.map_err(|error| runtime_error("clean up", record, error))?; + return Ok(LocalExecutionObservation { + state: ExecutionState::Stopped, + handle: None, + exit_code, + }); + } + if !self.promote_if_ready(record, &mut manager).await { + return Ok(LocalExecutionObservation { + state: ExecutionState::Creating, + handle: None, + exit_code: None, + }); + } + state = manager.state().await; + } + + if !manager + .health_check() + .await + .map_err(|error| runtime_error("inspect", record, error))? + { + let cleanup = manager.destroy().await; + let exit_code = manager.exit_code(); + drop(manager); + self.remove_manager(&record.id, &shared); + cleanup.map_err(|error| runtime_error("clean up", record, error))?; + return Ok(LocalExecutionObservation { + state: ExecutionState::Stopped, + handle: None, + exit_code, + }); + } + + if state != crate::BoxState::Ready + && state != crate::BoxState::Busy + && state != crate::BoxState::Compacting + { + return Err(ExecutionManagerError::Internal(format!( + "runtime manager for {} is in unexpected state {state:?}", + record.id + ))); + } + if matches!( + managed_state(record)?, + ManagedExecutionState::Starting | ManagedExecutionState::RestartStarting + ) && !exec_endpoint_ready(manager.exec_socket_path()).await + { + return Ok(LocalExecutionObservation { + state: ExecutionState::Creating, + handle: None, + exit_code: None, + }); + } + let visible_state = visible_active_state(record)?; + let handle = self.handle_from_manager(record, &manager).await?; + Ok(LocalExecutionObservation { + state: visible_state, + handle: Some(handle), + exit_code: None, + }) + } + + async fn promote_if_ready(&self, record: &BoxRecord, manager: &mut VmManager) -> bool { + let socket_dir = crate::vm::runtime_socket_dir(&self.home_dir, &record.id); + let exec_socket = socket_dir.join("exec.sock"); + if !exec_endpoint_ready(Some(&exec_socket)).await { + return false; + } + manager.exec_socket_path = Some(exec_socket); + manager.pty_socket_path = Some(socket_dir.join("pty.sock")); + manager.port_forward_socket_path = Some(socket_dir.join("portfwd.sock")); + *manager.state.write().await = crate::BoxState::Ready; + true + } + + async fn recover_microvm(&self, record: &BoxRecord) -> ExecutionManagerResult { + self.metadata(record)?; + let execution_id = execution_id(record)?; + let execution_id_label = record.id.clone(); + let recorded = record.pid.map(|pid| (pid, record.pid_start_time)); + let located = tokio::task::spawn_blocking(move || { + locate_microvm_process(&execution_id_label, recorded) + }) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!( + "MicroVM process discovery task failed for {}: {error}", + record.id + )) + })? + .map_err(ExecutionManagerError::Internal)? + .ok_or(ExecutionManagerError::NotFound(execution_id))?; + self.attach_microvm(record, located).await + } + + async fn attach_microvm( + &self, + record: &BoxRecord, + located: LocatedProcess, + ) -> ExecutionManagerResult { + let mut manager = self.new_manager(record)?; + let socket_dir = crate::vm::runtime_socket_dir(&self.home_dir, &record.id); + manager + .attach_running_process( + located.pid, + socket_dir.join("exec.sock"), + Some(socket_dir.join("pty.sock")), + ) + .await + .map_err(|error| runtime_error("recover", record, error))?; + if located.start_time.is_some() + && crate::process::pid_start_time(located.pid) != located.start_time + { + return Err(ExecutionManagerError::NotFound(execution_id(record)?)); + } + let recovered = Arc::new(Mutex::new(manager)); + match self.managers.entry(record.id.clone()) { + Entry::Occupied(entry) => Ok(Arc::clone(entry.get())), + Entry::Vacant(entry) => { + entry.insert(Arc::clone(&recovered)); + Ok(recovered) + } + } + } + + async fn require_microvm(&self, record: &BoxRecord) -> ExecutionManagerResult { + match self.manager(&record.id) { + Some(manager) => Ok(manager), + None => self.recover_microvm(record).await, + } + } + + async fn destroy_registered( + &self, + record: &BoxRecord, + shared: SharedVm, + remove_anonymous_volumes: bool, + timeout_secs: Option, + ) -> ExecutionManagerResult { + let mut manager = shared.lock().await; + let mut anonymous_volumes = if manager.anonymous_volumes().is_empty() { + record.anonymous_volumes.clone() + } else { + manager.anonymous_volumes().to_vec() + }; + let result = match graceful_stop_options(record, timeout_secs)? { + Some((signal, timeout_ms)) => manager.destroy_with_options(signal, timeout_ms).await, + None => manager.destroy().await, + }; + drop(manager); + self.remove_manager(&record.id, &shared); + result.map_err(|error| runtime_error("kill", record, error))?; + if remove_anonymous_volumes { + if anonymous_volumes.is_empty() { + anonymous_volumes = self.anonymous_volumes_for_record(record).await; + } + self.cleanup_anonymous_volumes(anonymous_volumes).await; + } + Ok(KillOutcome::Killed) + } + + async fn anonymous_volumes_for_record(&self, record: &BoxRecord) -> Vec { + if !record.anonymous_volumes.is_empty() { + return record.anonymous_volumes.clone(); + } + let home_dir = self.home_dir.clone(); + let execution_id = record.id.clone(); + let short_id = record.id.chars().take(8).collect::(); + let result = tokio::task::spawn_blocking(move || -> a3s_box_core::Result> { + let store = + crate::VolumeStore::new(home_dir.join("volumes.json"), home_dir.join("volumes")); + let prefix = format!("anon_{short_id}_"); + let mut names = store + .load()? + .into_values() + .filter(|volume| { + volume + .labels + .get("anonymous") + .is_some_and(|value| value == "true") + && (volume.in_use_by.iter().any(|id| id == &execution_id) + || volume.name.starts_with(&prefix)) + }) + .map(|volume| volume.name) + .collect::>(); + names.sort(); + Ok(names) + }) + .await; + match result { + Ok(Ok(names)) => names, + Ok(Err(error)) => { + tracing::warn!( + execution_id = %record.id, + %error, + "Failed to load anonymous volumes during managed cleanup" + ); + Vec::new() + } + Err(error) => { + tracing::warn!( + execution_id = %record.id, + %error, + "Anonymous volume recovery task failed" + ); + Vec::new() + } + } + } + + async fn cleanup_anonymous_volumes(&self, names: Vec) { + if names.is_empty() { + return; + } + let home_dir = self.home_dir.clone(); + let task = tokio::task::spawn_blocking(move || { + let store = crate::VolumeStore::new( + home_dir.join("volumes.json"), + home_dir.join("volumes"), + ); + for name in names { + if let Err(error) = store.remove(&name, true) { + tracing::warn!(volume = %name, %error, "Failed to remove managed anonymous volume"); + } + } + }) + .await; + if let Err(error) = task { + tracing::warn!(%error, "Anonymous volume cleanup task failed"); + } + } +} + +#[async_trait] +impl LocalExecutionBackend for VmLocalExecutionBackend { + async fn start(&self, record: &BoxRecord) -> ExecutionManagerResult { + self.metadata(record)?; + let manager = Arc::new(Mutex::new(self.new_manager(record)?)); + match self.managers.entry(record.id.clone()) { + Entry::Occupied(_) => { + return Err(ExecutionManagerError::Unavailable(format!( + "execution {} already has an in-process runtime owner", + record.id + ))) + } + Entry::Vacant(entry) => { + entry.insert(Arc::clone(&manager)); + } + } + + let mut guard = manager.lock().await; + let resource_home = self.home_dir.clone(); + let resource_record = record.clone(); + let resources = match tokio::task::spawn_blocking(move || { + ExecutionResourceGuard::prepare(&resource_home, &resource_record) + }) + .await + { + Ok(Ok(resources)) => resources, + Ok(Err(error)) => { + drop(guard); + self.remove_manager(&record.id, &manager); + return Err(error); + } + Err(error) => { + drop(guard); + self.remove_manager(&record.id, &manager); + return Err(ExecutionManagerError::Internal(format!( + "managed resource preparation task failed for {}: {error}", + record.id + ))); + } + }; + if let Err(error) = guard.boot().await { + drop(guard); + self.remove_manager(&record.id, &manager); + let rollback = tokio::task::spawn_blocking(move || resources.rollback()).await; + if let Err(rollback_error) = rollback { + tracing::warn!( + execution_id = %record.id, + %rollback_error, + "Managed resource rollback task failed" + ); + } + return Err(runtime_error("start", record, error)); + } + resources.disarm(); + self.handle_from_manager(record, &guard).await + } + + async fn inspect( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult { + let metadata = self.metadata(record)?; + if metadata.plan.backend == ExecutionBackend::Crun { + return self.inspect_sandbox(record).await; + } + if let Some(manager) = self.manager(&record.id) { + return self.inspect_registered(record, manager).await; + } + let manager = self.recover_microvm(record).await?; + self.inspect_registered(record, manager).await + } + + async fn pause( + &self, + record: &BoxRecord, + keep_memory: bool, + ) -> ExecutionManagerResult { + let metadata = self.metadata(record)?; + if metadata.plan.backend == ExecutionBackend::Crun { + if !keep_memory { + return Err(unsupported( + record, + "pause without memory retention", + "the Sandbox backend", + )); + } + return self.pause_sandbox(record).await; + } + if !keep_memory { + return Err(unsupported( + record, + "pause without memory retention", + "the local MicroVM backend", + )); + } + let shared = self.require_microvm(record).await?; + let manager = shared.lock().await; + require_recorded_pid(record, &manager).await?; + manager + .pause() + .await + .map_err(|error| runtime_error("pause", record, error))?; + self.handle_from_manager(record, &manager).await + } + + async fn resume(&self, record: &BoxRecord) -> ExecutionManagerResult { + let metadata = self.metadata(record)?; + if metadata.plan.backend == ExecutionBackend::Crun { + return self.resume_sandbox(record).await; + } + let shared = self.require_microvm(record).await?; + let manager = shared.lock().await; + require_recorded_pid(record, &manager).await?; + manager + .resume() + .await + .map_err(|error| runtime_error("resume", record, error))?; + self.handle_from_manager(record, &manager).await + } + + async fn kill(&self, record: &BoxRecord) -> ExecutionManagerResult { + let metadata = self.metadata(record)?; + let remove_anonymous_volumes = record.auto_remove; + let timeout_secs = record.stop_timeout; + if let Some(manager) = self.manager(&record.id) { + return self + .destroy_registered(record, manager, remove_anonymous_volumes, timeout_secs) + .await; + } + match metadata.plan.backend { + ExecutionBackend::Crun => { + self.destroy_detached_sandbox(record, remove_anonymous_volumes, timeout_secs) + .await + } + ExecutionBackend::Krun => { + let manager = self.recover_microvm(record).await?; + self.destroy_registered(record, manager, remove_anonymous_volumes, timeout_secs) + .await + } + } + } + + async fn stop_for_restart( + &self, + record: &BoxRecord, + timeout_secs: Option, + ) -> ExecutionManagerResult { + let metadata = self.metadata(record)?; + let timeout_secs = timeout_secs.or(record.stop_timeout); + if let Some(manager) = self.manager(&record.id) { + return self + .destroy_registered(record, manager, false, timeout_secs) + .await; + } + match metadata.plan.backend { + ExecutionBackend::Crun => { + self.destroy_detached_sandbox(record, false, timeout_secs) + .await + } + ExecutionBackend::Krun => { + let manager = self.recover_microvm(record).await?; + self.destroy_registered(record, manager, false, timeout_secs) + .await + } + } + } +} + +fn graceful_stop_options( + record: &BoxRecord, + timeout_secs: Option, +) -> ExecutionManagerResult> { + if timeout_secs.is_none() && record.stop_signal.is_none() { + return Ok(None); + } + let timeout_ms = timeout_secs + .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT_MS / 1_000) + .checked_mul(1_000) + .ok_or_else(|| { + ExecutionManagerError::InvalidRequest(format!( + "stop timeout is too large for execution {}", + record.id + )) + })?; + let signal = record + .stop_signal + .as_deref() + .map(a3s_box_core::vmm::parse_signal_name) + .unwrap_or(libc::SIGTERM); + Ok(Some((signal, timeout_ms))) +} + +async fn require_recorded_pid( + record: &BoxRecord, + manager: &VmManager, +) -> ExecutionManagerResult<()> { + let execution_id = execution_id(record)?; + let pid = manager + .pid() + .await + .ok_or_else(|| ExecutionManagerError::NotFound(execution_id.clone()))?; + if record.pid != Some(pid) + || !crate::process::is_process_alive_with_identity(pid, record.pid_start_time) + { + return Err(ExecutionManagerError::NotFound(execution_id)); + } + Ok(()) +} + +fn visible_active_state(record: &BoxRecord) -> ExecutionManagerResult { + match managed_state(record)? { + ManagedExecutionState::Paused | ManagedExecutionState::Resuming => { + Ok(ExecutionState::Paused) + } + ManagedExecutionState::Starting + | ManagedExecutionState::RestartStarting + | ManagedExecutionState::Running + | ManagedExecutionState::Pausing + | ManagedExecutionState::Killing => Ok(ExecutionState::Running), + ManagedExecutionState::Snapshotting => match record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.pending_operation.as_ref()) + { + Some(ManagedExecutionOperation::Snapshot { + source_state: ManagedExecutionState::Running, + .. + }) => Ok(ExecutionState::Running), + Some(ManagedExecutionOperation::Snapshot { + source_state: ManagedExecutionState::Paused, + .. + }) => Ok(ExecutionState::Paused), + _ => Err(ExecutionManagerError::Internal(format!( + "execution {} has invalid snapshot metadata", + record.id + ))), + }, + ManagedExecutionState::RestartStopping => match record + .managed_execution + .as_ref() + .and_then(|metadata| metadata.pending_operation.as_ref()) + { + Some(ManagedExecutionOperation::Restart { + source_state: ManagedExecutionState::Paused, + .. + }) => Ok(ExecutionState::Paused), + Some(ManagedExecutionOperation::Restart { + source_state: ManagedExecutionState::Running, + .. + }) => Ok(ExecutionState::Running), + _ => Err(ExecutionManagerError::Internal(format!( + "execution {} has invalid restart teardown metadata", + record.id + ))), + }, + state => Err(ExecutionManagerError::Internal(format!( + "execution {} has no active runtime in managed state {state}", + record.id + ))), + } +} + +fn managed_state(record: &BoxRecord) -> ExecutionManagerResult { + record + .managed_state() + .map_err(|error| ExecutionManagerError::Internal(error.to_string()))? + .ok_or_else(|| { + ExecutionManagerError::Internal(format!("execution {} is not managed", record.id)) + }) +} + +fn execution_id(record: &BoxRecord) -> ExecutionManagerResult { + ExecutionId::new(record.id.clone()) + .map_err(|error| ExecutionManagerError::Internal(error.to_string())) +} + +fn runtime_error( + action: &str, + record: &BoxRecord, + error: impl std::fmt::Display, +) -> ExecutionManagerError { + ExecutionManagerError::Internal(format!( + "failed to {action} execution {}: {error}", + record.id + )) +} + +fn unsupported(record: &BoxRecord, operation: &str, backend: &str) -> ExecutionManagerError { + match execution_id(record) { + Ok(execution_id) => ExecutionManagerError::Conflict { + execution_id, + message: format!("{operation} is not supported by {backend}"), + }, + Err(error) => error, + } +} + +#[cfg(unix)] +async fn exec_endpoint_ready(path: Option<&Path>) -> bool { + let Some(path) = path else { + return false; + }; + let attempt = async { + let client = crate::ExecClient::connect(path).await.ok()?; + client.heartbeat().await.ok().filter(|ready| *ready) + }; + tokio::time::timeout(Duration::from_millis(500), attempt) + .await + .ok() + .flatten() + .is_some() +} + +#[cfg(not(unix))] +async fn exec_endpoint_ready(path: Option<&Path>) -> bool { + path.is_some() +} + +#[cfg(test)] +#[path = "vm_backend_tests.rs"] +mod tests; diff --git a/src/runtime/src/local_execution/vm_backend_tests.rs b/src/runtime/src/local_execution/vm_backend_tests.rs new file mode 100644 index 00000000..16a1cd0c --- /dev/null +++ b/src/runtime/src/local_execution/vm_backend_tests.rs @@ -0,0 +1,253 @@ +use std::collections::BTreeMap; + +use a3s_box_core::{ + volume::VolumeConfig, BoxConfig, CreateExecutionRequest, ExecutionGeneration, + ExecutionIsolation, OperationId, +}; + +use super::*; +use crate::local_execution::record::build_managed_record; + +fn record(home_dir: &Path, isolation: ExecutionIsolation) -> BoxRecord { + let id = ExecutionId::new("11111111-1111-4111-8111-111111111111").unwrap(); + let mut config = BoxConfig { + isolation, + image: "alpine:latest".to_string(), + dns: vec!["1.1.1.1".to_string()], + ..Default::default() + }; + if isolation == ExecutionIsolation::Microvm { + config.sysctls = vec![("net.ipv4.ip_forward".to_string(), "1".to_string())]; + } + config.resources.memory_mb = 256; + build_managed_record( + home_dir, + &id, + OperationId::new("operation-1").unwrap(), + CreateExecutionRequest { + external_sandbox_id: "external-untrusted-label".to_string(), + config, + labels: BTreeMap::new(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + chrono::Utc::now(), + ) + .unwrap() +} + +#[test] +fn manager_uses_the_full_persisted_request_config() { + let temporary = tempfile::tempdir().unwrap(); + let backend = VmLocalExecutionBackend::new(temporary.path()); + let record = record(temporary.path(), ExecutionIsolation::Microvm); + + let manager = backend.new_manager(&record).unwrap(); + + assert_eq!(manager.config.dns, vec!["1.1.1.1"]); + assert_eq!( + manager.config.sysctls, + vec![("net.ipv4.ip_forward".to_string(), "1".to_string())] + ); + assert_eq!(manager.config.resources.memory_mb, 256); + assert_eq!(manager.box_id(), record.id); + assert_eq!(manager.home_dir, temporary.path()); +} + +#[test] +fn manager_uses_the_backend_pull_progress_callback() { + let temporary = tempfile::tempdir().unwrap(); + let callback: crate::PullProgressFn = Arc::new(|_, _, _, _| {}); + let backend = + VmLocalExecutionBackend::new(temporary.path()).with_pull_progress_fn(Arc::clone(&callback)); + let record = record(temporary.path(), ExecutionIsolation::Microvm); + + let manager = backend.new_manager(&record).unwrap(); + + assert!(manager.pull_progress_fn.is_some()); +} + +#[test] +fn manager_applies_persisted_shared_memory_policy_to_runtime_config() { + let temporary = tempfile::tempdir().unwrap(); + let backend = VmLocalExecutionBackend::new(temporary.path()); + let mut record = record(temporary.path(), ExecutionIsolation::Microvm); + let shm_size = 64 * 1024 * 1024; + record.shm_size = Some(shm_size); + record + .managed_execution + .as_mut() + .unwrap() + .request + .policy + .shm_size = Some(shm_size); + + let manager = backend.new_manager(&record).unwrap(); + + assert!(manager + .config + .tmpfs + .contains(&format!("/dev/shm:size={shm_size}"))); +} + +#[test] +fn validation_rejects_a_host_path_derived_from_external_input() { + let temporary = tempfile::tempdir().unwrap(); + let backend = VmLocalExecutionBackend::new(temporary.path()); + let mut record = record(temporary.path(), ExecutionIsolation::Microvm); + record.box_dir = temporary.path().join("external-untrusted-label"); + + let error = backend.new_manager(&record).err().unwrap(); + + assert!(error.to_string().contains("unexpected host directory")); +} + +#[test] +fn transitional_states_retry_idempotent_pause_and_resume_operations() { + let temporary = tempfile::tempdir().unwrap(); + let mut record = record(temporary.path(), ExecutionIsolation::Microvm); + record.status = ManagedExecutionState::Pausing.as_status().to_string(); + record.managed_execution.as_mut().unwrap().pending_operation = + Some(crate::ManagedExecutionOperation::Pause { keep_memory: true }); + assert_eq!( + visible_active_state(&record).unwrap(), + ExecutionState::Running + ); + + record.status = ManagedExecutionState::Resuming.as_status().to_string(); + record.managed_execution.as_mut().unwrap().pending_operation = + Some(crate::ManagedExecutionOperation::Resume); + assert_eq!( + visible_active_state(&record).unwrap(), + ExecutionState::Paused + ); +} + +#[test] +fn restart_teardown_preserves_old_runtime_visibility_until_generation_advance() { + let temporary = tempfile::tempdir().unwrap(); + let mut record = record(temporary.path(), ExecutionIsolation::Microvm); + record.status = ManagedExecutionState::RestartStopping + .as_status() + .to_string(); + record.managed_execution.as_mut().unwrap().pending_operation = + Some(crate::ManagedExecutionOperation::Restart { + operation_id: OperationId::new("operation-restart").unwrap(), + source_generation: ExecutionGeneration::INITIAL, + source_state: ManagedExecutionState::Paused, + stop_timeout_secs: None, + }); + assert_eq!( + visible_active_state(&record).unwrap(), + ExecutionState::Paused + ); + + record.status = ManagedExecutionState::RestartStarting + .as_status() + .to_string(); + record.managed_execution.as_mut().unwrap().generation = ExecutionGeneration::new(2).unwrap(); + assert_eq!( + visible_active_state(&record).unwrap(), + ExecutionState::Running + ); +} + +#[tokio::test] +async fn filesystem_only_pause_fails_before_starting_a_runtime() { + let temporary = tempfile::tempdir().unwrap(); + let backend = VmLocalExecutionBackend::new(temporary.path()); + let sandbox = record(temporary.path(), ExecutionIsolation::Sandbox); + let microvm = record(temporary.path(), ExecutionIsolation::Microvm); + + let sandbox_error = backend.pause(&sandbox, false).await.unwrap_err(); + let memory_error = backend.pause(µvm, false).await.unwrap_err(); + + assert!(sandbox_error + .to_string() + .contains("pause without memory retention")); + assert!(memory_error + .to_string() + .contains("pause without memory retention")); + assert!(backend.managers.is_empty()); +} + +#[tokio::test] +async fn retained_stops_preserve_anonymous_volumes_but_auto_remove_kill_removes_them() { + let temporary = tempfile::tempdir().unwrap(); + let backend = VmLocalExecutionBackend::new(temporary.path()); + let mut record = record(temporary.path(), ExecutionIsolation::Microvm); + let volume_name = "anonymous-restart-volume"; + let volumes = crate::VolumeStore::new( + temporary.path().join("volumes.json"), + temporary.path().join("volumes"), + ); + volumes.create(VolumeConfig::new(volume_name, "")).unwrap(); + record.anonymous_volumes = vec![volume_name.to_string()]; + + let manager = Arc::new(Mutex::new(backend.new_manager(&record).unwrap())); + backend.managers.insert(record.id.clone(), manager); + backend.stop_for_restart(&record, Some(0)).await.unwrap(); + assert!(volumes.get(volume_name).unwrap().is_some()); + + let manager = Arc::new(Mutex::new(backend.new_manager(&record).unwrap())); + backend.managers.insert(record.id.clone(), manager); + backend.kill(&record).await.unwrap(); + assert!(volumes.get(volume_name).unwrap().is_some()); + + record.auto_remove = true; + record + .managed_execution + .as_mut() + .unwrap() + .request + .policy + .auto_remove = true; + let manager = Arc::new(Mutex::new(backend.new_manager(&record).unwrap())); + backend.managers.insert(record.id.clone(), manager); + backend.kill(&record).await.unwrap(); + assert!(volumes.get(volume_name).unwrap().is_none()); +} + +#[test] +fn managed_kill_uses_persisted_stop_signal_and_timeout() { + let temporary = tempfile::tempdir().unwrap(); + let mut record = record(temporary.path(), ExecutionIsolation::Microvm); + + assert_eq!(graceful_stop_options(&record, None).unwrap(), None); + + record.stop_signal = Some("SIGINT".to_string()); + assert_eq!( + graceful_stop_options(&record, None).unwrap(), + Some((libc::SIGINT, a3s_box_core::DEFAULT_SHUTDOWN_TIMEOUT_MS)) + ); + + record.stop_timeout = Some(7); + assert_eq!( + graceful_stop_options(&record, record.stop_timeout).unwrap(), + Some((libc::SIGINT, 7_000)) + ); + assert_eq!( + graceful_stop_options(&record, Some(3)).unwrap(), + Some((libc::SIGINT, 3_000)) + ); +} + +#[test] +fn managed_kill_rejects_stop_timeout_overflow() { + let temporary = tempfile::tempdir().unwrap(); + let record = record(temporary.path(), ExecutionIsolation::Microvm); + + let error = graceful_stop_options(&record, Some(u64::MAX)).unwrap_err(); + + assert!(error.to_string().contains("stop timeout is too large")); +} + +#[test] +fn visible_state_rejects_terminal_records() { + let temporary = tempfile::tempdir().unwrap(); + let mut record = record(temporary.path(), ExecutionIsolation::Microvm); + record.status = ManagedExecutionState::Stopped.as_status().to_string(); + record.managed_execution.as_mut().unwrap().generation = ExecutionGeneration::INITIAL; + + assert!(visible_active_state(&record).is_err()); +} diff --git a/src/runtime/src/local_execution/vm_process.rs b/src/runtime/src/local_execution/vm_process.rs new file mode 100644 index 00000000..2f79c212 --- /dev/null +++ b/src/runtime/src/local_execution/vm_process.rs @@ -0,0 +1,131 @@ +//! Exact MicroVM shim discovery for managed restart recovery. + +use std::path::Path; + +use sysinfo::{Pid, System}; + +#[derive(Debug, Clone, Copy)] +pub(super) struct LocatedProcess { + pub(super) pid: u32, + pub(super) start_time: Option, +} + +pub(super) fn locate_microvm_process( + execution_id: &str, + recorded: Option<(u32, Option)>, +) -> Result, String> { + let system = System::new_all(); + + if let Some((pid, expected_start_time)) = recorded { + if !crate::process::is_process_alive_with_identity(pid, expected_start_time) { + return Ok(None); + } + let Some(process) = system.process(Pid::from_u32(pid)) else { + return Ok(None); + }; + if !shim_command_targets_execution(process.cmd(), execution_id) { + return Ok(None); + } + return Ok(Some(LocatedProcess { + pid, + start_time: crate::process::pid_start_time(pid), + })); + } + + let mut matches = system + .processes() + .values() + .filter(|process| shim_command_targets_execution(process.cmd(), execution_id)) + .map(|process| LocatedProcess { + pid: process.pid().as_u32(), + start_time: crate::process::pid_start_time(process.pid().as_u32()), + }); + let first = matches.next(); + if matches.next().is_some() { + return Err(format!( + "multiple MicroVM shim processes claim execution {execution_id}" + )); + } + Ok(first) +} + +fn shim_command_targets_execution(command: &[String], execution_id: &str) -> bool { + let Some(executable) = command.first() else { + return false; + }; + let Some(file_name) = Path::new(executable) + .file_name() + .and_then(|name| name.to_str()) + else { + return false; + }; + if !matches!(file_name, "a3s-box-shim" | "a3s-box-shim.exe") { + return false; + } + + let Some(config_index) = command.iter().position(|argument| argument == "--config") else { + return false; + }; + let Some(config) = command.get(config_index + 1) else { + return false; + }; + serde_json::from_str::(config) + .ok() + .and_then(|value| { + value + .get("box_id") + .and_then(serde_json::Value::as_str) + .map(|box_id| box_id == execution_id) + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_match_requires_exact_binary_argument_and_internal_id() { + let command = vec![ + "/usr/bin/a3s-box-shim".to_string(), + "--config".to_string(), + r#"{"box_id":"11111111-1111-4111-8111-111111111111"}"#.to_string(), + ]; + + assert!(shim_command_targets_execution( + &command, + "11111111-1111-4111-8111-111111111111" + )); + assert!(!shim_command_targets_execution(&command, "11111111")); + } + + #[test] + fn command_match_rejects_lookalikes_and_malformed_json() { + let lookalike = vec![ + "/tmp/not-a3s-box-shim".to_string(), + "--config".to_string(), + r#"{"box_id":"box-1"}"#.to_string(), + ]; + let malformed = vec![ + "a3s-box-shim".to_string(), + "--config".to_string(), + "not-json".to_string(), + ]; + + assert!(!shim_command_targets_execution(&lookalike, "box-1")); + assert!(!shim_command_targets_execution(&malformed, "box-1")); + } + + #[test] + fn recorded_non_shim_process_is_never_accepted() { + let located = locate_microvm_process( + std::process::id().to_string().as_str(), + Some(( + std::process::id(), + crate::process::pid_start_time(std::process::id()), + )), + ) + .unwrap(); + assert!(located.is_none()); + } +} diff --git a/src/runtime/src/local_execution/vm_sandbox.rs b/src/runtime/src/local_execution/vm_sandbox.rs new file mode 100644 index 00000000..abcc991a --- /dev/null +++ b/src/runtime/src/local_execution/vm_sandbox.rs @@ -0,0 +1,320 @@ +//! Durable `crun` recovery for the production local execution backend. + +use super::*; + +impl VmLocalExecutionBackend { + pub(super) async fn inspect_sandbox( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult { + self.metadata(record)?; + let home_dir = self.home_dir.clone(); + let box_dir = record.box_dir.clone(); + let box_id = record.id.clone(); + let execution_id = execution_id(record)?; + let state = tokio::task::spawn_blocking(move || { + inspect_recorded_sandbox(&home_dir, &box_dir, &box_id) + }) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!( + "Sandbox inspection task failed for {}: {error}", + record.id + )) + })? + .map_err(|error| runtime_error("inspect", record, error))? + .ok_or(ExecutionManagerError::NotFound(execution_id))?; + + match state.status.as_str() { + "created" | "running" => { + if state.pid == 0 { + return Err(ExecutionManagerError::Internal(format!( + "Sandbox runtime returned PID zero for {}", + record.id + ))); + } + let manager = self.attach_sandbox(record, state).await?; + self.inspect_registered(record, manager).await + } + "paused" => { + if state.pid == 0 { + return Err(ExecutionManagerError::Internal(format!( + "Sandbox runtime returned PID zero for {}", + record.id + ))); + } + let manager = self.attach_sandbox(record, state).await?; + let manager = manager.lock().await; + let handle = self.handle_from_manager(record, &manager).await?; + Ok(LocalExecutionObservation { + state: ExecutionState::Paused, + handle: Some(handle), + exit_code: None, + }) + } + "stopped" => { + self.cleanup_detached_sandbox(record).await?; + Ok(LocalExecutionObservation { + state: ExecutionState::Stopped, + handle: None, + exit_code: None, + }) + } + status => Err(ExecutionManagerError::Internal(format!( + "Sandbox runtime returned unknown state {status} for {}", + record.id + ))), + } + } + + #[cfg(target_os = "linux")] + pub(super) async fn pause_sandbox( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult { + self.transition_sandbox(record, true).await + } + + #[cfg(target_os = "linux")] + pub(super) async fn resume_sandbox( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult { + self.transition_sandbox(record, false).await + } + + #[cfg(not(target_os = "linux"))] + pub(super) async fn pause_sandbox( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult { + Err(unsupported( + record, + "pause", + "the Sandbox backend on this host", + )) + } + + #[cfg(not(target_os = "linux"))] + pub(super) async fn resume_sandbox( + &self, + record: &BoxRecord, + ) -> ExecutionManagerResult { + Err(unsupported( + record, + "resume", + "the Sandbox backend on this host", + )) + } + + #[cfg(target_os = "linux")] + async fn transition_sandbox( + &self, + record: &BoxRecord, + pause: bool, + ) -> ExecutionManagerResult { + self.metadata(record)?; + let home_dir = self.home_dir.clone(); + let box_dir = record.box_dir.clone(); + let box_id = record.id.clone(); + let operation = if pause { "pause" } else { "resume" }; + let inspection = tokio::task::spawn_blocking(move || { + let inspection = + inspect_recorded_sandbox(&home_dir, &box_dir, &box_id)?.ok_or_else(|| { + a3s_box_core::BoxError::StateError(format!( + "Sandbox runtime record is missing for {box_id}" + )) + })?; + if pause { + crate::sandbox::handler::CrunHandler::pause_at( + &inspection.runtime.runtime_path, + &inspection.runtime.runtime_root, + &box_id, + )?; + } else { + crate::sandbox::handler::CrunHandler::resume_at( + &inspection.runtime.runtime_path, + &inspection.runtime.runtime_root, + &box_id, + )?; + } + inspect_recorded_sandbox(&home_dir, &box_dir, &box_id)?.ok_or_else(|| { + a3s_box_core::BoxError::StateError(format!( + "Sandbox runtime record disappeared after {operation} for {box_id}" + )) + }) + }) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!( + "Sandbox {operation} task failed for {}: {error}", + record.id + )) + })? + .map_err(|error| runtime_error(operation, record, error))?; + + let expected = if pause { "paused" } else { "running" }; + if inspection.status != expected { + return Err(ExecutionManagerError::Internal(format!( + "Sandbox runtime returned state {} after {operation} for {}", + inspection.status, record.id + ))); + } + let manager = self.attach_sandbox(record, inspection).await?; + let manager = manager.lock().await; + self.handle_from_manager(record, &manager).await + } + + #[cfg(target_os = "linux")] + async fn attach_sandbox( + &self, + record: &BoxRecord, + inspection: SandboxInspection, + ) -> ExecutionManagerResult { + let mut manager = self.new_manager(record)?; + let socket_dir = crate::vm::runtime_socket_dir(&self.home_dir, &record.id); + manager.exec_socket_path = Some(socket_dir.join("exec.sock")); + manager.pty_socket_path = Some(socket_dir.join("pty.sock")); + manager.port_forward_socket_path = Some(socket_dir.join("portfwd.sock")); + *manager.handler.write().await = Some(Box::new( + crate::sandbox::handler::CrunHandler::from_recorded_runtime( + crate::sandbox::handler::CrunHandlerSpec::new( + inspection.runtime.runtime_path, + inspection.runtime.runtime_root, + record.id.clone(), + inspection.pid, + inspection.runtime.bundle_dir, + record.box_dir.join("sandbox/runtime.json"), + ), + inspection.runtime.log_worker_pid, + inspection.runtime.log_worker_pid_start_time, + ), + )); + if !matches!( + managed_state(record)?, + ManagedExecutionState::Starting | ManagedExecutionState::RestartStarting + ) { + *manager.state.write().await = crate::BoxState::Ready; + } + + let recovered = Arc::new(Mutex::new(manager)); + match self.managers.entry(record.id.clone()) { + Entry::Occupied(entry) => Ok(Arc::clone(entry.get())), + Entry::Vacant(entry) => { + entry.insert(Arc::clone(&recovered)); + Ok(recovered) + } + } + } + + #[cfg(not(target_os = "linux"))] + async fn attach_sandbox( + &self, + record: &BoxRecord, + _inspection: SandboxInspection, + ) -> ExecutionManagerResult { + Err(unsupported( + record, + "recovery", + "the Sandbox backend on this host", + )) + } + + pub(super) async fn destroy_detached_sandbox( + &self, + record: &BoxRecord, + remove_anonymous_volumes: bool, + timeout_secs: Option, + ) -> ExecutionManagerResult { + self.inspect_sandbox(record).await?; + if let Some(manager) = self.manager(&record.id) { + return self + .destroy_registered(record, manager, remove_anonymous_volumes, timeout_secs) + .await; + } + if remove_anonymous_volumes { + let anonymous_volumes = self.anonymous_volumes_for_record(record).await; + self.cleanup_anonymous_volumes(anonymous_volumes).await; + } + Ok(KillOutcome::Killed) + } + + async fn cleanup_detached_sandbox(&self, record: &BoxRecord) -> ExecutionManagerResult<()> { + let home_dir = self.home_dir.clone(); + let box_dir = record.box_dir.clone(); + let box_id = record.id.clone(); + tokio::task::spawn_blocking(move || { + crate::vm::reap::cleanup_recorded_sandbox_runtime_in(&home_dir, &box_dir, &box_id) + }) + .await + .map_err(|error| { + ExecutionManagerError::Internal(format!( + "Sandbox cleanup task failed for {}: {error}", + record.id + )) + })? + .map_err(|error| runtime_error("kill", record, error))?; + + let mut manager = self.new_manager(record)?; + manager + .destroy() + .await + .map_err(|error| runtime_error("clean up", record, error))?; + Ok(()) + } +} + +#[cfg(target_os = "linux")] +struct SandboxInspection { + status: String, + pid: u32, + runtime: crate::vm::reap::RecordedSandboxRuntime, +} + +#[cfg(target_os = "linux")] +fn inspect_recorded_sandbox( + home_dir: &Path, + box_dir: &Path, + box_id: &str, +) -> a3s_box_core::Result> { + let Some(runtime) = crate::vm::reap::load_recorded_sandbox_runtime(home_dir, box_dir, box_id)? + else { + return Ok(None); + }; + let state = crate::sandbox::handler::CrunHandler::query_state_at( + &runtime.runtime_path, + &runtime.runtime_root, + box_id, + )?; + let (status, pid) = match state { + Some(state) => (state.status, state.pid), + None => ("stopped".to_string(), 0), + }; + if matches!(status.as_str(), "created" | "running" | "paused") && pid != runtime.init_pid { + return Err(a3s_box_core::BoxError::StateError(format!( + "Sandbox runtime PID disagrees with its durable record for {box_id}" + ))); + } + Ok(Some(SandboxInspection { + status, + pid, + runtime, + })) +} + +#[cfg(not(target_os = "linux"))] +struct SandboxInspection { + status: String, + pid: u32, +} + +#[cfg(not(target_os = "linux"))] +fn inspect_recorded_sandbox( + _home_dir: &Path, + _box_dir: &Path, + _box_id: &str, +) -> a3s_box_core::Result> { + Err(a3s_box_core::BoxError::StateError( + "Sandbox execution requires Linux".to_string(), + )) +} diff --git a/src/runtime/src/managed_execution_store.rs b/src/runtime/src/managed_execution_store.rs new file mode 100644 index 00000000..ff1f338a --- /dev/null +++ b/src/runtime/src/managed_execution_store.rs @@ -0,0 +1,864 @@ +//! Durable generation-fenced transitions for managed local executions. + +use std::path::{Path, PathBuf}; + +use a3s_box_core::{ExecutionGeneration, ExecutionId, OperationId}; +use thiserror::Error; + +use crate::{ + BoxRecord, BoxStateStore, ManagedExecutionOperation, ManagedExecutionState, + ManagedRestartCompletion, ManagedRestartOutcome, +}; + +/// Strict durable repository used by the local `ExecutionManager`. +#[derive(Debug, Clone)] +pub struct ManagedExecutionStore { + path: PathBuf, +} + +/// Result of reserving an idempotent create operation. +#[derive(Debug, Clone)] +pub enum ManagedExecutionReservation { + /// The creation intent was inserted by this call. + Reserved(BoxRecord), + /// The operation already existed with the same creation intent. + Existing(BoxRecord), +} + +impl ManagedExecutionReservation { + pub const fn is_new(&self) -> bool { + matches!(self, Self::Reserved(_)) + } + + pub fn record(&self) -> &BoxRecord { + match self { + Self::Reserved(record) | Self::Existing(record) => record, + } + } + + pub fn into_record(self) -> BoxRecord { + match self { + Self::Reserved(record) | Self::Existing(record) => record, + } + } +} + +/// Fail-closed errors from managed lifecycle persistence. +#[derive(Debug, Error)] +pub enum ManagedExecutionStoreError { + #[error("managed execution state I/O failed: {0}")] + Io(#[from] std::io::Error), + #[error("managed execution not found: {0}")] + NotFound(ExecutionId), + #[error("execution record is not managed: {0}")] + Unmanaged(ExecutionId), + #[error("managed execution conflict for {execution_id}: {message}")] + Conflict { + execution_id: ExecutionId, + message: String, + }, + #[error("invalid managed execution record: {0}")] + InvalidRecord(String), + #[error("invalid managed execution transition for {execution_id}: {from} -> {to}")] + InvalidTransition { + execution_id: ExecutionId, + from: ManagedExecutionState, + to: ManagedExecutionState, + }, +} + +pub type ManagedExecutionStoreResult = std::result::Result; + +impl ManagedExecutionStore { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Return one managed record without mutating or reconciling state. + pub fn get( + &self, + execution_id: &ExecutionId, + ) -> ManagedExecutionStoreResult> { + let store = BoxStateStore::load_readonly(&self.path)?; + let Some(record) = store.find_by_id(execution_id.as_str()).cloned() else { + return Ok(None); + }; + if record.managed_execution.is_none() { + return Err(ManagedExecutionStoreError::Unmanaged(execution_id.clone())); + } + Ok(Some(record)) + } + + /// Return the record reserved by an idempotent creation operation. + pub fn get_by_operation_id( + &self, + operation_id: &OperationId, + ) -> ManagedExecutionStoreResult> { + let store = BoxStateStore::load_readonly(&self.path)?; + Ok(store.find_by_operation_id(operation_id).cloned()) + } + + /// Atomically reserve one creation operation before backend side effects. + /// + /// Retrying the same operation with the same full request returns the + /// existing record. Reusing an operation ID for different creation intent + /// fails without changing durable state. + pub fn reserve( + &self, + mut record: BoxRecord, + ) -> ManagedExecutionStoreResult { + let execution_id = validate_new_record(&record)?; + record.status = ManagedExecutionState::Created.as_status().to_string(); + let incoming_metadata = record + .managed_execution + .as_ref() + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))? + .clone(); + + BoxStateStore::transact(&self.path, move |store| { + if let Some(existing) = store + .find_by_operation_id(&incoming_metadata.operation_id) + .cloned() + { + let existing_id = ExecutionId::new(existing.id.clone()).map_err(|error| { + ManagedExecutionStoreError::InvalidRecord(error.to_string()) + })?; + let existing_metadata = existing + .managed_execution + .as_ref() + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(existing_id.clone()))?; + if !same_creation_intent(existing_metadata, &incoming_metadata)? { + return Err(ManagedExecutionStoreError::Conflict { + execution_id: existing_id, + message: format!( + "operation {} was already reserved with different creation intent", + incoming_metadata.operation_id + ), + }); + } + return Ok(ManagedExecutionReservation::Existing(existing)); + } + + if store.find_by_id(execution_id.as_str()).is_some() { + return Err(ManagedExecutionStoreError::Conflict { + execution_id, + message: "execution ID is already present".to_string(), + }); + } + + store.records_mut().push(record.clone()); + Ok(ManagedExecutionReservation::Reserved(record)) + }) + } + + /// Atomically compare generation and state, then persist one legal edge. + /// + /// Completing pause and resume, and advancing a restart from teardown to + /// startup, increments the runtime generation exactly once. Other edges + /// retain the current generation. + pub fn transition( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + expected_state: ManagedExecutionState, + next_state: ManagedExecutionState, + ) -> ManagedExecutionStoreResult { + self.transition_with( + execution_id, + expected_generation, + expected_state, + next_state, + |_| {}, + ) + } + + /// Persist one legal transition and update runtime evidence in the same + /// transaction. + pub fn transition_with( + &self, + execution_id: &ExecutionId, + expected_generation: ExecutionGeneration, + expected_state: ManagedExecutionState, + next_state: ManagedExecutionState, + update: impl FnOnce(&mut BoxRecord), + ) -> ManagedExecutionStoreResult { + let execution_id = execution_id.clone(); + BoxStateStore::transact(&self.path, move |store| { + let record = store + .find_by_id_mut(execution_id.as_str()) + .ok_or_else(|| ManagedExecutionStoreError::NotFound(execution_id.clone()))?; + let actual_state = record + .managed_state() + .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))? + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?; + let metadata = record + .managed_execution + .as_ref() + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?; + + if metadata.generation != expected_generation || actual_state != expected_state { + return Err(ManagedExecutionStoreError::Conflict { + execution_id: execution_id.clone(), + message: format!( + "expected {expected_state} generation {}, found {actual_state} generation {}", + expected_generation.get(), + metadata.generation.get() + ), + }); + } + + let next_generation = transition_generation( + &execution_id, + expected_state, + next_state, + expected_generation, + )?; + let original_metadata = record + .managed_execution + .as_ref() + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))? + .clone(); + update(record); + if record.id != execution_id.as_str() { + return Err(ManagedExecutionStoreError::InvalidRecord(format!( + "transition changed execution ID {execution_id}" + ))); + } + let updated_metadata = record + .managed_execution + .as_ref() + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?; + if updated_metadata.operation_id != original_metadata.operation_id + || !same_creation_intent(updated_metadata, &original_metadata)? + { + return Err(ManagedExecutionStoreError::InvalidRecord(format!( + "transition changed creation identity for {execution_id}" + ))); + } + record.status = next_state.as_status().to_string(); + let metadata = record + .managed_execution + .as_mut() + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?; + metadata.generation = next_generation; + if expected_state == ManagedExecutionState::RestartStarting + && matches!( + next_state, + ManagedExecutionState::Running | ManagedExecutionState::Failed + ) + { + let Some(ManagedExecutionOperation::Restart { + operation_id, + source_generation, + stop_timeout_secs, + .. + }) = metadata.pending_operation.as_ref() + else { + return Err(ManagedExecutionStoreError::InvalidRecord(format!( + "restart completion for {execution_id} has no persisted restart intent" + ))); + }; + metadata.last_restart = Some(ManagedRestartCompletion { + operation_id: operation_id.clone(), + source_generation: *source_generation, + target_generation: next_generation, + outcome: if next_state == ManagedExecutionState::Running { + ManagedRestartOutcome::Running + } else { + ManagedRestartOutcome::Failed + }, + stop_timeout_secs: *stop_timeout_secs, + }); + } + metadata.pending_operation = match next_state { + ManagedExecutionState::Starting => Some(ManagedExecutionOperation::Start), + ManagedExecutionState::Pausing => match metadata.pending_operation.take() { + Some(operation @ ManagedExecutionOperation::Pause { .. }) => Some(operation), + _ => Some(ManagedExecutionOperation::Pause { keep_memory: false }), + }, + ManagedExecutionState::Resuming => Some(ManagedExecutionOperation::Resume), + ManagedExecutionState::Snapshotting => match metadata.pending_operation.take() { + Some(operation @ ManagedExecutionOperation::Snapshot { .. }) => Some(operation), + _ => { + return Err(ManagedExecutionStoreError::InvalidRecord(format!( + "snapshot transition for {execution_id} has no persisted snapshot intent" + ))) + } + }, + ManagedExecutionState::Killing => Some(ManagedExecutionOperation::Kill), + ManagedExecutionState::RestartStopping | ManagedExecutionState::RestartStarting => { + match metadata.pending_operation.take() { + Some(operation @ ManagedExecutionOperation::Restart { .. }) => { + Some(operation) + } + _ => { + return Err(ManagedExecutionStoreError::InvalidRecord(format!( + "restart transition for {execution_id} has no persisted restart intent" + ))) + } + } + } + ManagedExecutionState::Creating + | ManagedExecutionState::Created + | ManagedExecutionState::Running + | ManagedExecutionState::Paused + | ManagedExecutionState::Stopped + | ManagedExecutionState::Failed => None, + }; + Ok(record.clone()) + }) + } +} + +fn validate_new_record(record: &BoxRecord) -> ManagedExecutionStoreResult { + let execution_id = ExecutionId::new(record.id.clone()) + .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?; + let metadata = record + .managed_execution + .as_ref() + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?; + metadata + .validate() + .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?; + let state = record + .managed_state() + .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))? + .ok_or_else(|| ManagedExecutionStoreError::Unmanaged(execution_id.clone()))?; + if state != ManagedExecutionState::Created { + return Err(ManagedExecutionStoreError::InvalidRecord(format!( + "new execution {execution_id} must be created, found {state}" + ))); + } + if metadata.generation != ExecutionGeneration::INITIAL { + return Err(ManagedExecutionStoreError::InvalidRecord(format!( + "new execution {execution_id} must start at generation {}", + ExecutionGeneration::INITIAL.get() + ))); + } + Ok(execution_id) +} + +fn same_creation_intent( + left: &crate::ManagedExecutionMetadata, + right: &crate::ManagedExecutionMetadata, +) -> ManagedExecutionStoreResult { + let left_request = serde_json::to_value(&left.request) + .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?; + let right_request = serde_json::to_value(&right.request) + .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string()))?; + Ok(left_request == right_request && left.plan == right.plan) +} + +fn transition_generation( + execution_id: &ExecutionId, + from: ManagedExecutionState, + to: ManagedExecutionState, + current: ExecutionGeneration, +) -> ManagedExecutionStoreResult { + use ManagedExecutionState::{ + Created, Creating, Failed, Killing, Paused, Pausing, RestartStarting, RestartStopping, + Resuming, Running, Snapshotting, Starting, Stopped, + }; + + let legal = matches!( + (from, to), + (Creating, Created | Starting | Killing | Stopped | Failed) + | ( + Created, + Starting | Killing | RestartStopping | Stopped | Failed + ) + | ( + Starting, + Created | Creating | Running | Killing | Stopped | Failed + ) + | ( + Running, + Pausing | Snapshotting | Killing | RestartStopping | Stopped | Failed + ) + | (Pausing, Paused | Running | Killing | Stopped | Failed) + | ( + Paused, + Resuming | Snapshotting | Killing | RestartStopping | Stopped | Failed + ) + | (Resuming, Running | Paused | Killing | Stopped | Failed) + | (Snapshotting, Running | Paused | Stopped | Failed) + | (Killing, Stopped | Failed) + | (Stopped | Failed, RestartStopping) + | (RestartStopping, RestartStarting) + | (RestartStarting, Running | Failed) + ); + if !legal { + return Err(ManagedExecutionStoreError::InvalidTransition { + execution_id: execution_id.clone(), + from, + to, + }); + } + + if matches!( + (from, to), + (Pausing, Paused) | (Resuming, Running) | (RestartStopping, RestartStarting) + ) { + let value = current.get().checked_add(1).ok_or_else(|| { + ManagedExecutionStoreError::InvalidRecord(format!( + "execution {execution_id} generation is exhausted" + )) + })?; + return ExecutionGeneration::new(value) + .map_err(|error| ManagedExecutionStoreError::InvalidRecord(error.to_string())); + } + Ok(current) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Barrier}; + + use a3s_box_core::{CreateExecutionRequest, ExecutionIsolation, ExecutionSnapshotId}; + + use super::*; + use crate::ManagedExecutionMetadata; + + fn managed_record(id: &str, operation: &str) -> BoxRecord { + let mut record: BoxRecord = serde_json::from_value(serde_json::json!({ + "id": id, + "short_id": BoxRecord::make_short_id(id), + "name": format!("box-{id}"), + "image": "alpine:latest", + "isolation": "sandbox", + "status": "created", + "pid": null, + "cpus": 1, + "memory_mb": 128, + "volumes": [], + "env": {}, + "cmd": ["sh"], + "box_dir": format!("/tmp/{id}"), + "console_log": format!("/tmp/{id}/console.log"), + "created_at": "2026-07-14T12:00:00Z", + "started_at": null, + "auto_remove": false + })) + .unwrap(); + let config = a3s_box_core::BoxConfig { + image: "alpine:latest".to_string(), + isolation: ExecutionIsolation::Sandbox, + ..Default::default() + }; + record.managed_execution = Some( + ManagedExecutionMetadata::new( + OperationId::new(operation).unwrap(), + ExecutionGeneration::INITIAL, + CreateExecutionRequest { + external_sandbox_id: "sandbox-1".to_string(), + config, + labels: Default::default(), + policy: Default::default(), + rootfs_snapshot_id: None, + }, + ) + .unwrap(), + ); + record + } + + #[test] + fn reservation_is_idempotent_for_the_same_full_request() { + let directory = tempfile::tempdir().unwrap(); + let store = ManagedExecutionStore::new(directory.path().join("boxes.json")); + + let first = store.reserve(managed_record("execution-1", "operation-1")); + let retry = store.reserve(managed_record("execution-2", "operation-1")); + + assert!(first.unwrap().is_new()); + let retry = retry.unwrap(); + assert!(!retry.is_new()); + assert_eq!(retry.record().id, "execution-1"); + assert_eq!( + BoxStateStore::load(store.path()).unwrap().records().len(), + 1 + ); + } + + #[test] + fn reservation_rejects_operation_reuse_with_different_intent() { + let directory = tempfile::tempdir().unwrap(); + let store = ManagedExecutionStore::new(directory.path().join("boxes.json")); + store + .reserve(managed_record("execution-1", "operation-1")) + .unwrap(); + let mut conflicting = managed_record("execution-2", "operation-1"); + conflicting + .managed_execution + .as_mut() + .unwrap() + .request + .external_sandbox_id = "sandbox-2".to_string(); + + let error = store.reserve(conflicting).unwrap_err(); + + assert!(matches!(error, ManagedExecutionStoreError::Conflict { .. })); + assert_eq!( + BoxStateStore::load(store.path()).unwrap().records().len(), + 1 + ); + } + + #[test] + fn pause_and_resume_completion_advance_generation_once() { + let directory = tempfile::tempdir().unwrap(); + let store = ManagedExecutionStore::new(directory.path().join("boxes.json")); + let id = ExecutionId::new("execution-1").unwrap(); + store + .reserve(managed_record(id.as_str(), "operation-1")) + .unwrap(); + + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + ) + .unwrap(); + let running = store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + ) + .unwrap(); + assert_eq!( + running.managed_execution.unwrap().generation, + ExecutionGeneration::INITIAL + ); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Running, + ManagedExecutionState::Pausing, + ) + .unwrap(); + let paused = store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Pausing, + ManagedExecutionState::Paused, + ) + .unwrap(); + let generation_two = ExecutionGeneration::new(2).unwrap(); + assert_eq!(paused.managed_execution.unwrap().generation, generation_two); + store + .transition( + &id, + generation_two, + ManagedExecutionState::Paused, + ManagedExecutionState::Resuming, + ) + .unwrap(); + let resumed = store + .transition( + &id, + generation_two, + ManagedExecutionState::Resuming, + ManagedExecutionState::Running, + ) + .unwrap(); + assert_eq!( + resumed.managed_execution.unwrap().generation, + ExecutionGeneration::new(3).unwrap() + ); + } + + #[test] + fn snapshot_intent_is_durable_and_preserves_runtime_generation() { + let directory = tempfile::tempdir().unwrap(); + let store = ManagedExecutionStore::new(directory.path().join("boxes.json")); + let id = ExecutionId::new("execution-1").unwrap(); + store + .reserve(managed_record(id.as_str(), "operation-1")) + .unwrap(); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + ) + .unwrap(); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + ) + .unwrap(); + let snapshot_id = ExecutionSnapshotId::new("snapshot-1").unwrap(); + let claimed = store + .transition_with( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Running, + ManagedExecutionState::Snapshotting, + |record| { + record.managed_execution.as_mut().unwrap().pending_operation = + Some(ManagedExecutionOperation::Snapshot { + snapshot_id: snapshot_id.clone(), + source_state: ManagedExecutionState::Running, + }); + }, + ) + .unwrap(); + assert_eq!( + claimed.managed_execution.as_ref().unwrap().generation, + ExecutionGeneration::INITIAL + ); + assert!(matches!( + claimed + .managed_execution + .as_ref() + .unwrap() + .pending_operation + .as_ref(), + Some(ManagedExecutionOperation::Snapshot { + snapshot_id, + source_state: ManagedExecutionState::Running, + }) if snapshot_id.as_str() == "snapshot-1" + )); + + let reopened = ManagedExecutionStore::new(store.path().to_path_buf()); + let persisted = reopened.get(&id).unwrap().unwrap(); + assert_eq!( + persisted.managed_state().unwrap(), + Some(ManagedExecutionState::Snapshotting) + ); + let completed = reopened + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Snapshotting, + ManagedExecutionState::Running, + ) + .unwrap(); + let metadata = completed.managed_execution.unwrap(); + assert_eq!(metadata.generation, ExecutionGeneration::INITIAL); + assert!(metadata.pending_operation.is_none()); + assert!(matches!( + reopened.transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Running, + ManagedExecutionState::Snapshotting, + ), + Err(ManagedExecutionStoreError::InvalidRecord(_)) + )); + } + + #[test] + fn restart_advances_generation_between_durable_teardown_and_startup() { + let directory = tempfile::tempdir().unwrap(); + let store = ManagedExecutionStore::new(directory.path().join("boxes.json")); + let id = ExecutionId::new("execution-1").unwrap(); + store + .reserve(managed_record(id.as_str(), "operation-create")) + .unwrap(); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + ) + .unwrap(); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + ) + .unwrap(); + let restart_operation = OperationId::new("operation-restart").unwrap(); + let stopping = store + .transition_with( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Running, + ManagedExecutionState::RestartStopping, + |record| { + record.managed_execution.as_mut().unwrap().pending_operation = + Some(ManagedExecutionOperation::Restart { + operation_id: restart_operation.clone(), + source_generation: ExecutionGeneration::INITIAL, + source_state: ManagedExecutionState::Running, + stop_timeout_secs: Some(10), + }); + }, + ) + .unwrap(); + assert_eq!( + stopping.managed_execution.as_ref().unwrap().generation, + ExecutionGeneration::INITIAL + ); + + let starting = store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::RestartStopping, + ManagedExecutionState::RestartStarting, + ) + .unwrap(); + let generation_two = ExecutionGeneration::new(2).unwrap(); + assert_eq!( + starting.managed_execution.as_ref().unwrap().generation, + generation_two + ); + let running = store + .transition( + &id, + generation_two, + ManagedExecutionState::RestartStarting, + ManagedExecutionState::Running, + ) + .unwrap(); + let metadata = running.managed_execution.unwrap(); + assert_eq!(metadata.generation, generation_two); + assert!(metadata.pending_operation.is_none()); + let completed = metadata.last_restart.unwrap(); + assert_eq!(completed.operation_id, restart_operation); + assert_eq!(completed.source_generation, ExecutionGeneration::INITIAL); + assert_eq!(completed.target_generation, generation_two); + assert_eq!(completed.outcome, ManagedRestartOutcome::Running); + assert_eq!(completed.stop_timeout_secs, Some(10)); + } + + #[test] + fn stale_generation_and_invalid_edges_do_not_change_disk() { + let directory = tempfile::tempdir().unwrap(); + let store = ManagedExecutionStore::new(directory.path().join("boxes.json")); + let id = ExecutionId::new("execution-1").unwrap(); + store + .reserve(managed_record(id.as_str(), "operation-1")) + .unwrap(); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + ) + .unwrap(); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + ) + .unwrap(); + + let stale = store.transition( + &id, + ExecutionGeneration::new(2).unwrap(), + ManagedExecutionState::Running, + ManagedExecutionState::Pausing, + ); + let invalid = store.transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Running, + ManagedExecutionState::Paused, + ); + + assert!(matches!( + stale, + Err(ManagedExecutionStoreError::Conflict { .. }) + )); + assert!(matches!( + invalid, + Err(ManagedExecutionStoreError::InvalidTransition { .. }) + )); + let persisted = store.get(&id).unwrap().unwrap(); + assert_eq!( + persisted.managed_state().unwrap(), + Some(ManagedExecutionState::Running) + ); + assert_eq!( + persisted.managed_execution.unwrap().generation, + ExecutionGeneration::INITIAL + ); + } + + #[cfg(unix)] + #[test] + fn concurrent_claims_have_one_winner() { + let directory = tempfile::tempdir().unwrap(); + let store = Arc::new(ManagedExecutionStore::new( + directory.path().join("boxes.json"), + )); + let id = ExecutionId::new("execution-1").unwrap(); + store + .reserve(managed_record(id.as_str(), "operation-1")) + .unwrap(); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Created, + ManagedExecutionState::Starting, + ) + .unwrap(); + store + .transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Starting, + ManagedExecutionState::Running, + ) + .unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let handles: Vec<_> = (0..2) + .map(|_| { + let store = Arc::clone(&store); + let id = id.clone(); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + store.transition( + &id, + ExecutionGeneration::INITIAL, + ManagedExecutionState::Running, + ManagedExecutionState::Pausing, + ) + }) + }) + .collect(); + barrier.wait(); + let results: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect(); + + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(ManagedExecutionStoreError::Conflict { .. }))) + .count(), + 1 + ); + assert_eq!( + store.get(&id).unwrap().unwrap().managed_state().unwrap(), + Some(ManagedExecutionState::Pausing) + ); + } +} diff --git a/src/runtime/src/oci/build/dockerfile/mod.rs b/src/runtime/src/oci/build/dockerfile/mod.rs index e7f64a3e..160aacaa 100644 --- a/src/runtime/src/oci/build/dockerfile/mod.rs +++ b/src/runtime/src/oci/build/dockerfile/mod.rs @@ -2,7 +2,7 @@ //! //! Parses a Dockerfile into a sequence of build instructions. //! Supports line continuations (`\`), comments, and both shell and JSON -//! (exec) forms for CMD/ENTRYPOINT. +//! (exec) forms for RUN/CMD/ENTRYPOINT. use a3s_box_core::error::{BoxError, Result}; @@ -18,8 +18,13 @@ pub enum Instruction { image: String, alias: Option, }, - /// `RUN ` (shell form) - Run { command: String }, + /// `RUN [--mount=type=cache|bind|tmpfs,...] ` (shell or exec form) + Run { + command: RunCommand, + cache_mounts: Vec, + bind_mounts: Vec, + tmpfs_mounts: Vec, + }, /// `COPY [--from=] [--chown=user[:group]] ... ` Copy { src: Vec, @@ -72,6 +77,67 @@ pub enum Instruction { Volume { paths: Vec }, } +/// Dockerfile RUN command form. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunCommand { + /// Shell form: `RUN echo hello`. + Shell(String), + /// Exec form: `RUN ["echo", "hello"]`. + Exec(Vec), +} + +/// Supported subset of Docker BuildKit `RUN --mount=...`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunCacheMount { + pub raw: String, + pub id: Option, + /// Optional source stage/image used to seed a new cache directory. + pub from: Option, + /// Source path inside `from`; defaults to root. + pub source: String, + pub sharing: RunCacheSharing, + /// Optional mode for the cache mount root, parsed as octal. + pub mode: Option, + /// Optional owner uid for the cache mount root. + pub uid: Option, + /// Optional owner gid for the cache mount root. + pub gid: Option, + pub target: String, +} + +/// Supported subset of Docker BuildKit `RUN --mount=type=bind`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunBindMount { + pub raw: String, + /// Optional source stage/index. `None` means the build context. + pub from: Option, + /// Source path inside the build context or source stage. Defaults to root. + pub source: String, + /// Target path inside the build rootfs; relative targets resolve from WORKDIR. + pub target: String, + /// `rw`/`readwrite` allows writes during RUN. Like BuildKit, writes are + /// discarded after the RUN and are not committed into the image layer. + pub read_write: bool, +} + +/// Supported subset of Docker BuildKit `RUN --mount=type=tmpfs`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunTmpfsMount { + pub raw: String, + /// Target path inside the build rootfs; relative targets resolve from WORKDIR. + pub target: String, +} + +/// Supported cache sharing behavior for `RUN --mount=type=cache`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunCacheSharing { + /// Docker/BuildKit default. The warm-pool overlay persists the shared cache + /// but serializes hydrate/publish for one key to avoid writeback races. + Shared, + /// Serialize writers for the same cache key. + Locked, +} + /// Parsed Dockerfile: a list of instructions in order. #[derive(Debug, Clone)] pub struct Dockerfile { diff --git a/src/runtime/src/oci/build/dockerfile/parsers.rs b/src/runtime/src/oci/build/dockerfile/parsers.rs index 17e3a0f2..d430ea18 100644 --- a/src/runtime/src/oci/build/dockerfile/parsers.rs +++ b/src/runtime/src/oci/build/dockerfile/parsers.rs @@ -3,7 +3,10 @@ use a3s_box_core::error::{BoxError, Result}; use super::utils::{parse_duration_secs, parse_json_array, shell_split, unquote}; -use super::{split_first_word, Instruction}; +use super::{ + split_first_word, Instruction, RunBindMount, RunCacheMount, RunCacheSharing, RunCommand, + RunTmpfsMount, +}; pub(super) fn parse_from(rest: &str, line_num: usize) -> Result { if rest.is_empty() { @@ -32,15 +35,351 @@ pub(super) fn parse_run(rest: &str, line_num: usize) -> Result { ))); } - if rest.starts_with('[') { + let options = parse_run_options(rest, line_num)?; + let command = options.command; + let command = if command.starts_with('[') { + let exec = parse_json_array(command, line_num)?; + if exec.is_empty() { + return Err(BoxError::BuildError(format!( + "Line {}: RUN exec form requires at least one argument", + line_num + ))); + } + RunCommand::Exec(exec) + } else { + RunCommand::Shell(command.to_string()) + }; + + Ok(Instruction::Run { + command, + cache_mounts: options.cache_mounts, + bind_mounts: options.bind_mounts, + tmpfs_mounts: options.tmpfs_mounts, + }) +} + +struct RunOptions<'a> { + cache_mounts: Vec, + bind_mounts: Vec, + tmpfs_mounts: Vec, + command: &'a str, +} + +fn parse_run_options(rest: &str, line_num: usize) -> Result> { + let mut remaining = rest.trim_start(); + let mut cache_mounts = Vec::new(); + let mut bind_mounts = Vec::new(); + let mut tmpfs_mounts = Vec::new(); + + while remaining.starts_with("--") { + let (flag, tail) = split_first_word(remaining); + if let Some(spec) = flag.strip_prefix("--mount=") { + match run_mount_type(flag, spec, line_num)? { + "cache" => cache_mounts.push(parse_run_cache_mount(flag, spec, line_num)?), + "bind" => bind_mounts.push(parse_run_bind_mount(flag, spec, line_num)?), + "tmpfs" => tmpfs_mounts.push(parse_run_tmpfs_mount(flag, spec, line_num)?), + other => { + return Err(BoxError::BuildError(format!( + "Line {}: RUN mount '{}' is not supported yet; only type=cache, type=bind, and type=tmpfs are supported (got type={})", + line_num, flag, other + ))); + } + } + } else if let Some(network) = flag.strip_prefix("--network=") { + if network != "default" { + return Err(BoxError::BuildError(format!( + "Line {}: RUN option '--network={}' is not supported yet by the warm-pool build path; only --network=default is supported", + line_num, network + ))); + } + } else if let Some(security) = flag.strip_prefix("--security=") { + if security != "sandbox" { + return Err(BoxError::BuildError(format!( + "Line {}: RUN option '--security={}' is not supported yet by the warm-pool build path; only --security=sandbox is supported", + line_num, security + ))); + } + } else { + return Err(BoxError::BuildError(format!( + "Line {}: RUN option '{}' is not supported yet; only BuildKit cache/bind/tmpfs mounts plus --network=default and --security=sandbox are supported", + line_num, flag + ))); + } + remaining = tail.trim_start(); + } + + if remaining.is_empty() { return Err(BoxError::BuildError(format!( - "Line {}: RUN exec form is not supported yet; use shell form", + "Line {}: RUN requires a command after BuildKit options", line_num ))); } - Ok(Instruction::Run { - command: rest.to_string(), + Ok(RunOptions { + cache_mounts, + bind_mounts, + tmpfs_mounts, + command: remaining, + }) +} + +fn run_mount_type<'a>(raw: &str, spec: &'a str, line_num: usize) -> Result<&'a str> { + for part in spec.split(',') { + let Some((key, value)) = part.split_once('=') else { + continue; + }; + if key == "type" { + return Ok(value); + } + } + Err(BoxError::BuildError(format!( + "Line {}: RUN mount '{}' requires type=cache, type=bind, or type=tmpfs", + line_num, raw + ))) +} + +fn parse_run_cache_mount(raw: &str, spec: &str, line_num: usize) -> Result { + let mut mount_type = None; + let mut id = None; + let mut from = None; + let mut source = None; + let mut sharing = None; + let mut mode = None; + let mut uid = None; + let mut gid = None; + let mut target = None; + + for part in spec.split(',') { + let (key, value) = part.split_once('=').ok_or_else(|| { + BoxError::BuildError(format!( + "Line {}: Invalid RUN mount option '{}'", + line_num, raw + )) + })?; + match key { + "type" => mount_type = Some(value), + "id" => id = Some(value), + "from" => from = Some(value), + "source" | "src" => source = Some(value), + "mode" => mode = Some(parse_run_cache_mount_mode(value, raw, line_num)?), + "uid" => uid = Some(parse_run_cache_mount_id("uid", value, raw, line_num)?), + "gid" => gid = Some(parse_run_cache_mount_id("gid", value, raw, line_num)?), + "sharing" => match value { + "shared" => sharing = Some(RunCacheSharing::Shared), + "locked" => sharing = Some(RunCacheSharing::Locked), + "private" => { + return Err(BoxError::BuildError(format!( + "Line {}: RUN cache mount sharing='private' is not supported yet by the warm-pool build path", + line_num + ))); + } + _ => { + return Err(BoxError::BuildError(format!( + "Line {}: Invalid RUN cache mount sharing value '{}'", + line_num, value + ))); + } + }, + "target" | "dst" | "destination" => target = Some(value), + _ => { + return Err(BoxError::BuildError(format!( + "Line {}: RUN cache mount option '{}=' is not supported", + line_num, key + ))); + } + } + } + + if mount_type != Some("cache") { + return Err(BoxError::BuildError(format!( + "Line {}: RUN mount '{}' is not supported yet; only type=cache is supported", + line_num, raw + ))); + } + + let Some(target) = target.filter(|target| !target.is_empty()) else { + return Err(BoxError::BuildError(format!( + "Line {}: RUN cache mount '{}' requires a target= path", + line_num, raw + ))); + }; + + let sharing = sharing.unwrap_or(RunCacheSharing::Shared); + + let from = from.filter(|from| !from.is_empty()).map(str::to_string); + if source.is_some() && from.is_none() { + return Err(BoxError::BuildError(format!( + "Line {}: RUN cache mount source= requires from=", + line_num + ))); + } + + if !target.starts_with('/') { + return Err(BoxError::BuildError(format!( + "Line {}: RUN cache mount target '{}' must be absolute", + line_num, target + ))); + } + + Ok(RunCacheMount { + raw: raw.to_string(), + id: id.filter(|id| !id.is_empty()).map(str::to_string), + from, + source: source.unwrap_or(".").to_string(), + sharing, + mode, + uid, + gid, + target: target.to_string(), + }) +} + +fn parse_run_bind_mount(raw: &str, spec: &str, line_num: usize) -> Result { + let mut mount_type = None; + let mut from = None; + let mut source = None; + let mut target = None; + let mut read_write = false; + + for part in spec.split(',') { + if let Some((key, value)) = part.split_once('=') { + match key { + "type" => mount_type = Some(value), + "from" => from = Some(value), + "source" | "src" => source = Some(value), + "target" | "dst" | "destination" => target = Some(value), + "rw" | "readwrite" => { + read_write = parse_bool_mount_flag(key, value, raw, line_num)?; + } + _ => { + return Err(BoxError::BuildError(format!( + "Line {}: RUN bind mount option '{}=' is not supported", + line_num, key + ))); + } + } + } else { + match part { + "rw" | "readwrite" => read_write = true, + _ => { + return Err(BoxError::BuildError(format!( + "Line {}: RUN bind mount option '{}' is not supported", + line_num, part + ))); + } + } + } + } + + if mount_type != Some("bind") { + return Err(BoxError::BuildError(format!( + "Line {}: RUN bind mount '{}' has invalid type", + line_num, raw + ))); + } + + let Some(target) = target.filter(|target| !target.is_empty()) else { + return Err(BoxError::BuildError(format!( + "Line {}: RUN bind mount '{}' requires a target= path", + line_num, raw + ))); + }; + + Ok(RunBindMount { + raw: raw.to_string(), + from: from.filter(|from| !from.is_empty()).map(str::to_string), + source: source.unwrap_or(".").to_string(), + target: target.to_string(), + read_write, + }) +} + +fn parse_run_tmpfs_mount(raw: &str, spec: &str, line_num: usize) -> Result { + let mut mount_type = None; + let mut target = None; + + for part in spec.split(',') { + let (key, value) = part.split_once('=').ok_or_else(|| { + BoxError::BuildError(format!( + "Line {}: Invalid RUN tmpfs mount option '{}'", + line_num, raw + )) + })?; + match key { + "type" => mount_type = Some(value), + "target" | "dst" | "destination" => target = Some(value), + "size" => { + return Err(BoxError::BuildError(format!( + "Line {}: RUN tmpfs mount option 'size=' is not supported yet by the warm-pool build path", + line_num + ))); + } + _ => { + return Err(BoxError::BuildError(format!( + "Line {}: RUN tmpfs mount option '{}=' is not supported", + line_num, key + ))); + } + } + } + + if mount_type != Some("tmpfs") { + return Err(BoxError::BuildError(format!( + "Line {}: RUN tmpfs mount '{}' has invalid type", + line_num, raw + ))); + } + + let Some(target) = target.filter(|target| !target.is_empty()) else { + return Err(BoxError::BuildError(format!( + "Line {}: RUN tmpfs mount '{}' requires a target= path", + line_num, raw + ))); + }; + + Ok(RunTmpfsMount { + raw: raw.to_string(), + target: target.to_string(), + }) +} + +fn parse_bool_mount_flag(key: &str, value: &str, raw: &str, line_num: usize) -> Result { + match value { + "1" | "true" | "True" | "TRUE" => Ok(true), + "0" | "false" | "False" | "FALSE" => Ok(false), + _ => Err(BoxError::BuildError(format!( + "Line {}: RUN mount '{}' has invalid boolean {}='{}'", + line_num, raw, key, value + ))), + } +} + +fn parse_run_cache_mount_mode(value: &str, raw: &str, line_num: usize) -> Result { + let value = value + .strip_prefix("0o") + .or_else(|| value.strip_prefix("0O")) + .unwrap_or(value); + let mode = u32::from_str_radix(value, 8).map_err(|_| { + BoxError::BuildError(format!( + "Line {}: RUN cache mount '{}' has invalid octal mode '{}'", + line_num, raw, value + )) + })?; + if mode > 0o7777 { + return Err(BoxError::BuildError(format!( + "Line {}: RUN cache mount '{}' mode '{}' exceeds 07777", + line_num, raw, value + ))); + } + Ok(mode) +} + +fn parse_run_cache_mount_id(key: &str, value: &str, raw: &str, line_num: usize) -> Result { + value.parse::().map_err(|_| { + BoxError::BuildError(format!( + "Line {}: RUN cache mount '{}' has invalid {} '{}'", + line_num, raw, key, value + )) }) } diff --git a/src/runtime/src/oci/build/dockerfile/tests.rs b/src/runtime/src/oci/build/dockerfile/tests.rs index 7386ac18..9fdbd0fa 100644 --- a/src/runtime/src/oci/build/dockerfile/tests.rs +++ b/src/runtime/src/oci/build/dockerfile/tests.rs @@ -72,17 +72,440 @@ mod tests { assert_eq!( result, Instruction::Run { - command: "apt-get update && apt-get install -y curl".to_string(), + command: RunCommand::Shell("apt-get update && apt-get install -y curl".to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], } ); } #[test] - fn test_parse_run_exec_form_rejected() { - let err = parsers::parse_run(r#"["echo", "hello"]"#, 1) + fn test_parse_run_exec_form() { + let result = parsers::parse_run(r#"["/bin/sh", "-c", "echo exec > /out"]"#, 1).unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Exec(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo exec > /out".to_string(), + ]), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_exec_form_empty_rejected() { + let err = parsers::parse_run("[]", 1).unwrap_err().to_string(); + assert!(err.contains("requires at least one argument")); + } + + #[test] + fn test_parse_run_default_network_and_security_options() { + let result = + parsers::parse_run("--network=default --security=sandbox echo hello", 1).unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("echo hello".to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_rejects_non_default_network_and_security_options() { + let err = parsers::parse_run("--network=none echo hello", 1) + .unwrap_err() + .to_string(); + assert!(err.contains("--network=none")); + assert!(err.contains("only --network=default")); + + let err = parsers::parse_run("--security=insecure echo hello", 1) + .unwrap_err() + .to_string(); + assert!(err.contains("--security=insecure")); + assert!(err.contains("only --security=sandbox")); + } + + #[test] + fn test_parse_run_buildkit_cache_mount() { + let result = parsers::parse_run( + "--mount=type=cache,sharing=locked,target=/root/.cache pnpm install", + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("pnpm install".to_string()), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,sharing=locked,target=/root/.cache".to_string(), + id: None, + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_id() { + let result = parsers::parse_run( + "--mount=type=cache,id=pnpm,sharing=locked,target=/root/.cache pnpm install", + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("pnpm install".to_string()), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,id=pnpm,sharing=locked,target=/root/.cache" + .to_string(), + id: Some("pnpm".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_sharing_locked() { + let result = parsers::parse_run( + "--mount=type=cache,id=apt,sharing=locked,target=/var/cache/apt apt-get update", + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("apt-get update".to_string()), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,id=apt,sharing=locked,target=/var/cache/apt" + .to_string(), + id: Some("apt".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/var/cache/apt".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_mode_uid_gid() { + let result = parsers::parse_run( + "--mount=type=cache,id=apt,sharing=locked,mode=0750,uid=1000,gid=1001,target=/var/cache/apt apt-get update", + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("apt-get update".to_string()), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,id=apt,sharing=locked,mode=0750,uid=1000,gid=1001,target=/var/cache/apt" + .to_string(), + id: Some("apt".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: Some(0o750), + uid: Some(1000), + gid: Some(1001), + target: "/var/cache/apt".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_rejects_invalid_mode() { + let err = parsers::parse_run( + "--mount=type=cache,sharing=locked,mode=0999,target=/root/.cache pnpm install", + 1, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("invalid octal mode")); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_from_source() { + let result = parsers::parse_run( + "--mount=type=cache,id=seeded,sharing=locked,from=builder,source=/seed,target=/root/.cache pnpm install", + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("pnpm install".to_string()), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,id=seeded,sharing=locked,from=builder,source=/seed,target=/root/.cache" + .to_string(), + id: Some("seeded".to_string()), + from: Some("builder".to_string()), + source: "/seed".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_rejects_source_without_from() { + let err = parsers::parse_run( + "--mount=type=cache,sharing=locked,source=/seed,target=/root/.cache pnpm install", + 1, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("source=")); + assert!(err.contains("requires from=")); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_rejects_unknown_option() { + let err = parsers::parse_run( + "--mount=type=cache,sharing=locked,foo=bar,target=/root/.cache pnpm install", + 1, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("foo=")); + assert!(err.contains("not supported")); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_sharing_shared() { + let result = parsers::parse_run( + "--mount=type=cache,sharing=shared,target=/root/.cache pnpm install", + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("pnpm install".to_string()), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,sharing=shared,target=/root/.cache".to_string(), + id: None, + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Shared, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_omitted_sharing_defaults_to_shared() { + let result = + parsers::parse_run("--mount=type=cache,target=/root/.cache pnpm install", 1).unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("pnpm install".to_string()), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,target=/root/.cache".to_string(), + id: None, + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Shared, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_cache_mount_rejects_private_sharing() { + let err = parsers::parse_run( + "--mount=type=cache,sharing=private,target=/root/.cache pnpm install", + 1, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("sharing='private'")); + assert!(err.contains("not supported yet")); + } + + #[test] + fn test_parse_run_unsupported_buildkit_mount_rejected() { + let err = parsers::parse_run("--mount=type=secret,id=npmrc npm install", 1) .unwrap_err() .to_string(); - assert!(err.contains("RUN exec form is not supported yet")); + assert!(err.contains("only type=cache, type=bind, and type=tmpfs are supported")); + } + + #[test] + fn test_parse_run_buildkit_bind_mount_defaults_to_context_root() { + let result = parsers::parse_run("--mount=type=bind,target=. go build ./...", 1).unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("go build ./...".to_string()), + cache_mounts: vec![], + bind_mounts: vec![RunBindMount { + from: None, + raw: "--mount=type=bind,target=.".to_string(), + source: ".".to_string(), + target: ".".to_string(), + read_write: false, + }], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_bind_mount_source_and_rw() { + let result = parsers::parse_run( + "--mount=type=bind,source=src,target=/mnt,readwrite=true make", + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("make".to_string()), + cache_mounts: vec![], + bind_mounts: vec![RunBindMount { + from: None, + raw: "--mount=type=bind,source=src,target=/mnt,readwrite=true".to_string(), + source: "src".to_string(), + target: "/mnt".to_string(), + read_write: true, + }], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_bind_mount_from_stage() { + let result = parsers::parse_run( + "--mount=type=bind,from=builder,source=/src,target=/mnt make", + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("make".to_string()), + cache_mounts: vec![], + bind_mounts: vec![RunBindMount { + from: Some("builder".to_string()), + raw: "--mount=type=bind,from=builder,source=/src,target=/mnt".to_string(), + source: "/src".to_string(), + target: "/mnt".to_string(), + read_write: false, + }], + tmpfs_mounts: vec![], + } + ); + } + + #[test] + fn test_parse_run_buildkit_tmpfs_mount() { + let result = parsers::parse_run("--mount=type=tmpfs,target=/tmp make test", 1).unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Shell("make test".to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![RunTmpfsMount { + raw: "--mount=type=tmpfs,target=/tmp".to_string(), + target: "/tmp".to_string(), + }], + } + ); + } + + #[test] + fn test_parse_run_buildkit_tmpfs_mount_rejects_size() { + let err = parsers::parse_run("--mount=type=tmpfs,target=/tmp,size=64m make test", 1) + .unwrap_err() + .to_string(); + assert!(err.contains("size=")); + assert!(err.contains("not supported yet")); + } + + #[test] + fn test_parse_run_exec_form_with_cache_mount() { + let result = parsers::parse_run( + r#"--mount=type=cache,sharing=locked,target=/cache ["echo", "hello"]"#, + 1, + ) + .unwrap(); + assert_eq!( + result, + Instruction::Run { + command: RunCommand::Exec(vec!["echo".to_string(), "hello".to_string()]), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,sharing=locked,target=/cache".to_string(), + id: None, + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/cache".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + } + ); } #[test] @@ -555,7 +978,11 @@ CMD ["app.py"] let content = "FROM alpine:3.19\nRUN apk add --no-cache \\\n curl \\\n wget"; let df = Dockerfile::parse(content).unwrap(); assert_eq!(df.instructions.len(), 2); - if let Instruction::Run { command } = &df.instructions[1] { + if let Instruction::Run { + command: RunCommand::Shell(command), + .. + } = &df.instructions[1] + { assert!(command.contains("curl")); assert!(command.contains("wget")); } else { @@ -905,7 +1332,10 @@ CMD ["app.py"] result, Instruction::OnBuild { instruction: Box::new(Instruction::Run { - command: "echo hello".to_string(), + command: RunCommand::Shell("echo hello".to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], }), } ); @@ -1086,7 +1516,10 @@ CMD ["app.py"] assert_eq!( instruction, Instruction::Run { - command: "echo ready".to_string() + command: RunCommand::Shell("echo ready".to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], } ); } diff --git a/src/runtime/src/oci/build/engine/handlers.rs b/src/runtime/src/oci/build/engine/handlers.rs index 3fb3e8c2..c5fb3c3f 100644 --- a/src/runtime/src/oci/build/engine/handlers.rs +++ b/src/runtime/src/oci/build/engine/handlers.rs @@ -4,12 +4,15 @@ use std::path::{Path, PathBuf}; use a3s_box_core::error::{BoxError, Result}; -use super::super::dockerfile::Instruction; +use super::super::dockerfile::{ + Instruction, RunBindMount, RunCacheMount, RunCommand, RunTmpfsMount, +}; use super::super::dockerignore::DockerIgnore; use super::super::layer::{ create_layer_from_dir_with_chown, create_layer_with_chown, create_layer_with_deletions, - LayerInfo, + sha256_bytes, LayerInfo, }; +use super::stages::resolve_stage_rootfs; use super::utils::{ assert_within, copy_dir_filtered, expand_args, extract_tar_to_dst, is_tar_archive, reject_path_traversal, resolve_chown, resolve_path, @@ -18,6 +21,7 @@ use super::BuildState; #[cfg(target_os = "macos")] const UNSAFE_HOST_RUN_ENV: &str = "A3S_BOX_UNSAFE_HOST_RUN"; +const RUN_OUTPUT_CONTEXT_BYTES: usize = 16 * 1024; /// Whether a COPY/ADD source contains shell glob metacharacters. fn has_glob_meta(s: &str) -> bool { @@ -241,7 +245,12 @@ pub(super) fn handle_copy( /// Returns Some(LayerInfo) if a layer was created, None if skipped. #[allow(clippy::too_many_arguments)] pub(super) fn handle_run( - command: &str, + command: &RunCommand, + cache_mounts: &[RunCacheMount], + bind_mounts: &[RunBindMount], + tmpfs_mounts: &[RunTmpfsMount], + context_dir: &Path, + completed_stages: &[(Option, PathBuf)], rootfs_dir: &Path, layers_dir: &Path, workdir: &str, @@ -249,19 +258,26 @@ pub(super) fn handle_run( shell: &[String], layer_index: usize, quiet: bool, + ignore: Option<&DockerIgnore>, ) -> Result> { #[cfg(target_os = "macos")] { if !unsafe_host_run_enabled() { return Err(BoxError::BuildError(format!( "Dockerfile RUN is not supported on macOS yet because isolated Linux build \ - execution is not implemented. Re-run on Linux or set {UNSAFE_HOST_RUN_ENV}=1 \ + execution is not implemented locally. Re-run on Linux, delegate with \ + `a3s-box build --builder=buildkit-vm`, or set {UNSAFE_HOST_RUN_ENV}=1 \ to opt into unsafe host-side execution for local experiments." ))); } handle_run_on_host_unsafe( command, + cache_mounts, + bind_mounts, + tmpfs_mounts, + context_dir, + completed_stages, rootfs_dir, layers_dir, workdir, @@ -269,6 +285,7 @@ pub(super) fn handle_run( shell, layer_index, quiet, + ignore, ) } @@ -277,63 +294,54 @@ pub(super) fn handle_run( { use super::super::layer::DirSnapshot; - validate_linux_run_preconditions(rootfs_dir, shell, linux_effective_uid())?; - let workdir_path = ensure_linux_run_workdir(rootfs_dir, workdir)?; + validate_linux_run_preconditions(rootfs_dir, command, shell, linux_effective_uid())?; + prepare_linux_run_filesystem(rootfs_dir)?; + ensure_linux_run_workdir(rootfs_dir, workdir)?; + ensure_run_cache_mount_targets(rootfs_dir, cache_mounts)?; let before = DirSnapshot::capture(rootfs_dir)?; + let bind_mount_guard = RunBindMountOverlays::activate( + rootfs_dir, + context_dir, + completed_stages, + bind_mounts, + workdir, + ignore, + )?; + let tmpfs_mount_guard = RunTmpfsMountOverlays::activate(rootfs_dir, tmpfs_mounts, workdir)?; + let run_mounts = LinuxRunMounts::mount(rootfs_dir)?; + let run_mounts = + run_mounts.with_cache_mounts(rootfs_dir, cache_mounts, completed_stages)?; - // Build the command using the configured shell - let mut cmd = std::process::Command::new("chroot"); - cmd.arg(rootfs_dir); - if shell.len() >= 2 { - cmd.arg(&shell[0]); - for arg in &shell[1..] { - cmd.arg(arg); - } - } else if shell.len() == 1 { - cmd.arg(&shell[0]); - } else { - cmd.arg("/bin/sh"); - cmd.arg("-c"); - } - cmd.arg(command); - cmd.current_dir(&workdir_path); - - // Set environment - cmd.env_clear(); - cmd.env( - "PATH", - "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - ); - cmd.env("HOME", "/root"); - for (key, value) in env { - cmd.env(key, value); - } - - let output = cmd - .output() - .map_err(|e| BoxError::BuildError(format!("Failed to execute RUN command: {}", e)))?; + let output = execute_linux_run_command(rootfs_dir, command, workdir, env, shell)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(BoxError::BuildError(format!( - "RUN command failed (exit {}): {}", - output.status.code().unwrap_or(-1), - stderr.trim() - ))); - } - - if !quiet { - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.is_empty() { - print!("{}", stdout); - } + return Err(run_command_failed_error( + &run_command_to_string(command), + &output, + )); } + print_run_output(&output, quiet); + run_mounts.unmount()?; + tmpfs_mount_guard.restore()?; + bind_mount_guard.restore()?; // Capture diff let after = DirSnapshot::capture(rootfs_dir)?; - let changed = before.diff(&after); - let deleted = before.deletions(&after); + let changed = filter_run_mount_paths( + before.diff(&after), + cache_mounts, + bind_mounts, + tmpfs_mounts, + workdir, + ); + let deleted = filter_run_mount_paths( + before.deletions(&after), + cache_mounts, + bind_mounts, + tmpfs_mounts, + workdir, + ); if changed.is_empty() && deleted.is_empty() { return Ok(None); @@ -350,36 +358,228 @@ pub(super) fn handle_run( let _ = ( rootfs_dir, layers_dir, + cache_mounts, + bind_mounts, + tmpfs_mounts, + context_dir, + completed_stages, workdir, env, shell, layer_index, quiet, + ignore, ); Err(BoxError::BuildError(format!( "Dockerfile RUN is not supported on this platform yet because isolated Linux build execution is not implemented: {}", - command + run_command_to_string(command) ))) } } -#[cfg(any(target_os = "linux", test))] +/// Handle RUN through a warm-pool VM lease. +/// +/// The build stage rootfs is mounted into the leased helper VM and the command +/// executes with `ExecRequest.rootfs`, so mutations land back in `rootfs_dir`. +#[cfg(feature = "pool")] +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_run_with_pool( + command: &RunCommand, + cache_mounts: &[RunCacheMount], + bind_mounts: &[RunBindMount], + tmpfs_mounts: &[RunTmpfsMount], + context_dir: &Path, + completed_stages: &[(Option, PathBuf)], + rootfs_dir: &Path, + layers_dir: &Path, + workdir: &str, + env: &[(String, String)], + shell: &[String], + user: Option<&str>, + layer_index: usize, + quiet: bool, + session: &super::BuildRunPoolSession, + ignore: Option<&DockerIgnore>, +) -> Result> { + use super::super::layer::DirSnapshot; + + validate_run_command_preconditions(rootfs_dir, command, shell)?; + prepare_pool_run_filesystem(rootfs_dir)?; + ensure_linux_run_workdir(rootfs_dir, workdir)?; + ensure_run_cache_mount_targets(rootfs_dir, cache_mounts)?; + + let before = DirSnapshot::capture(rootfs_dir)?; + let bind_mount_guard = RunBindMountOverlays::activate( + rootfs_dir, + context_dir, + completed_stages, + bind_mounts, + workdir, + ignore, + )?; + let tmpfs_mount_guard = RunTmpfsMountOverlays::activate(rootfs_dir, tmpfs_mounts, workdir)?; + let cache_mount_guard = PoolRunCacheMounts::activate_with_cache_root( + rootfs_dir, + cache_mounts, + &session.run_cache_dir, + completed_stages, + )?; + let output = match session + .lease + .exec(crate::pool::PoolLeaseExec { + cmd: build_pool_run_cmd(command, shell, workdir), + timeout_ns: Some(session.timeout_ns), + env: run_env_entries(env), + working_dir: build_pool_run_workdir(command, workdir), + rootfs: Some(session.guest_rootfs.clone()), + stdin: None, + user: user.map(str::to_string), + }) + .await + { + Ok(output) => output, + Err(error) => { + cache_mount_guard.restore_without_sync()?; + tmpfs_mount_guard.restore()?; + bind_mount_guard.restore()?; + return Err(BoxError::BuildError(format!( + "Failed to execute RUN in warm pool: {error}" + ))); + } + }; + + if output.exit_code != 0 { + cache_mount_guard.restore_without_sync()?; + tmpfs_mount_guard.restore()?; + bind_mount_guard.restore()?; + return Err(run_command_failed_error_parts( + &run_command_to_string(command), + output.exit_code, + &output.stdout, + &output.stderr, + )); + } + cache_mount_guard.restore()?; + tmpfs_mount_guard.restore()?; + bind_mount_guard.restore()?; + print_output_parts(&output.stdout, &output.stderr, quiet); + + let after = DirSnapshot::capture(rootfs_dir)?; + let changed = filter_run_mount_paths( + before.diff(&after), + cache_mounts, + bind_mounts, + tmpfs_mounts, + workdir, + ); + let deleted = filter_run_mount_paths( + before.deletions(&after), + cache_mounts, + bind_mounts, + tmpfs_mounts, + workdir, + ); + + if changed.is_empty() && deleted.is_empty() { + return Ok(None); + } + + let layer_path = layers_dir.join(format!("layer_{}.tar.gz", layer_index)); + let layer_info = create_layer_with_deletions(rootfs_dir, &changed, &deleted, &layer_path)?; + Ok(Some(layer_info)) +} + +#[cfg(not(feature = "pool"))] +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_run_with_pool( + command: &RunCommand, + _cache_mounts: &[RunCacheMount], + _bind_mounts: &[RunBindMount], + _tmpfs_mounts: &[RunTmpfsMount], + _context_dir: &Path, + _completed_stages: &[(Option, PathBuf)], + _rootfs_dir: &Path, + _layers_dir: &Path, + _workdir: &str, + _env: &[(String, String)], + _shell: &[String], + _user: Option<&str>, + _layer_index: usize, + _quiet: bool, + _session: &super::BuildRunPoolSession, + _ignore: Option<&DockerIgnore>, +) -> Result> { + Err(BoxError::BuildError(format!( + "Dockerfile RUN warm-pool execution requires the runtime 'pool' feature: {}", + run_command_to_string(command) + ))) +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn shell_command_in_workdir(workdir: &str, command: &str) -> String { + let workdir = if workdir.trim().is_empty() { + "/" + } else { + workdir + }; + if workdir == "/" { + command.to_string() + } else { + format!("cd {} && {}", shell_quote(workdir), command) + } +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn shell_quote(value: &str) -> String { + let mut out = String::from("'"); + for ch in value.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} + +#[cfg_attr(all(not(feature = "pool"), not(target_os = "linux")), allow(dead_code))] +fn normalized_run_workdir(workdir: &str) -> &str { + if workdir.trim().is_empty() { + "/" + } else { + workdir + } +} + +fn run_command_to_string(command: &RunCommand) -> String { + match command { + RunCommand::Shell(command) => command.clone(), + RunCommand::Exec(exec) => serde_json::to_string(exec).unwrap_or_else(|_| { + exec.iter() + .map(String::as_str) + .collect::>() + .join(" ") + }), + } +} + fn linux_run_shell_path(shell: &[String]) -> &str { shell.first().map(String::as_str).unwrap_or("/bin/sh") } -#[cfg(any(target_os = "linux", test))] -fn validate_linux_run_preconditions( +fn validate_run_command_preconditions( rootfs_dir: &Path, + command: &RunCommand, shell: &[String], - effective_uid: u32, ) -> Result<()> { - if effective_uid != 0 { - return Err(BoxError::BuildError( - "Dockerfile RUN on Linux requires root privileges because the current isolated build path uses chroot. Re-run as root or build on a root-capable builder.".to_string(), - )); + match command { + RunCommand::Shell(_) => validate_run_shell_preconditions(rootfs_dir, shell), + RunCommand::Exec(exec) => validate_run_exec_preconditions(rootfs_dir, exec), } +} +fn validate_run_shell_preconditions(rootfs_dir: &Path, shell: &[String]) -> Result<()> { let shell_path = linux_run_shell_path(shell); if !shell_path.starts_with('/') { return Err(BoxError::BuildError(format!( @@ -388,7 +588,7 @@ fn validate_linux_run_preconditions( ))); } let shell_in_rootfs = rootfs_dir.join(shell_path.trim_start_matches('/')); - if !shell_in_rootfs.exists() { + if std::fs::symlink_metadata(&shell_in_rootfs).is_err() { return Err(BoxError::BuildError(format!( "Dockerfile RUN shell '{}' was not found in rootfs at {}; the base image must contain the configured shell", shell_path, @@ -399,12 +599,54 @@ fn validate_linux_run_preconditions( Ok(()) } +fn validate_run_exec_preconditions(rootfs_dir: &Path, exec: &[String]) -> Result<()> { + let executable = exec.first().ok_or_else(|| { + BoxError::BuildError("Dockerfile RUN exec form requires at least one argument".to_string()) + })?; + if executable.is_empty() { + return Err(BoxError::BuildError( + "Dockerfile RUN exec form executable cannot be empty".to_string(), + )); + } + if executable.starts_with('/') { + let executable_in_rootfs = rootfs_dir.join(executable.trim_start_matches('/')); + if std::fs::symlink_metadata(&executable_in_rootfs).is_err() { + return Err(BoxError::BuildError(format!( + "Dockerfile RUN exec form executable '{}' was not found in rootfs at {}", + executable, + executable_in_rootfs.display() + ))); + } + } + + Ok(()) +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn validate_linux_run_preconditions( + rootfs_dir: &Path, + command: &RunCommand, + shell: &[String], + effective_uid: u32, +) -> Result<()> { + if effective_uid != 0 { + return Err(BoxError::BuildError( + "Dockerfile RUN on Linux requires root privileges because the current isolated build path uses chroot. Re-run as root or build on a root-capable builder.".to_string(), + )); + } + + validate_run_command_preconditions(rootfs_dir, command, shell) +} + #[cfg(target_os = "linux")] fn linux_effective_uid() -> u32 { unsafe { libc::geteuid() } } -#[cfg(any(target_os = "linux", test))] +#[cfg_attr( + all(not(feature = "pool"), not(target_os = "linux"), not(test)), + allow(dead_code) +)] fn ensure_linux_run_workdir(rootfs_dir: &Path, workdir: &str) -> Result { let workdir = if workdir.trim().is_empty() { "/" @@ -429,121 +671,1639 @@ fn ensure_linux_run_workdir(rootfs_dir: &Path, workdir: &str) -> Result Ok(workdir_path) } -#[cfg(target_os = "macos")] -fn unsafe_host_run_enabled() -> bool { - std::env::var(UNSAFE_HOST_RUN_ENV) - .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) +#[cfg_attr(all(not(feature = "pool"), not(test)), allow(dead_code))] +fn build_run_shell_cmd(shell: &[String], workdir: &str, command: &str) -> Vec { + let run_command = shell_command_in_workdir(workdir, command); + if shell.len() >= 2 { + let mut cmd = shell.to_vec(); + cmd.push(run_command); + cmd + } else if shell.len() == 1 { + vec![shell[0].clone(), run_command] + } else { + vec!["/bin/sh".to_string(), "-c".to_string(), run_command] + } } -/// Execute RUN command directly on host (unsafe macOS escape hatch). -/// -/// This does not provide container/Linux build semantics. It exists only for -/// explicit local experiments while isolated macOS build execution is pending. -#[cfg(target_os = "macos")] -#[allow(clippy::too_many_arguments)] -fn handle_run_on_host_unsafe( - command: &str, +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn build_pool_run_cmd(command: &RunCommand, shell: &[String], workdir: &str) -> Vec { + match command { + RunCommand::Shell(command) => build_run_shell_cmd(shell, workdir, command), + RunCommand::Exec(exec) => exec.to_vec(), + } +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn build_pool_run_workdir(command: &RunCommand, workdir: &str) -> Option { + match command { + RunCommand::Shell(_) => Some("/".to_string()), + RunCommand::Exec(_) => Some(normalized_run_workdir(workdir).to_string()), + } +} + +#[cfg_attr(all(not(feature = "pool"), not(test)), allow(dead_code))] +fn run_env_entries(env: &[(String, String)]) -> Vec { + let mut entries = vec![ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(), + "HOME=/root".to_string(), + ]; + entries.extend(env.iter().map(|(key, value)| format!("{key}={value}"))); + entries +} + +#[cfg(target_os = "linux")] +fn execute_linux_run_command( rootfs_dir: &Path, - layers_dir: &Path, + command: &RunCommand, workdir: &str, - _env: &[(String, String)], + env: &[(String, String)], shell: &[String], - layer_index: usize, - quiet: bool, -) -> Result> { - use super::super::layer::DirSnapshot; - - if !quiet { - println!("→ Executing RUN command on host (unsafe)"); +) -> Result { + match command { + RunCommand::Shell(command) => { + let mut cmd = std::process::Command::new("chroot"); + cmd.arg(rootfs_dir); + if shell.len() >= 2 { + cmd.arg(&shell[0]); + for arg in &shell[1..] { + cmd.arg(arg); + } + } else if shell.len() == 1 { + cmd.arg(&shell[0]); + } else { + cmd.arg("/bin/sh"); + cmd.arg("-c"); + } + let run_command = shell_command_in_workdir(workdir, command); + cmd.arg(&run_command); + configure_run_command_env(&mut cmd, env); + cmd.output() + .map_err(|e| BoxError::BuildError(format!("Failed to execute RUN command: {}", e))) + } + RunCommand::Exec(exec) => execute_linux_run_exec_form(rootfs_dir, exec, workdir, env), } +} - // Capture filesystem state before execution - let before = DirSnapshot::capture(rootfs_dir)?; +#[cfg(target_os = "linux")] +fn execute_linux_run_exec_form( + rootfs_dir: &Path, + exec: &[String], + workdir: &str, + env: &[(String, String)], +) -> Result { + use std::os::unix::process::CommandExt; + + validate_run_exec_preconditions(rootfs_dir, exec)?; + let mut cmd = std::process::Command::new(&exec[0]); + cmd.args(&exec[1..]); + configure_run_command_env(&mut cmd, env); + + let rootfs = path_cstring(rootfs_dir, "RUN rootfs")?; + let workdir = std::ffi::CString::new(normalized_run_workdir(workdir)) + .map_err(|_| BoxError::BuildError("Dockerfile RUN workdir contains NUL".to_string()))?; + unsafe { + cmd.pre_exec(move || { + if libc::chroot(rootfs.as_ptr()) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::chdir(workdir.as_ptr()) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } - // Build the shell command - let shell_cmd = if !shell.is_empty() { - let mut parts = shell.to_vec(); - parts.push(command.to_string()); - parts - } else { - vec!["/bin/sh".to_string(), "-c".to_string(), command.to_string()] - }; + cmd.output() + .map_err(|e| BoxError::BuildError(format!("Failed to execute RUN exec form: {}", e))) +} - // Execute command in rootfs directory - if !quiet { - println!("→ Executing: {}", command); +#[cfg(target_os = "linux")] +fn configure_run_command_env(cmd: &mut std::process::Command, env: &[(String, String)]) { + cmd.env_clear(); + cmd.env( + "PATH", + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ); + cmd.env("HOME", "/root"); + for (key, value) in env { + cmd.env(key, value); } +} - let workdir_path = if workdir.is_empty() || workdir == "/" { - rootfs_dir.to_path_buf() - } else { - rootfs_dir.join(workdir.trim_start_matches('/')) - }; - - // Ensure workdir exists - if !workdir_path.exists() { - std::fs::create_dir_all(&workdir_path).map_err(|e| { +fn ensure_run_cache_mount_targets(rootfs_dir: &Path, cache_mounts: &[RunCacheMount]) -> Result<()> { + for mount in cache_mounts { + let target = run_cache_mount_target(rootfs_dir, mount)?; + std::fs::create_dir_all(&target).map_err(|e| { BoxError::BuildError(format!( - "Failed to create workdir {}: {}", - workdir_path.display(), + "Failed to create RUN cache mount target {}: {}", + target.display(), e )) })?; } + Ok(()) +} - let output = std::process::Command::new(&shell_cmd[0]) - .args(&shell_cmd[1..]) - .current_dir(&workdir_path) - .output() - .map_err(|e| BoxError::BuildError(format!("Failed to execute command: {}", e)))?; +fn run_bind_mount_source( + source_root: &Path, + source_label: &str, + mount: &RunBindMount, + ignore: Option<&DockerIgnore>, +) -> Result<(PathBuf, PathBuf)> { + reject_path_traversal(&mount.source)?; + let rel = normalized_context_rel(&mount.source); + let source_path = source_root.join(&rel); + if !source_path.exists() { + return Err(BoxError::BuildError(format!( + "RUN bind mount source not found: {} (in {})", + mount.source, source_label + ))); + } + assert_within(source_root, &source_path)?; + if let Some(ign) = ignore { + if !rel.as_os_str().is_empty() && ign.is_excluded(&rel) { + return Err(BoxError::BuildError(format!( + "RUN bind mount source not found: {} (excluded by .dockerignore)", + mount.source + ))); + } + } + Ok((source_path, rel)) +} - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); +fn run_bind_mount_target( + rootfs_dir: &Path, + workdir: &str, + mount: &RunBindMount, +) -> Result<(PathBuf, PathBuf)> { + let resolved = resolve_path(normalized_run_workdir(workdir), &mount.target); + reject_path_traversal(&resolved)?; + let rel = normalized_rootfs_rel(&resolved); + if rel.as_os_str().is_empty() { return Err(BoxError::BuildError(format!( - "RUN command failed (exit {}): {}", - output.status.code().unwrap_or(-1), - stderr.trim() + "RUN bind mount target '{}' resolves to /, which is not supported by the warm-pool build overlay", + mount.target ))); } + let target = rootfs_dir.join(&rel); + assert_within(rootfs_dir, &target)?; + Ok((target, rel)) +} - if !quiet { - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.is_empty() { - print!("{}", stdout); - } +fn run_tmpfs_mount_target( + rootfs_dir: &Path, + workdir: &str, + mount: &RunTmpfsMount, +) -> Result<(PathBuf, PathBuf)> { + let resolved = resolve_path(normalized_run_workdir(workdir), &mount.target); + reject_path_traversal(&resolved)?; + let rel = normalized_rootfs_rel(&resolved); + if rel.as_os_str().is_empty() { + return Err(BoxError::BuildError(format!( + "RUN tmpfs mount target '{}' resolves to /, which is not supported by the warm-pool build overlay", + mount.target + ))); } + let target = rootfs_dir.join(&rel); + assert_within(rootfs_dir, &target)?; + Ok((target, rel)) +} - // Capture filesystem state after execution - let after = DirSnapshot::capture(rootfs_dir)?; - let changed = before.diff(&after); - let deleted = before.deletions(&after); +fn normalized_context_rel(path: &str) -> PathBuf { + let trimmed = path.trim_start_matches('/'); + if trimmed == "." { + PathBuf::new() + } else { + normalize_rel_components(Path::new(trimmed)) + } +} - if changed.is_empty() && deleted.is_empty() { - if !quiet { - println!("→ No filesystem changes detected"); +fn normalized_rootfs_rel(path: &str) -> PathBuf { + normalize_rel_components(Path::new(path.trim_start_matches('/'))) +} + +fn normalize_rel_components(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + if let std::path::Component::Normal(part) = component { + out.push(part); } - return Ok(None); } + out +} - // Create layer from changes (and OCI whiteouts for deletions) - let layer_path = layers_dir.join(format!("layer_{}.tar.gz", layer_index)); - let layer_info = create_layer_with_deletions(rootfs_dir, &changed, &deleted, &layer_path)?; +fn copy_run_bind_mount_source( + source: &Path, + source_rel: &Path, + target: &Path, + ignore: Option<&DockerIgnore>, +) -> Result<()> { + let meta = std::fs::symlink_metadata(source).map_err(|e| { + BoxError::BuildError(format!( + "Failed to inspect RUN bind mount source {}: {}", + source.display(), + e + )) + })?; - if !quiet { - println!( - "→ Created layer with {} changes, {} deletions", - changed.len(), - deleted.len() - ); + if meta.is_dir() { + copy_dir_filtered(source, target, source_rel, ignore)?; + return Ok(()); } - Ok(Some(layer_info)) -} - -/// Handle ADD: like COPY but supports URL download and tar auto-extraction. -#[allow(clippy::too_many_arguments)] -pub(super) fn handle_add( + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN bind mount target parent {}: {}", + parent.display(), + e + )) + })?; + } + + if meta.file_type().is_symlink() { + copy_symlink(source, target) + } else { + std::fs::copy(source, target).map(|_| ()).map_err(|e| { + BoxError::BuildError(format!( + "Failed to copy RUN bind mount source {} to {}: {}", + source.display(), + target.display(), + e + )) + }) + } +} + +fn copy_symlink(source: &Path, target: &Path) -> Result<()> { + let link_target = std::fs::read_link(source).map_err(|e| { + BoxError::BuildError(format!( + "Failed to read RUN bind mount symlink {}: {}", + source.display(), + e + )) + })?; + #[cfg(unix)] + { + std::os::unix::fs::symlink(&link_target, target).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN bind mount symlink {} -> {}: {}", + target.display(), + link_target.display(), + e + )) + }) + } + #[cfg(not(unix))] + { + std::fs::write(target, Vec::new()).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN bind mount symlink placeholder {} -> {}: {}", + target.display(), + link_target.display(), + e + )) + }) + } +} + +fn run_cache_mount_target(rootfs_dir: &Path, mount: &RunCacheMount) -> Result { + reject_path_traversal(&mount.target)?; + let target = rootfs_dir.join(mount.target.trim_start_matches('/')); + assert_within(rootfs_dir, &target)?; + Ok(target) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn filter_run_mount_paths( + paths: Vec, + cache_mounts: &[RunCacheMount], + bind_mounts: &[RunBindMount], + tmpfs_mounts: &[RunTmpfsMount], + workdir: &str, +) -> Vec { + if cache_mounts.is_empty() && bind_mounts.is_empty() && tmpfs_mounts.is_empty() { + return paths; + } + + let mut exact_paths = Vec::new(); + let mut subtree_paths = Vec::new(); + for mount in cache_mounts { + let mount_path = PathBuf::from(mount.target.trim_start_matches('/')); + subtree_paths.push(mount_path.clone()); + for ancestor in mount_path.ancestors() { + if !ancestor.as_os_str().is_empty() { + exact_paths.push(ancestor.to_path_buf()); + } + } + } + for mount in bind_mounts { + let resolved = resolve_path(normalized_run_workdir(workdir), &mount.target); + let mount_path = normalized_rootfs_rel(&resolved); + subtree_paths.push(mount_path.clone()); + for ancestor in mount_path.ancestors() { + if !ancestor.as_os_str().is_empty() { + exact_paths.push(ancestor.to_path_buf()); + } + } + } + for mount in tmpfs_mounts { + let resolved = resolve_path(normalized_run_workdir(workdir), &mount.target); + let mount_path = normalized_rootfs_rel(&resolved); + subtree_paths.push(mount_path.clone()); + for ancestor in mount_path.ancestors() { + if !ancestor.as_os_str().is_empty() { + exact_paths.push(ancestor.to_path_buf()); + } + } + } + exact_paths.sort(); + exact_paths.dedup(); + subtree_paths.sort(); + subtree_paths.dedup(); + paths + .into_iter() + .filter(|path| { + !exact_paths.iter().any(|mount_path| path == mount_path) + && !subtree_paths + .iter() + .any(|mount_path| path == mount_path || path.starts_with(mount_path)) + }) + .collect() +} + +fn run_cache_mount_dir(cache_root: &Path, mount: &RunCacheMount) -> PathBuf { + let id = mount.id.as_deref().unwrap_or(&mount.target); + cache_root.join(sha256_bytes(id.as_bytes())) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn run_cache_mount_seed_source( + completed_stages: &[(Option, PathBuf)], + mount: &RunCacheMount, +) -> Result> { + let Some(from_ref) = mount.from.as_deref() else { + return Ok(None); + }; + + reject_path_traversal(&mount.source)?; + let rel = normalized_context_rel(&mount.source); + let source_root = resolve_stage_rootfs(from_ref, completed_stages)?; + let source = source_root.join(&rel); + if !source.exists() { + return Err(BoxError::BuildError(format!( + "RUN cache mount seed source not found: {} (in from={})", + mount.source, from_ref + ))); + } + assert_within(source_root, &source)?; + if !source.is_dir() { + return Err(BoxError::BuildError(format!( + "RUN cache mount seed source must be a directory: {} (in from={})", + mount.source, from_ref + ))); + } + Ok(Some(source)) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn seed_run_cache_mount( + cache_dir: &Path, + mount: &RunCacheMount, + completed_stages: &[(Option, PathBuf)], +) -> Result<()> { + if cache_dir.exists() { + return Ok(()); + } + + let Some(seed_source) = run_cache_mount_seed_source(completed_stages, mount)? else { + return Ok(()); + }; + + let parent = cache_dir.parent().ok_or_else(|| { + BoxError::BuildError(format!( + "RUN cache directory has no parent: {}", + cache_dir.display() + )) + })?; + std::fs::create_dir_all(parent).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN cache parent {}: {}", + parent.display(), + e + )) + })?; + copy_run_cache_seed_to(&seed_source, cache_dir) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn copy_run_cache_seed_to(seed_source: &Path, cache_dir: &Path) -> Result<()> { + crate::cache::layer_cache::copy_dir_recursive(seed_source, cache_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to seed RUN cache mount {} from {}: {}", + cache_dir.display(), + seed_source.display(), + e + )) + }) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn hydrate_run_cache_mount(cache_dir: &Path, target: &Path) -> Result<()> { + if !cache_dir.exists() { + return Ok(()); + } + if !cache_dir.is_dir() { + return Err(BoxError::BuildError(format!( + "RUN cache mount {} is not a directory", + cache_dir.display() + ))); + } + remove_path_any(target)?; + crate::cache::layer_cache::copy_dir_recursive(cache_dir, target).map_err(|e| { + BoxError::BuildError(format!( + "Failed to hydrate RUN cache mount {} from {}: {}", + target.display(), + cache_dir.display(), + e + )) + }) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn sync_run_cache_mount(target: &Path, cache_dir: &Path) -> Result<()> { + let parent = cache_dir.parent().ok_or_else(|| { + BoxError::BuildError(format!( + "RUN cache directory has no parent: {}", + cache_dir.display() + )) + })?; + std::fs::create_dir_all(parent).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN cache parent {}: {}", + parent.display(), + e + )) + })?; + + let staging = tempfile::Builder::new() + .prefix(".run-cache-staging-") + .tempdir_in(parent) + .map_err(|e| { + BoxError::BuildError(format!("Failed to create RUN cache staging dir: {e}")) + })?; + let staged = staging.path().join("cache"); + if target.exists() { + crate::cache::layer_cache::copy_dir_recursive(target, &staged).map_err(|e| { + BoxError::BuildError(format!( + "Failed to stage RUN cache mount {} into {}: {}", + target.display(), + staged.display(), + e + )) + })?; + } else { + std::fs::create_dir_all(&staged).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create empty RUN cache staging dir {}: {}", + staged.display(), + e + )) + })?; + } + + remove_path_any(cache_dir)?; + std::fs::rename(&staged, cache_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to publish RUN cache mount {}: {}", + cache_dir.display(), + e + )) + }) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +struct PoolRunCacheMounts { + staging_dir: Option, + overlays: Vec, + restored: bool, + sync_cache: bool, +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +struct PoolRunCacheMountOverlay { + target: PathBuf, + backup: PathBuf, + cache_dir: PathBuf, + _lock: crate::file_lock::FileLock, +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +impl PoolRunCacheMounts { + fn activate_with_cache_root( + rootfs_dir: &Path, + cache_mounts: &[RunCacheMount], + cache_root: &Path, + completed_stages: &[(Option, PathBuf)], + ) -> Result { + let mut mounts = Self { + staging_dir: None, + overlays: Vec::new(), + restored: false, + sync_cache: false, + }; + + if cache_mounts.is_empty() { + return Ok(mounts); + } + + std::fs::create_dir_all(cache_root).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN cache root {}: {}", + cache_root.display(), + e + )) + })?; + let staging_dir = rootfs_dir + .join(".a3s-box-run-cache-overlays") + .join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&staging_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN cache mount staging dir {}: {}", + staging_dir.display(), + e + )) + })?; + mounts.staging_dir = Some(staging_dir.clone()); + + for (idx, mount) in cache_mounts.iter().enumerate() { + let target = run_cache_mount_target(rootfs_dir, mount)?; + let backup = staging_dir.join(format!("target-{idx}")); + let cache_dir = run_cache_mount_dir(cache_root, mount); + if mounts + .overlays + .iter() + .any(|overlay| overlay.cache_dir == cache_dir) + { + return Err(BoxError::BuildError(format!( + "Duplicate RUN cache mount id/target for {}", + mount.raw + ))); + } + // The warm-pool cache mount is a host-side hydrate/publish overlay, + // not BuildKit's live shared directory, so even `sharing=shared` + // serializes one cache key to avoid losing concurrent writeback. + let lock = crate::file_lock::FileLock::acquire(&cache_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to lock RUN cache mount {}: {}", + cache_dir.display(), + e + )) + })?; + seed_run_cache_mount(&cache_dir, mount, completed_stages)?; + std::fs::rename(&target, &backup).map_err(|e| { + BoxError::BuildError(format!( + "Failed to hide RUN cache mount target {}: {}", + target.display(), + e + )) + })?; + mounts.overlays.push(PoolRunCacheMountOverlay { + target: target.clone(), + backup, + cache_dir: cache_dir.clone(), + _lock: lock, + }); + std::fs::create_dir_all(&target).map_err(|e| { + BoxError::BuildError(format!( + "Failed to activate RUN cache mount target {}: {}", + target.display(), + e + )) + })?; + hydrate_run_cache_mount(&cache_dir, &target)?; + apply_run_cache_mount_metadata(&target, mount)?; + } + + mounts.sync_cache = true; + Ok(mounts) + } + + fn restore(mut self) -> Result<()> { + let result = self.restore_inner(); + if result.is_ok() { + self.restored = true; + } + result + } + + fn restore_without_sync(mut self) -> Result<()> { + self.sync_cache = false; + let result = self.restore_inner(); + if result.is_ok() { + self.restored = true; + } + result + } + + fn restore_inner(&mut self) -> Result<()> { + if self.restored { + return Ok(()); + } + + let mut first_error = None; + for overlay in self.overlays.iter().rev() { + if self.sync_cache { + if let Err(error) = sync_run_cache_mount(&overlay.target, &overlay.cache_dir) { + first_error.get_or_insert(error); + } + } + if let Err(error) = remove_path_any(&overlay.target) { + first_error.get_or_insert(error); + continue; + } + if let Err(error) = std::fs::rename(&overlay.backup, &overlay.target).map_err(|e| { + BoxError::BuildError(format!( + "Failed to restore RUN cache mount target {}: {}", + overlay.target.display(), + e + )) + }) { + first_error.get_or_insert(error); + } + } + + if let Some(staging_dir) = &self.staging_dir { + if let Err(error) = std::fs::remove_dir_all(staging_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to remove RUN cache mount staging dir {}: {}", + staging_dir.display(), + e + )) + }) { + first_error.get_or_insert(error); + } + if let Some(parent) = staging_dir.parent() { + match std::fs::remove_dir(parent) { + Ok(()) => {} + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => {} + Err(err) => { + first_error.get_or_insert(BoxError::BuildError(format!( + "Failed to remove RUN cache mount staging parent {}: {}", + parent.display(), + err + ))); + } + } + } + } + + match first_error { + Some(error) => Err(error), + None => { + self.restored = true; + Ok(()) + } + } + } +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +impl Drop for PoolRunCacheMounts { + fn drop(&mut self) { + let _ = self.restore_inner(); + } +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +struct RunBindMountOverlays { + staging_dir: Option, + overlays: Vec, + restored: bool, +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +struct RunBindMountOverlay { + target: PathBuf, + backup: Option, +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +impl RunBindMountOverlays { + #[cfg(test)] + fn activate_context( + rootfs_dir: &Path, + context_dir: &Path, + bind_mounts: &[RunBindMount], + workdir: &str, + ignore: Option<&DockerIgnore>, + ) -> Result { + Self::activate(rootfs_dir, context_dir, &[], bind_mounts, workdir, ignore) + } + + fn activate( + rootfs_dir: &Path, + context_dir: &Path, + completed_stages: &[(Option, PathBuf)], + bind_mounts: &[RunBindMount], + workdir: &str, + ignore: Option<&DockerIgnore>, + ) -> Result { + let mut mounts = Self { + staging_dir: None, + overlays: Vec::new(), + restored: false, + }; + + if bind_mounts.is_empty() { + return Ok(mounts); + } + + let staging_dir = rootfs_dir + .join(".a3s-box-run-bind-overlays") + .join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&staging_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN bind mount staging dir {}: {}", + staging_dir.display(), + e + )) + })?; + mounts.staging_dir = Some(staging_dir.clone()); + + for (idx, mount) in bind_mounts.iter().enumerate() { + let (source_root, source_ignore, source_label) = match mount.from.as_deref() { + Some(from_ref) => { + let rootfs = resolve_stage_rootfs(from_ref, completed_stages)?; + ( + rootfs, + None, + format!("stage '{}' rootfs {}", from_ref, rootfs.display()), + ) + } + None => ( + context_dir, + ignore, + format!("build context {}", context_dir.display()), + ), + }; + let (source, source_rel) = + run_bind_mount_source(source_root, &source_label, mount, source_ignore)?; + let (target, _target_rel) = run_bind_mount_target(rootfs_dir, workdir, mount)?; + let backup = if target.exists() { + let backup = staging_dir.join(format!("target-{idx}")); + std::fs::rename(&target, &backup).map_err(|e| { + BoxError::BuildError(format!( + "Failed to hide RUN bind mount target {}: {}", + target.display(), + e + )) + })?; + Some(backup) + } else { + None + }; + + mounts.overlays.push(RunBindMountOverlay { + target: target.clone(), + backup, + }); + copy_run_bind_mount_source(&source, &source_rel, &target, source_ignore)?; + } + + Ok(mounts) + } + + fn restore(mut self) -> Result<()> { + let result = self.restore_inner(); + if result.is_ok() { + self.restored = true; + } + result + } + + fn restore_inner(&mut self) -> Result<()> { + if self.restored { + return Ok(()); + } + + let mut first_error = None; + for overlay in self.overlays.iter().rev() { + if let Err(error) = remove_path_any(&overlay.target) { + first_error.get_or_insert(error); + } + if let Some(backup) = &overlay.backup { + if let Err(error) = std::fs::rename(backup, &overlay.target).map_err(|e| { + BoxError::BuildError(format!( + "Failed to restore RUN bind mount target {}: {}", + overlay.target.display(), + e + )) + }) { + first_error.get_or_insert(error); + } + } + } + + if let Some(staging_dir) = &self.staging_dir { + if let Err(error) = std::fs::remove_dir_all(staging_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to remove RUN bind mount staging dir {}: {}", + staging_dir.display(), + e + )) + }) { + first_error.get_or_insert(error); + } + if let Some(parent) = staging_dir.parent() { + match std::fs::remove_dir(parent) { + Ok(()) => {} + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => {} + Err(err) => { + first_error.get_or_insert(BoxError::BuildError(format!( + "Failed to remove RUN bind mount staging parent {}: {}", + parent.display(), + err + ))); + } + } + } + } + + match first_error { + Some(error) => Err(error), + None => { + self.restored = true; + Ok(()) + } + } + } +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +impl Drop for RunBindMountOverlays { + fn drop(&mut self) { + let _ = self.restore_inner(); + } +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +struct RunTmpfsMountOverlays { + staging_dir: Option, + overlays: Vec, + restored: bool, +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +struct RunTmpfsMountOverlay { + target: PathBuf, + backup: Option, +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +impl RunTmpfsMountOverlays { + fn activate(rootfs_dir: &Path, tmpfs_mounts: &[RunTmpfsMount], workdir: &str) -> Result { + let mut mounts = Self { + staging_dir: None, + overlays: Vec::new(), + restored: false, + }; + + if tmpfs_mounts.is_empty() { + return Ok(mounts); + } + + let staging_dir = rootfs_dir + .join(".a3s-box-run-tmpfs-overlays") + .join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&staging_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN tmpfs mount staging dir {}: {}", + staging_dir.display(), + e + )) + })?; + mounts.staging_dir = Some(staging_dir.clone()); + + for (idx, mount) in tmpfs_mounts.iter().enumerate() { + let (target, _target_rel) = run_tmpfs_mount_target(rootfs_dir, workdir, mount)?; + let backup = if target.exists() { + let backup = staging_dir.join(format!("target-{idx}")); + std::fs::rename(&target, &backup).map_err(|e| { + BoxError::BuildError(format!( + "Failed to hide RUN tmpfs mount target {}: {}", + target.display(), + e + )) + })?; + Some(backup) + } else { + None + }; + + mounts.overlays.push(RunTmpfsMountOverlay { + target: target.clone(), + backup, + }); + std::fs::create_dir_all(&target).map_err(|e| { + BoxError::BuildError(format!( + "Failed to activate RUN tmpfs mount target {}: {}", + target.display(), + e + )) + })?; + } + + Ok(mounts) + } + + fn restore(mut self) -> Result<()> { + let result = self.restore_inner(); + if result.is_ok() { + self.restored = true; + } + result + } + + fn restore_inner(&mut self) -> Result<()> { + if self.restored { + return Ok(()); + } + + let mut first_error = None; + for overlay in self.overlays.iter().rev() { + if let Err(error) = remove_path_any(&overlay.target) { + first_error.get_or_insert(error); + } + if let Some(backup) = &overlay.backup { + if let Err(error) = std::fs::rename(backup, &overlay.target).map_err(|e| { + BoxError::BuildError(format!( + "Failed to restore RUN tmpfs mount target {}: {}", + overlay.target.display(), + e + )) + }) { + first_error.get_or_insert(error); + } + } + } + + if let Some(staging_dir) = &self.staging_dir { + if let Err(error) = std::fs::remove_dir_all(staging_dir).map_err(|e| { + BoxError::BuildError(format!( + "Failed to remove RUN tmpfs mount staging dir {}: {}", + staging_dir.display(), + e + )) + }) { + first_error.get_or_insert(error); + } + if let Some(parent) = staging_dir.parent() { + match std::fs::remove_dir(parent) { + Ok(()) => {} + Err(err) + if matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => {} + Err(err) => { + first_error.get_or_insert(BoxError::BuildError(format!( + "Failed to remove RUN tmpfs mount staging parent {}: {}", + parent.display(), + err + ))); + } + } + } + } + + match first_error { + Some(error) => Err(error), + None => { + self.restored = true; + Ok(()) + } + } + } +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +impl Drop for RunTmpfsMountOverlays { + fn drop(&mut self) { + let _ = self.restore_inner(); + } +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn remove_path_any(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() && !meta.file_type().is_symlink() => { + std::fs::remove_dir_all(path) + } + Ok(_) => std::fs::remove_file(path), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => { + return Err(BoxError::BuildError(format!( + "Failed to inspect RUN cache mount target {}: {}", + path.display(), + err + ))); + } + } + .map_err(|e| { + BoxError::BuildError(format!( + "Failed to remove RUN cache mount target {}: {}", + path.display(), + e + )) + }) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn apply_run_cache_mount_metadata(target: &Path, mount: &RunCacheMount) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::PermissionsExt; + + if let Some(mode) = mount.mode { + let mut permissions = std::fs::metadata(target) + .map_err(|e| { + BoxError::BuildError(format!( + "Failed to inspect RUN cache mount target {}: {}", + target.display(), + e + )) + })? + .permissions(); + permissions.set_mode(mode); + std::fs::set_permissions(target, permissions).map_err(|e| { + BoxError::BuildError(format!( + "Failed to set RUN cache mount mode {:o} on {}: {}", + mode, + target.display(), + e + )) + })?; + } + + if mount.uid.is_some() || mount.gid.is_some() { + let uid = mount.uid.map(|uid| uid as libc::uid_t).unwrap_or(!0); + let gid = mount.gid.map(|gid| gid as libc::gid_t).unwrap_or(!0); + let c_path = std::ffi::CString::new(target.as_os_str().as_bytes()).map_err(|_| { + BoxError::BuildError(format!( + "RUN cache mount target contains NUL: {}", + target.display() + )) + })?; + let ret = unsafe { libc::chown(c_path.as_ptr(), uid, gid) }; + if ret != 0 { + return Err(BoxError::BuildError(format!( + "Failed to set RUN cache mount ownership on {}: {}", + target.display(), + std::io::Error::last_os_error() + ))); + } + } + } + + #[cfg(not(unix))] + { + let _ = (target, mount); + } + + Ok(()) +} + +fn print_run_output(output: &std::process::Output, quiet: bool) { + print_output_parts(&output.stdout, &output.stderr, quiet); +} + +fn print_output_parts(stdout: &[u8], stderr: &[u8], quiet: bool) { + if quiet { + return; + } + + if !stdout.is_empty() { + print!("{}", String::from_utf8_lossy(stdout)); + } + if !stderr.is_empty() { + use std::io::Write as _; + let _ = std::io::stderr().write_all(String::from_utf8_lossy(stderr).as_bytes()); + } +} + +fn run_command_failed_error(command: &str, output: &std::process::Output) -> BoxError { + let exit = output + .status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()); + run_command_failed_error_message(command, exit, &output.stdout, &output.stderr) +} + +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +fn run_command_failed_error_parts( + command: &str, + exit_code: i32, + stdout: &[u8], + stderr: &[u8], +) -> BoxError { + run_command_failed_error_message(command, exit_code.to_string(), stdout, stderr) +} + +fn run_command_failed_error_message( + command: &str, + exit: String, + stdout: &[u8], + stderr: &[u8], +) -> BoxError { + let mut message = format!("RUN command failed (exit {exit}): {command}"); + + append_output_context(&mut message, "stdout", stdout); + append_output_context(&mut message, "stderr", stderr); + + if stdout.is_empty() && stderr.is_empty() { + message.push_str("\n(no stdout or stderr captured)"); + } + + BoxError::BuildError(message) +} + +#[cfg_attr(all(not(feature = "pool"), not(test)), allow(dead_code))] +fn prepare_pool_run_filesystem(rootfs_dir: &Path) -> Result<()> { + for dir in ["dev", "proc", "sys", "tmp", "var/tmp", "etc"] { + std::fs::create_dir_all(rootfs_dir.join(dir)).map_err(|e| { + BoxError::BuildError(format!( + "Failed to prepare RUN directory {}: {}", + rootfs_dir.join(dir).display(), + e + )) + })?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for dir in ["tmp", "var/tmp"] { + let path = rootfs_dir.join(dir); + let mut perms = std::fs::metadata(&path) + .map_err(|e| { + BoxError::BuildError(format!("Failed to inspect {}: {}", path.display(), e)) + })? + .permissions(); + perms.set_mode(0o1777); + std::fs::set_permissions(&path, perms).map_err(|e| { + BoxError::BuildError(format!( + "Failed to set sticky tmp permissions on {}: {}", + path.display(), + e + )) + })?; + } + ensure_run_symlink(rootfs_dir.join("dev/fd"), "/proc/self/fd")?; + ensure_run_symlink(rootfs_dir.join("dev/stdin"), "/proc/self/fd/0")?; + ensure_run_symlink(rootfs_dir.join("dev/stdout"), "/proc/self/fd/1")?; + ensure_run_symlink(rootfs_dir.join("dev/stderr"), "/proc/self/fd/2")?; + } + + ensure_run_resolv_conf(rootfs_dir) +} + +fn append_output_context(message: &mut String, label: &str, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + + message.push('\n'); + message.push_str(label); + message.push_str(":\n"); + message.push_str(&lossy_tail(bytes, RUN_OUTPUT_CONTEXT_BYTES)); +} + +fn lossy_tail(bytes: &[u8], max_bytes: usize) -> String { + let (slice, truncated) = if bytes.len() > max_bytes { + (&bytes[bytes.len() - max_bytes..], true) + } else { + (bytes, false) + }; + let mut output = String::new(); + if truncated { + output.push_str(&format!( + "[showing last {} bytes of {} captured bytes]\n", + max_bytes, + bytes.len() + )); + } + output.push_str(String::from_utf8_lossy(slice).trim_end()); + output +} + +#[cfg(target_os = "linux")] +fn prepare_linux_run_filesystem(rootfs_dir: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + for dir in ["dev", "proc", "tmp", "var/tmp", "etc"] { + std::fs::create_dir_all(rootfs_dir.join(dir)).map_err(|e| { + BoxError::BuildError(format!( + "Failed to prepare RUN directory {}: {}", + rootfs_dir.join(dir).display(), + e + )) + })?; + } + + for dir in ["tmp", "var/tmp"] { + let path = rootfs_dir.join(dir); + let mut perms = std::fs::metadata(&path) + .map_err(|e| { + BoxError::BuildError(format!("Failed to inspect {}: {}", path.display(), e)) + })? + .permissions(); + perms.set_mode(0o1777); + std::fs::set_permissions(&path, perms).map_err(|e| { + BoxError::BuildError(format!( + "Failed to set sticky tmp permissions on {}: {}", + path.display(), + e + )) + })?; + } + + for dev in ["null", "zero", "random", "urandom"] { + let target = rootfs_dir.join("dev").join(dev); + if !target.exists() { + std::fs::File::create(&target).map_err(|e| { + BoxError::BuildError(format!("Failed to create {}: {}", target.display(), e)) + })?; + } + } + + ensure_run_symlink(rootfs_dir.join("dev/fd"), "/proc/self/fd")?; + ensure_run_symlink(rootfs_dir.join("dev/stdin"), "/proc/self/fd/0")?; + ensure_run_symlink(rootfs_dir.join("dev/stdout"), "/proc/self/fd/1")?; + ensure_run_symlink(rootfs_dir.join("dev/stderr"), "/proc/self/fd/2")?; + ensure_run_resolv_conf(rootfs_dir)?; + + Ok(()) +} + +#[cfg(unix)] +#[cfg_attr( + all(not(feature = "pool"), not(target_os = "linux"), not(test)), + allow(dead_code) +)] +fn ensure_run_symlink(path: PathBuf, target: &str) -> Result<()> { + match std::fs::symlink_metadata(&path) { + Ok(meta) if meta.file_type().is_symlink() => return Ok(()), + Ok(_) => return Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(BoxError::BuildError(format!( + "Failed to inspect {}: {}", + path.display(), + err + ))); + } + } + + std::os::unix::fs::symlink(target, &path).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create RUN symlink {} -> {}: {}", + path.display(), + target, + e + )) + }) +} + +#[cfg_attr( + all(not(feature = "pool"), not(target_os = "linux"), not(test)), + allow(dead_code) +)] +fn ensure_run_resolv_conf(rootfs_dir: &Path) -> Result<()> { + let path = rootfs_dir.join("etc/resolv.conf"); + if std::fs::metadata(&path) + .map(|m| m.len() > 0) + .unwrap_or(false) + { + return Ok(()); + } + + let content = std::fs::read_to_string("/etc/resolv.conf") + .unwrap_or_else(|_| "nameserver 8.8.8.8\nnameserver 8.8.4.4\n".to_string()); + std::fs::write(&path, content) + .map_err(|e| BoxError::BuildError(format!("Failed to write {}: {}", path.display(), e))) +} + +#[cfg(target_os = "linux")] +struct LinuxRunMounts { + mounted: Vec, + cache_dirs: Vec, +} + +#[cfg(target_os = "linux")] +impl LinuxRunMounts { + fn mount(rootfs_dir: &Path) -> Result { + let mut mounts = Self { + mounted: Vec::new(), + cache_dirs: Vec::new(), + }; + + mounts.mount_proc(&rootfs_dir.join("proc"))?; + for dev in ["null", "zero", "random", "urandom"] { + mounts.bind_mount( + Path::new("/dev").join(dev), + rootfs_dir.join("dev").join(dev), + )?; + } + + Ok(mounts) + } + + fn with_cache_mounts( + mut self, + rootfs_dir: &Path, + cache_mounts: &[RunCacheMount], + completed_stages: &[(Option, PathBuf)], + ) -> Result { + for mount in cache_mounts { + let cache_dir = tempfile::Builder::new() + .prefix("a3s-box-run-cache-") + .tempdir() + .map_err(|e| { + BoxError::BuildError(format!("Failed to create RUN cache mount: {}", e)) + })?; + if let Some(seed_source) = run_cache_mount_seed_source(completed_stages, mount)? { + copy_run_cache_seed_to(&seed_source, cache_dir.path())?; + } + let target = rootfs_dir.join(mount.target.trim_start_matches('/')); + self.bind_mount(cache_dir.path().to_path_buf(), target)?; + self.cache_dirs.push(cache_dir); + } + Ok(self) + } + + fn mount_proc(&mut self, target: &Path) -> Result<()> { + mount_linux( + Some(Path::new("proc")), + target, + Some("proc"), + libc::MS_NOSUID | libc::MS_NOEXEC | libc::MS_NODEV, + )?; + self.mounted.push(target.to_path_buf()); + Ok(()) + } + + fn bind_mount(&mut self, source: PathBuf, target: PathBuf) -> Result<()> { + mount_linux(Some(&source), &target, None, libc::MS_BIND)?; + self.mounted.push(target); + Ok(()) + } + + fn unmount(mut self) -> Result<()> { + let mut first_error = None; + for target in self.mounted.iter().rev() { + if let Err(error) = unmount_linux(target) { + first_error.get_or_insert(error); + } + } + self.mounted.clear(); + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } +} + +#[cfg(target_os = "linux")] +impl Drop for LinuxRunMounts { + fn drop(&mut self) { + for target in self.mounted.iter().rev() { + let _ = unmount_linux(target); + } + } +} + +#[cfg(target_os = "linux")] +fn unmount_linux(target: &Path) -> Result<()> { + let c_target = path_cstring(target, "unmount target")?; + let ret = unsafe { libc::umount2(c_target.as_ptr(), libc::MNT_DETACH) }; + if ret == 0 { + Ok(()) + } else { + Err(BoxError::BuildError(format!( + "Failed to unmount RUN support at {}: {}", + target.display(), + std::io::Error::last_os_error() + ))) + } +} + +#[cfg(target_os = "linux")] +fn mount_linux( + source: Option<&Path>, + target: &Path, + fstype: Option<&str>, + flags: libc::c_ulong, +) -> Result<()> { + let c_source = source + .map(|source| path_cstring(source, "mount source")) + .transpose()?; + let c_target = path_cstring(target, "mount target")?; + let c_fstype = fstype + .map(std::ffi::CString::new) + .transpose() + .map_err(|_| BoxError::BuildError("Cannot mount fstype containing NUL".to_string()))?; + + let ret = unsafe { + libc::mount( + c_source + .as_ref() + .map(|value| value.as_ptr()) + .unwrap_or(std::ptr::null()), + c_target.as_ptr(), + c_fstype + .as_ref() + .map(|value| value.as_ptr()) + .unwrap_or(std::ptr::null()), + flags, + std::ptr::null(), + ) + }; + + if ret == 0 { + Ok(()) + } else { + Err(BoxError::BuildError(format!( + "Failed to mount RUN support at {}: {}", + target.display(), + std::io::Error::last_os_error() + ))) + } +} + +#[cfg(target_os = "linux")] +fn path_cstring(path: &Path, label: &str) -> Result { + use std::os::unix::ffi::OsStrExt; + + std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|_| BoxError::BuildError(format!("{label} contains NUL: {}", path.display()))) +} + +#[cfg(target_os = "macos")] +fn unsafe_host_run_enabled() -> bool { + std::env::var(UNSAFE_HOST_RUN_ENV) + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false) +} + +/// Execute RUN command directly on host (unsafe macOS escape hatch). +/// +/// This does not provide container/Linux build semantics. It exists only for +/// explicit local experiments while isolated macOS build execution is pending. +#[cfg(target_os = "macos")] +#[allow(clippy::too_many_arguments)] +fn handle_run_on_host_unsafe( + command: &RunCommand, + cache_mounts: &[RunCacheMount], + bind_mounts: &[RunBindMount], + tmpfs_mounts: &[RunTmpfsMount], + context_dir: &Path, + completed_stages: &[(Option, PathBuf)], + rootfs_dir: &Path, + layers_dir: &Path, + workdir: &str, + env: &[(String, String)], + shell: &[String], + layer_index: usize, + quiet: bool, + ignore: Option<&DockerIgnore>, +) -> Result> { + use super::super::layer::DirSnapshot; + + let RunCommand::Shell(command) = command else { + return Err(BoxError::BuildError( + "Dockerfile RUN exec form requires isolated Linux execution; use --run-pool or --builder=buildkit-vm on macOS".to_string(), + )); + }; + + if !quiet { + println!("→ Executing RUN command on host (unsafe)"); + } + + // Capture filesystem state before execution + let before = DirSnapshot::capture(rootfs_dir)?; + + // Build the shell command + let shell_cmd = if !shell.is_empty() { + let mut parts = shell.to_vec(); + parts.push(command.to_string()); + parts + } else { + vec!["/bin/sh".to_string(), "-c".to_string(), command.to_string()] + }; + + // Execute command in rootfs directory + if !quiet { + println!("→ Executing: {}", command); + } + + let workdir_path = if workdir.is_empty() || workdir == "/" { + rootfs_dir.to_path_buf() + } else { + rootfs_dir.join(workdir.trim_start_matches('/')) + }; + + // Ensure workdir exists + if !workdir_path.exists() { + std::fs::create_dir_all(&workdir_path).map_err(|e| { + BoxError::BuildError(format!( + "Failed to create workdir {}: {}", + workdir_path.display(), + e + )) + })?; + } + ensure_run_cache_mount_targets(rootfs_dir, cache_mounts)?; + + let mut cmd = std::process::Command::new(&shell_cmd[0]); + cmd.args(&shell_cmd[1..]).current_dir(&workdir_path); + for (key, value) in env { + cmd.env(key, value); + } + let bind_mount_guard = RunBindMountOverlays::activate( + rootfs_dir, + context_dir, + completed_stages, + bind_mounts, + workdir, + ignore, + )?; + let tmpfs_mount_guard = RunTmpfsMountOverlays::activate(rootfs_dir, tmpfs_mounts, workdir)?; + let output = cmd + .output() + .map_err(|e| BoxError::BuildError(format!("Failed to execute command: {}", e)))?; + + if !output.status.success() { + tmpfs_mount_guard.restore()?; + bind_mount_guard.restore()?; + return Err(run_command_failed_error(command, &output)); + } + tmpfs_mount_guard.restore()?; + bind_mount_guard.restore()?; + print_run_output(&output, quiet); + + // Capture filesystem state after execution + let after = DirSnapshot::capture(rootfs_dir)?; + let changed = filter_run_mount_paths( + before.diff(&after), + cache_mounts, + bind_mounts, + tmpfs_mounts, + workdir, + ); + let deleted = filter_run_mount_paths( + before.deletions(&after), + cache_mounts, + bind_mounts, + tmpfs_mounts, + workdir, + ); + + if changed.is_empty() && deleted.is_empty() { + if !quiet { + println!("→ No filesystem changes detected"); + } + return Ok(None); + } + + // Create layer from changes (and OCI whiteouts for deletions) + let layer_path = layers_dir.join(format!("layer_{}.tar.gz", layer_index)); + let layer_info = create_layer_with_deletions(rootfs_dir, &changed, &deleted, &layer_path)?; + + if !quiet { + println!( + "→ Created layer with {} changes, {} deletions", + changed.len(), + deleted.len() + ); + } + + Ok(Some(layer_info)) +} + +/// Handle ADD: like COPY but supports URL download and tar auto-extraction. +#[allow(clippy::too_many_arguments)] +pub(super) fn handle_add( src_patterns: &[String], dst: &str, chown: Option<&str>, @@ -748,7 +2508,25 @@ pub(super) fn execute_onbuild_trigger( /// Convert an Instruction back to a string representation for ONBUILD storage. pub(super) fn instruction_to_string(instr: &Instruction) -> String { match instr { - Instruction::Run { command } => format!("RUN {}", command), + Instruction::Run { + command, + cache_mounts, + bind_mounts, + tmpfs_mounts, + } => { + let flags = cache_mounts + .iter() + .map(|mount| mount.raw.as_str()) + .chain(bind_mounts.iter().map(|mount| mount.raw.as_str())) + .chain(tmpfs_mounts.iter().map(|mount| mount.raw.as_str())) + .collect::>() + .join(" "); + if flags.is_empty() { + format!("RUN {}", run_command_to_string(command)) + } else { + format!("RUN {} {}", flags, run_command_to_string(command)) + } + } Instruction::Copy { src, dst, @@ -816,132 +2594,931 @@ pub(super) fn instruction_to_string(instr: &Instruction) -> String { } } } -} +} + +/// Apply base image config to build state. +pub(super) fn apply_base_config( + state: &mut BuildState, + config: &crate::oci::image::OciImageConfig, +) { + state.env = config.env.clone(); + state.entrypoint = config.entrypoint.clone(); + state.cmd = config.cmd.clone(); + state.user = config.user.clone(); + state.exposed_ports = config.exposed_ports.clone(); + state.labels = config.labels.clone(); + if let Some(ref wd) = config.working_dir { + state.workdir = wd.clone(); + } + if let Some(ref sig) = config.stop_signal { + state.stop_signal = Some(sig.clone()); + } + if let Some(ref hc) = config.health_check { + state.health_check = Some(hc.clone()); + } + // Inherit volumes from base image + for v in &config.volumes { + if !state.volumes.contains(v) { + state.volumes.push(v.clone()); + } + } + // Note: onbuild triggers are NOT inherited — they are executed, not stored +} + +/// Download a URL and return the response bytes. +/// +/// Uses `tokio::task::block_in_place` to run async reqwest from a sync context +/// while inside a tokio runtime (the build engine runs inside `async fn build()`). +fn download_url(url: &str) -> std::result::Result, String> { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .no_proxy() + .build() + .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + + let mut response = client + .get(url) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("HTTP {} for {}", response.status(), url)); + } + + // Cap the download so a hostile/huge URL cannot OOM the build host: + // `bytes()` buffers the WHOLE body with no limit. Reject an oversized + // advertised length early, then stream with a hard cap (the length + // header may be absent or lie). + const MAX_ADD_URL_BYTES: u64 = 512 * 1024 * 1024; // 512 MiB + if let Some(len) = response.content_length() { + if len > MAX_ADD_URL_BYTES { + return Err(format!( + "ADD URL body too large: {len} bytes (max {MAX_ADD_URL_BYTES})" + )); + } + } + let mut buf: Vec = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("Failed to read response body: {}", e))? + { + if buf.len() as u64 + chunk.len() as u64 > MAX_ADD_URL_BYTES { + return Err(format!( + "ADD URL body exceeds max {MAX_ADD_URL_BYTES} bytes" + )); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::super::super::dockerfile::{ + Instruction, RunBindMount, RunCacheMount, RunCacheSharing, RunCommand, RunTmpfsMount, + }; + use super::{ + execute_onbuild_trigger, expand_glob_sources, glob_segment_match, handle_add, + instruction_to_string, run_command_failed_error, shell_command_in_workdir, + }; + use crate::oci::build::engine::{BuildConfig, BuildState}; + use a3s_box_core::error::BoxError; + use std::collections::HashMap; + use std::path::PathBuf; + + fn shell_run(command: &str) -> RunCommand { + RunCommand::Shell(command.to_string()) + } + + #[test] + fn test_glob_segment_match() { + assert!(glob_segment_match("*.conf", "alpha.conf")); + assert!(glob_segment_match("*.conf", ".conf")); + assert!(!glob_segment_match("*.conf", "skip.txt")); + assert!(glob_segment_match("a?c", "abc")); + assert!(!glob_segment_match("a?c", "ac")); + assert!(glob_segment_match("*", "anything")); + assert!(glob_segment_match("pre*post", "pre_middle_post")); + assert!(!glob_segment_match("pre*post", "pre_middle")); + } + + #[cfg(unix)] + #[test] + fn test_run_command_failed_error_includes_stdout_and_stderr() { + use std::os::unix::process::ExitStatusExt; + + let output = std::process::Output { + status: std::process::ExitStatus::from_raw(2 << 8), + stdout: b"resolved package metadata\n".to_vec(), + stderr: b"corepack prepare failed\n".to_vec(), + }; + + let BoxError::BuildError(message) = + run_command_failed_error("corepack prepare pnpm@10.30.3 --activate", &output) + else { + panic!("expected build error"); + }; + + assert!(message.contains("RUN command failed (exit 2)")); + assert!(message.contains("corepack prepare pnpm@10.30.3 --activate")); + assert!(message.contains("stdout:\nresolved package metadata")); + assert!(message.contains("stderr:\ncorepack prepare failed")); + } + + #[test] + fn test_expand_glob_sources() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("alpha.conf"), "1").unwrap(); + std::fs::write(dir.path().join("beta.conf"), "2").unwrap(); + std::fs::write(dir.path().join("skip.txt"), "x").unwrap(); + let mut got = expand_glob_sources(dir.path(), "*.conf"); + got.sort(); + assert_eq!(got, vec!["alpha.conf".to_string(), "beta.conf".to_string()]); + // Non-matching glob yields no entries. + assert!(expand_glob_sources(dir.path(), "*.md").is_empty()); + } + + #[test] + fn test_instruction_to_string_run() { + let instr = Instruction::Run { + command: RunCommand::Shell("echo hello".to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], + }; + assert_eq!(instruction_to_string(&instr), "RUN echo hello"); + } + + #[test] + fn test_instruction_to_string_run_exec_form() { + let instr = Instruction::Run { + command: RunCommand::Exec(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hello".to_string(), + ]), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], + }; + assert_eq!( + instruction_to_string(&instr), + r#"RUN ["/bin/sh","-c","echo hello"]"# + ); + } + + #[test] + fn test_instruction_to_string_run_with_cache_mount() { + let instr = Instruction::Run { + command: RunCommand::Shell("pnpm install".to_string()), + cache_mounts: vec![RunCacheMount { + raw: "--mount=type=cache,sharing=locked,target=/root/.cache".to_string(), + id: None, + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }], + bind_mounts: vec![], + tmpfs_mounts: vec![], + }; + assert_eq!( + instruction_to_string(&instr), + "RUN --mount=type=cache,sharing=locked,target=/root/.cache pnpm install" + ); + } + + #[test] + fn test_instruction_to_string_run_with_bind_mount() { + let instr = Instruction::Run { + command: RunCommand::Shell("go build ./...".to_string()), + cache_mounts: vec![], + bind_mounts: vec![RunBindMount { + from: None, + raw: "--mount=type=bind,source=.,target=.".to_string(), + source: ".".to_string(), + target: ".".to_string(), + read_write: false, + }], + tmpfs_mounts: vec![], + }; + assert_eq!( + instruction_to_string(&instr), + "RUN --mount=type=bind,source=.,target=. go build ./..." + ); + } + + #[test] + fn test_instruction_to_string_run_with_tmpfs_mount() { + let instr = Instruction::Run { + command: RunCommand::Shell("make test".to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![RunTmpfsMount { + raw: "--mount=type=tmpfs,target=/tmp".to_string(), + target: "/tmp".to_string(), + }], + }; + assert_eq!( + instruction_to_string(&instr), + "RUN --mount=type=tmpfs,target=/tmp make test" + ); + } + + #[test] + fn test_shell_command_in_workdir_enters_workdir_inside_chroot() { + assert_eq!( + shell_command_in_workdir("/app", "pnpm install"), + "cd '/app' && pnpm install" + ); + assert_eq!( + shell_command_in_workdir("/app's dir", "pwd"), + "cd '/app'\\''s dir' && pwd" + ); + assert_eq!(shell_command_in_workdir("/", "pwd"), "pwd"); + } + + #[test] + fn test_build_run_shell_cmd_uses_configured_shell_and_workdir() { + let cmd = super::build_run_shell_cmd( + &["/bin/bash".to_string(), "-lc".to_string()], + "/app", + "echo hi", + ); + + assert_eq!(cmd, vec!["/bin/bash", "-lc", "cd '/app' && echo hi"]); + } + + #[test] + fn test_run_env_entries_includes_defaults_and_build_env() { + let env = super::run_env_entries(&[("FOO".to_string(), "bar".to_string())]); + + assert!(env + .iter() + .any(|entry| entry + == "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")); + assert!(env.iter().any(|entry| entry == "HOME=/root")); + assert!(env.iter().any(|entry| entry == "FOO=bar")); + } + + #[test] + fn test_prepare_pool_run_filesystem_creates_support_paths() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + std::fs::create_dir_all(&rootfs).unwrap(); + + super::prepare_pool_run_filesystem(&rootfs).unwrap(); + + assert!(rootfs.join("dev").is_dir()); + assert!(rootfs.join("proc").is_dir()); + assert!(rootfs.join("sys").is_dir()); + assert!(rootfs.join("tmp").is_dir()); + assert!(rootfs.join("etc/resolv.conf").is_file()); + } + + #[test] + fn test_run_bind_mount_overlays_context_and_restores_target() { + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let rootfs = tmp.path().join("rootfs"); + let source = context.join("src"); + let target = rootfs.join("work"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(source.join("input.txt"), "from-context").unwrap(); + std::fs::write(target.join("original.txt"), "from-rootfs").unwrap(); + + let mounts = vec![RunBindMount { + from: None, + raw: "--mount=type=bind,source=src,target=.".to_string(), + source: "src".to_string(), + target: ".".to_string(), + read_write: true, + }]; + + let guard = super::RunBindMountOverlays::activate_context( + &rootfs, &context, &mounts, "/work", None, + ) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("input.txt")).unwrap(), + "from-context" + ); + assert!(!target.join("original.txt").exists()); + std::fs::write(target.join("generated.txt"), "discard me").unwrap(); + + guard.restore().unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "from-rootfs" + ); + assert!(!target.join("input.txt").exists()); + assert!(!target.join("generated.txt").exists()); + } + + #[test] + fn test_run_bind_mount_overlays_stage_source_and_restores_target() { + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let stage_rootfs = tmp.path().join("stage-rootfs"); + let rootfs = tmp.path().join("rootfs"); + let source = stage_rootfs.join("out"); + let target = rootfs.join("work"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(source.join("artifact.txt"), "from-stage").unwrap(); + std::fs::write(target.join("original.txt"), "from-rootfs").unwrap(); + + let mounts = vec![RunBindMount { + from: Some("builder".to_string()), + raw: "--mount=type=bind,from=builder,source=/out,target=.".to_string(), + source: "/out".to_string(), + target: ".".to_string(), + read_write: false, + }]; + let completed_stages = vec![(Some("builder".to_string()), stage_rootfs)]; + + let guard = super::RunBindMountOverlays::activate( + &rootfs, + &context, + &completed_stages, + &mounts, + "/work", + None, + ) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("artifact.txt")).unwrap(), + "from-stage" + ); + assert!(!target.join("original.txt").exists()); + std::fs::write(target.join("generated.txt"), "discard me").unwrap(); + + guard.restore().unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "from-rootfs" + ); + assert!(!target.join("artifact.txt").exists()); + assert!(!target.join("generated.txt").exists()); + } + + #[test] + fn test_filter_run_mount_paths_excludes_bind_target() { + let mounts = vec![RunBindMount { + from: None, + raw: "--mount=type=bind,source=src,target=.".to_string(), + source: "src".to_string(), + target: ".".to_string(), + read_write: false, + }]; + let paths = vec![ + PathBuf::from("work/input.txt"), + PathBuf::from("work/generated.txt"), + PathBuf::from("out.txt"), + ]; + + let filtered = super::filter_run_mount_paths(paths, &[], &mounts, &[], "/work"); + + assert_eq!(filtered, vec![PathBuf::from("out.txt")]); + } + + #[test] + fn test_filter_run_mount_paths_keeps_siblings_under_mount_ancestor() { + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,target=/root/.cache".to_string(), + id: None, + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Shared, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + let paths = vec![ + PathBuf::from("root"), + PathBuf::from("root/.cache/pkg"), + PathBuf::from("root/.profile"), + ]; + + let filtered = super::filter_run_mount_paths(paths, &mounts, &[], &[], "/"); + + assert_eq!(filtered, vec![PathBuf::from("root/.profile")]); + } + + #[test] + fn test_run_tmpfs_mount_overlays_empty_dir_and_restores_target() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("work/tmp"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("original.txt"), "from-rootfs").unwrap(); + + let mounts = vec![RunTmpfsMount { + raw: "--mount=type=tmpfs,target=tmp".to_string(), + target: "tmp".to_string(), + }]; + + let guard = super::RunTmpfsMountOverlays::activate(&rootfs, &mounts, "/work").unwrap(); + + assert!(target.is_dir()); + assert!(!target.join("original.txt").exists()); + std::fs::write(target.join("generated.txt"), "discard me").unwrap(); + + guard.restore().unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "from-rootfs" + ); + assert!(!target.join("generated.txt").exists()); + } -/// Apply base image config to build state. -pub(super) fn apply_base_config( - state: &mut BuildState, - config: &crate::oci::image::OciImageConfig, -) { - state.env = config.env.clone(); - state.entrypoint = config.entrypoint.clone(); - state.cmd = config.cmd.clone(); - state.user = config.user.clone(); - state.exposed_ports = config.exposed_ports.clone(); - state.labels = config.labels.clone(); - if let Some(ref wd) = config.working_dir { - state.workdir = wd.clone(); + #[test] + fn test_filter_run_mount_paths_excludes_tmpfs_target() { + let mounts = vec![RunTmpfsMount { + raw: "--mount=type=tmpfs,target=/tmp".to_string(), + target: "/tmp".to_string(), + }]; + let paths = vec![ + PathBuf::from("tmp/generated.txt"), + PathBuf::from("var/output.txt"), + ]; + + let filtered = super::filter_run_mount_paths(paths, &[], &[], &mounts, "/"); + + assert_eq!(filtered, vec![PathBuf::from("var/output.txt")]); } - if let Some(ref sig) = config.stop_signal { - state.stop_signal = Some(sig.clone()); + + #[test] + fn test_pool_run_cache_mounts_restore_original_target() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("root/.cache"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("original.txt"), "original").unwrap(); + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,sharing=locked,target=/root/.cache".to_string(), + id: None, + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + + super::ensure_run_cache_mount_targets(&rootfs, &mounts).unwrap(); + let guard = + super::PoolRunCacheMounts::activate_with_cache_root(&rootfs, &mounts, &cache_root, &[]) + .unwrap(); + + assert!(!target.join("original.txt").exists()); + std::fs::write(target.join("cache-only.txt"), "cache").unwrap(); + + guard.restore().unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "original" + ); + assert!(!target.join("cache-only.txt").exists()); } - if let Some(ref hc) = config.health_check { - state.health_check = Some(hc.clone()); + + #[test] + fn test_pool_run_cache_mounts_persist_by_id() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("root/.cache"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&rootfs).unwrap(); + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,id=shared,sharing=locked,target=/root/.cache".to_string(), + id: Some("shared".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + + super::ensure_run_cache_mount_targets(&rootfs, &mounts).unwrap(); + let guard = + super::PoolRunCacheMounts::activate_with_cache_root(&rootfs, &mounts, &cache_root, &[]) + .unwrap(); + std::fs::write(target.join("cache-only.txt"), "cache").unwrap(); + guard.restore().unwrap(); + let cache_dir = super::run_cache_mount_dir(&cache_root, &mounts[0]); + assert_eq!( + std::fs::read_to_string(cache_dir.join("cache-only.txt")).unwrap(), + "cache" + ); + + let guard = + super::PoolRunCacheMounts::activate_with_cache_root(&rootfs, &mounts, &cache_root, &[]) + .unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("cache-only.txt")).unwrap(), + "cache" + ); + guard.restore().unwrap(); } - // Inherit volumes from base image - for v in &config.volumes { - if !state.volumes.contains(v) { - state.volumes.push(v.clone()); - } + + #[test] + fn test_pool_run_cache_mounts_seed_from_stage_once() { + let tmp = tempfile::TempDir::new().unwrap(); + let stage_rootfs = tmp.path().join("stage-rootfs"); + let seed_dir = stage_rootfs.join("seed-cache"); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("root/.cache"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&seed_dir).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(seed_dir.join("seed.txt"), "seed-v1").unwrap(); + std::fs::write(target.join("original.txt"), "original").unwrap(); + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,id=seeded,sharing=locked,from=builder,source=/seed-cache,target=/root/.cache".to_string(), + id: Some("seeded".to_string()), + from: Some("builder".to_string()), + source: "/seed-cache".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + let completed_stages = vec![(Some("builder".to_string()), stage_rootfs)]; + + super::ensure_run_cache_mount_targets(&rootfs, &mounts).unwrap(); + let guard = super::PoolRunCacheMounts::activate_with_cache_root( + &rootfs, + &mounts, + &cache_root, + &completed_stages, + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("seed.txt")).unwrap(), + "seed-v1" + ); + assert!(!target.join("original.txt").exists()); + std::fs::write(target.join("generated.txt"), "persisted").unwrap(); + guard.restore().unwrap(); + + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "original" + ); + assert!(!target.join("seed.txt").exists()); + let cache_dir = super::run_cache_mount_dir(&cache_root, &mounts[0]); + assert_eq!( + std::fs::read_to_string(cache_dir.join("seed.txt")).unwrap(), + "seed-v1" + ); + assert_eq!( + std::fs::read_to_string(cache_dir.join("generated.txt")).unwrap(), + "persisted" + ); + + std::fs::write(seed_dir.join("seed.txt"), "seed-v2").unwrap(); + let guard = super::PoolRunCacheMounts::activate_with_cache_root( + &rootfs, + &mounts, + &cache_root, + &completed_stages, + ) + .unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("seed.txt")).unwrap(), + "seed-v1", + "existing persistent cache should not be re-seeded" + ); + guard.restore().unwrap(); } - // Note: onbuild triggers are NOT inherited — they are executed, not stored -} -/// Download a URL and return the response bytes. -/// -/// Uses `tokio::task::block_in_place` to run async reqwest from a sync context -/// while inside a tokio runtime (the build engine runs inside `async fn build()`). -fn download_url(url: &str) -> std::result::Result, String> { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(60)) - .no_proxy() - .build() - .map_err(|e| format!("Failed to build HTTP client: {}", e))?; + #[test] + fn test_pool_run_cache_mounts_reject_missing_seed_source() { + let tmp = tempfile::TempDir::new().unwrap(); + let stage_rootfs = tmp.path().join("stage-rootfs"); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("root/.cache"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&stage_rootfs).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("original.txt"), "original").unwrap(); + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,id=seeded,sharing=locked,from=builder,source=/missing,target=/root/.cache".to_string(), + id: Some("seeded".to_string()), + from: Some("builder".to_string()), + source: "/missing".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + let completed_stages = vec![(Some("builder".to_string()), stage_rootfs)]; + + let err = match super::PoolRunCacheMounts::activate_with_cache_root( + &rootfs, + &mounts, + &cache_root, + &completed_stages, + ) { + Ok(_) => panic!("missing RUN cache seed source should fail"), + Err(err) => err.to_string(), + }; - let mut response = client - .get(url) - .send() - .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + assert!(err.contains("RUN cache mount seed source not found")); + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "original" + ); + } - if !response.status().is_success() { - return Err(format!("HTTP {} for {}", response.status(), url)); - } + #[test] + fn test_pool_run_cache_mounts_reject_file_seed_source() { + let tmp = tempfile::TempDir::new().unwrap(); + let stage_rootfs = tmp.path().join("stage-rootfs"); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("root/.cache"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&stage_rootfs).unwrap(); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(stage_rootfs.join("seed-cache"), "not a directory").unwrap(); + std::fs::write(target.join("original.txt"), "original").unwrap(); + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,id=seeded,sharing=locked,from=builder,source=/seed-cache,target=/root/.cache".to_string(), + id: Some("seeded".to_string()), + from: Some("builder".to_string()), + source: "/seed-cache".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + let completed_stages = vec![(Some("builder".to_string()), stage_rootfs)]; + + let err = match super::PoolRunCacheMounts::activate_with_cache_root( + &rootfs, + &mounts, + &cache_root, + &completed_stages, + ) { + Ok(_) => panic!("file RUN cache seed source should fail"), + Err(err) => err.to_string(), + }; - // Cap the download so a hostile/huge URL cannot OOM the build host: - // `bytes()` buffers the WHOLE body with no limit. Reject an oversized - // advertised length early, then stream with a hard cap (the length - // header may be absent or lie). - const MAX_ADD_URL_BYTES: u64 = 512 * 1024 * 1024; // 512 MiB - if let Some(len) = response.content_length() { - if len > MAX_ADD_URL_BYTES { - return Err(format!( - "ADD URL body too large: {len} bytes (max {MAX_ADD_URL_BYTES})" - )); - } - } - let mut buf: Vec = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .map_err(|e| format!("Failed to read response body: {}", e))? - { - if buf.len() as u64 + chunk.len() as u64 > MAX_ADD_URL_BYTES { - return Err(format!( - "ADD URL body exceeds max {MAX_ADD_URL_BYTES} bytes" - )); - } - buf.extend_from_slice(&chunk); - } - Ok(buf) - }) - }) -} + assert!(err.contains("RUN cache mount seed source must be a directory")); + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "original" + ); + } -#[cfg(test)] -mod tests { - use super::super::super::dockerfile::Instruction; - use super::{ - execute_onbuild_trigger, expand_glob_sources, glob_segment_match, handle_add, - instruction_to_string, - }; - use crate::oci::build::engine::{BuildConfig, BuildState}; - use std::collections::HashMap; - use std::path::PathBuf; + #[test] + fn test_pool_run_cache_mounts_restore_without_sync_discards_failed_run_cache() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("root/.cache"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("original.txt"), "original").unwrap(); + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,id=failed,sharing=locked,target=/root/.cache".to_string(), + id: Some("failed".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + + super::ensure_run_cache_mount_targets(&rootfs, &mounts).unwrap(); + let guard = + super::PoolRunCacheMounts::activate_with_cache_root(&rootfs, &mounts, &cache_root, &[]) + .unwrap(); + std::fs::write(target.join("partial.txt"), "partial").unwrap(); + guard.restore_without_sync().unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "original" + ); + assert!(!target.join("partial.txt").exists()); + let cache_dir = super::run_cache_mount_dir(&cache_root, &mounts[0]); + assert!( + !cache_dir.join("partial.txt").exists(), + "failed RUN cache contents must not be persisted" + ); + } + + #[cfg(unix)] #[test] - fn test_glob_segment_match() { - assert!(glob_segment_match("*.conf", "alpha.conf")); - assert!(glob_segment_match("*.conf", ".conf")); - assert!(!glob_segment_match("*.conf", "skip.txt")); - assert!(glob_segment_match("a?c", "abc")); - assert!(!glob_segment_match("a?c", "ac")); - assert!(glob_segment_match("*", "anything")); - assert!(glob_segment_match("pre*post", "pre_middle_post")); - assert!(!glob_segment_match("pre*post", "pre_middle")); + fn test_pool_run_cache_mounts_apply_root_metadata() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("var/cache/apt"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&rootfs).unwrap(); + let uid = unsafe { libc::geteuid() }; + let gid = unsafe { libc::getegid() }; + let mounts = vec![RunCacheMount { + raw: format!( + "--mount=type=cache,id=apt,sharing=locked,mode=0750,uid={uid},gid={gid},target=/var/cache/apt" + ), + id: Some("apt".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: Some(0o750), + uid: Some(uid), + gid: Some(gid), + target: "/var/cache/apt".to_string(), + }]; + + super::ensure_run_cache_mount_targets(&rootfs, &mounts).unwrap(); + let guard = + super::PoolRunCacheMounts::activate_with_cache_root(&rootfs, &mounts, &cache_root, &[]) + .unwrap(); + let metadata = std::fs::metadata(&target).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o750); + assert_eq!(metadata.uid(), uid); + assert_eq!(metadata.gid(), gid); + guard.restore().unwrap(); } #[test] - fn test_expand_glob_sources() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("alpha.conf"), "1").unwrap(); - std::fs::write(dir.path().join("beta.conf"), "2").unwrap(); - std::fs::write(dir.path().join("skip.txt"), "x").unwrap(); - let mut got = expand_glob_sources(dir.path(), "*.conf"); - got.sort(); - assert_eq!(got, vec!["alpha.conf".to_string(), "beta.conf".to_string()]); - // Non-matching glob yields no entries. - assert!(expand_glob_sources(dir.path(), "*.md").is_empty()); + fn test_pool_run_cache_mounts_reject_duplicate_cache_key() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(rootfs.join("a")).unwrap(); + std::fs::create_dir_all(rootfs.join("b")).unwrap(); + let mounts = vec![ + RunCacheMount { + raw: "--mount=type=cache,id=shared,sharing=locked,target=/a".to_string(), + id: Some("shared".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/a".to_string(), + }, + RunCacheMount { + raw: "--mount=type=cache,id=shared,sharing=locked,target=/b".to_string(), + id: Some("shared".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/b".to_string(), + }, + ]; + + let err = match super::PoolRunCacheMounts::activate_with_cache_root( + &rootfs, + &mounts, + &cache_root, + &[], + ) { + Ok(_) => panic!("duplicate RUN cache mount key should fail"), + Err(err) => err.to_string(), + }; + + assert!(err.contains("Duplicate RUN cache mount")); + assert!(rootfs.join("a").is_dir()); + assert!(rootfs.join("b").is_dir()); } #[test] - fn test_instruction_to_string_run() { - let instr = Instruction::Run { - command: "echo hello".to_string(), + fn test_pool_run_cache_mount_hydrate_failure_does_not_sync_partial_cache() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + let target = rootfs.join("root/.cache"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("original.txt"), "original").unwrap(); + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,id=broken,sharing=locked,target=/root/.cache".to_string(), + id: Some("broken".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + let cache_dir = super::run_cache_mount_dir(&cache_root, &mounts[0]); + std::fs::create_dir_all(cache_dir.parent().unwrap()).unwrap(); + std::fs::write(&cache_dir, "not a directory").unwrap(); + + let err = match super::PoolRunCacheMounts::activate_with_cache_root( + &rootfs, + &mounts, + &cache_root, + &[], + ) { + Ok(_) => panic!("file cache entry should fail to hydrate"), + Err(err) => err.to_string(), }; - assert_eq!(instruction_to_string(&instr), "RUN echo hello"); + + assert!(err.contains("is not a directory")); + assert_eq!( + std::fs::read_to_string(target.join("original.txt")).unwrap(), + "original" + ); + assert!(cache_dir.is_file()); + assert_eq!( + std::fs::read_to_string(cache_dir).unwrap(), + "not a directory" + ); + } + + #[cfg(unix)] + #[test] + fn test_pool_run_cache_mount_lock_blocks_same_cache_key() { + use std::sync::mpsc; + use std::time::Duration; + + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs-a"); + let cache_root = tmp.path().join("run-cache"); + std::fs::create_dir_all(&rootfs).unwrap(); + let mounts = vec![RunCacheMount { + raw: "--mount=type=cache,id=shared,sharing=locked,target=/root/.cache".to_string(), + id: Some("shared".to_string()), + from: None, + source: ".".to_string(), + sharing: RunCacheSharing::Locked, + mode: None, + uid: None, + gid: None, + target: "/root/.cache".to_string(), + }]; + super::ensure_run_cache_mount_targets(&rootfs, &mounts).unwrap(); + let guard = + super::PoolRunCacheMounts::activate_with_cache_root(&rootfs, &mounts, &cache_root, &[]) + .unwrap(); + + let rootfs_b = tmp.path().join("rootfs-b"); + std::fs::create_dir_all(&rootfs_b).unwrap(); + super::ensure_run_cache_mount_targets(&rootfs_b, &mounts).unwrap(); + let thread_mounts = mounts.clone(); + let thread_cache_root = cache_root.clone(); + let (started_tx, started_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let guard = super::PoolRunCacheMounts::activate_with_cache_root( + &rootfs_b, + &thread_mounts, + &thread_cache_root, + &[], + ) + .unwrap(); + done_tx.send(()).unwrap(); + guard.restore().unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!( + done_rx.recv_timeout(Duration::from_millis(100)).is_err(), + "same RUN cache key should remain locked while the first mount is active" + ); + + guard.restore().unwrap(); + done_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + waiter.join().unwrap(); } #[test] @@ -1157,7 +3734,10 @@ mod tests { #[test] fn test_instruction_to_string_onbuild() { let inner = Instruction::Run { - command: "echo triggered".to_string(), + command: RunCommand::Shell("echo triggered".to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], }; let instr = Instruction::OnBuild { instruction: Box::new(inner), @@ -1209,6 +3789,7 @@ mod tests { target: None, no_cache: false, metrics: None, + run_pool: None, }; let tmp = tempfile::TempDir::new().unwrap(); @@ -1243,7 +3824,7 @@ mod tests { std::fs::create_dir_all(rootfs.join("bin")).unwrap(); std::fs::write(rootfs.join("bin/sh"), "fake shell").unwrap(); - let err = super::validate_linux_run_preconditions(&rootfs, &[], 1000) + let err = super::validate_linux_run_preconditions(&rootfs, &shell_run("true"), &[], 1000) .unwrap_err() .to_string(); @@ -1256,24 +3837,64 @@ mod tests { let rootfs = tmp.path().join("rootfs"); std::fs::create_dir_all(&rootfs).unwrap(); - let err = super::validate_linux_run_preconditions(&rootfs, &[], 0) + let err = super::validate_linux_run_preconditions(&rootfs, &shell_run("true"), &[], 0) .unwrap_err() .to_string(); assert!(err.contains("was not found in rootfs")); } + #[cfg(unix)] + #[test] + fn test_linux_run_preconditions_accept_absolute_shell_symlink() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + std::fs::create_dir_all(rootfs.join("bin")).unwrap(); + std::fs::write(rootfs.join("bin/busybox"), "fake busybox").unwrap(); + std::os::unix::fs::symlink("/bin/busybox", rootfs.join("bin/sh")).unwrap(); + + super::validate_linux_run_preconditions(&rootfs, &shell_run("true"), &[], 0).unwrap(); + } + #[test] fn test_linux_run_preconditions_reject_relative_shell() { let tmp = tempfile::TempDir::new().unwrap(); let rootfs = tmp.path().join("rootfs"); std::fs::create_dir_all(&rootfs).unwrap(); - let err = super::validate_linux_run_preconditions(&rootfs, &["sh".to_string()], 0) + let err = super::validate_linux_run_preconditions( + &rootfs, + &shell_run("true"), + &["sh".to_string()], + 0, + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("is not absolute")); + } + + #[test] + fn test_run_exec_preconditions_accept_absolute_executable() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + std::fs::create_dir_all(rootfs.join("bin")).unwrap(); + std::fs::write(rootfs.join("bin/echo"), "fake echo").unwrap(); + + super::validate_run_exec_preconditions(&rootfs, &["/bin/echo".to_string()]).unwrap(); + } + + #[test] + fn test_run_exec_preconditions_reject_missing_absolute_executable() { + let tmp = tempfile::TempDir::new().unwrap(); + let rootfs = tmp.path().join("rootfs"); + std::fs::create_dir_all(&rootfs).unwrap(); + + let err = super::validate_run_exec_preconditions(&rootfs, &["/bin/missing".to_string()]) .unwrap_err() .to_string(); - assert!(err.contains("is not absolute")); + assert!(err.contains("was not found in rootfs")); } #[test] @@ -1312,9 +3933,26 @@ mod tests { std::fs::create_dir_all(&rootfs).unwrap(); std::fs::create_dir_all(&layers).unwrap(); - let result = super::handle_run("echo unsafe", &rootfs, &layers, "/", &[], &[], 0, true); + let command = shell_run("echo unsafe"); + let result = super::handle_run( + &command, + &[], + &[], + &[], + tmp.path(), + &[], + &rootfs, + &layers, + "/", + &[], + &[], + 0, + true, + None, + ); let err = result.unwrap_err().to_string(); assert!(err.contains("Dockerfile RUN is not supported on macOS yet")); + assert!(err.contains("--builder=buildkit-vm")); assert!(err.contains(super::UNSAFE_HOST_RUN_ENV)); } } diff --git a/src/runtime/src/oci/build/engine/mod.rs b/src/runtime/src/oci/build/engine/mod.rs index 885ef4a8..7207b7e2 100644 --- a/src/runtime/src/oci/build/engine/mod.rs +++ b/src/runtime/src/oci/build/engine/mod.rs @@ -12,7 +12,7 @@ use a3s_box_core::error::{BoxError, Result}; use a3s_box_core::platform::Platform; use super::cache::{hash_context_sources, BuildCache}; -use super::dockerfile::{Dockerfile, Instruction}; +use super::dockerfile::{Dockerfile, Instruction, RunBindMount, RunCacheMount}; use super::dockerignore::DockerIgnore; use super::layer::{sha256_bytes, sha256_file, LayerInfo}; use crate::oci::image::OciImageConfig; @@ -29,7 +29,7 @@ mod tests; use handlers::{ apply_base_config, execute_onbuild_trigger, handle_add, handle_copy, handle_run, - instruction_to_string, + handle_run_with_pool, instruction_to_string, }; use stages::{global_arg_decls, resolve_stage_rootfs, split_into_stages}; use utils::{compute_diff_id, expand_args, format_size, resolve_path}; @@ -57,6 +57,27 @@ pub struct BuildConfig { pub no_cache: bool, /// Prometheus metrics (optional). pub metrics: Option, + /// Execute Dockerfile RUN instructions through a warm-pool daemon lease. + pub run_pool: Option, +} + +/// Configuration for executing Dockerfile RUN instructions in a warm-pool VM. +#[derive(Debug, Clone)] +pub struct BuildRunPoolConfig { + /// Pool daemon Unix socket. + pub socket: String, + /// Helper VM image. `None` uses the daemon's default image. + pub image: Option, + /// Helper VM vCPU count for lazily-created pools. + pub vcpus: u32, + /// Helper VM memory in MiB for lazily-created pools. + pub memory_mb: u32, + /// Guest path where the stage rootfs is mounted. + pub guest_rootfs: String, + /// RUN exec timeout in nanoseconds. + pub timeout_ns: u64, + /// Persistent cache directory for `RUN --mount=type=cache`. + pub run_cache_dir: PathBuf, } /// Result of a successful build. @@ -72,6 +93,235 @@ pub struct BuildResult { pub layer_count: usize, } +#[cfg_attr(not(feature = "pool"), allow(dead_code))] +struct BuildRunPoolSession { + guest_rootfs: String, + timeout_ns: u64, + run_cache_dir: PathBuf, + #[cfg(feature = "pool")] + lease: crate::pool::PoolLeaseClient, +} + +impl BuildRunPoolSession { + async fn acquire(config: &BuildRunPoolConfig, rootfs_dir: &Path) -> Result { + #[cfg(feature = "pool")] + { + let rootfs_dir = rootfs_dir.canonicalize().map_err(|e| { + BoxError::BuildError(format!( + "Failed to canonicalize build RUN rootfs {}: {}", + rootfs_dir.display(), + e + )) + })?; + let volume = format!("{}:{}:rw", rootfs_dir.display(), config.guest_rootfs); + let lease = crate::pool::PoolLeaseClient::acquire(crate::pool::PoolClientLease { + socket: config.socket.clone(), + image: config.image.clone(), + volumes: vec![volume], + vcpus: config.vcpus, + memory_mb: config.memory_mb, + }) + .await + .map_err(|e| { + BoxError::BuildError(format!( + "Failed to lease warm-pool VM for Dockerfile RUN: {}", + e + )) + })?; + Ok(Self { + guest_rootfs: config.guest_rootfs.clone(), + timeout_ns: config.timeout_ns, + run_cache_dir: config.run_cache_dir.clone(), + lease, + }) + } + + #[cfg(not(feature = "pool"))] + { + let _ = (config, rootfs_dir); + Err(BoxError::BuildError( + "Dockerfile RUN warm-pool execution requires the runtime 'pool' feature" + .to_string(), + )) + } + } + + async fn release(self) -> Result<()> { + #[cfg(feature = "pool")] + { + self.lease.release().await.map_err(|e| { + BoxError::BuildError(format!( + "Failed to release warm-pool Dockerfile RUN lease: {}", + e + )) + }) + } + + #[cfg(not(feature = "pool"))] + { + Ok(()) + } + } +} + +fn run_bind_mount_input_hash( + context_dir: &Path, + completed_stages: &[(Option, PathBuf)], + bind_mounts: &[RunBindMount], +) -> Option { + let mut input = String::new(); + + for mount in bind_mounts { + if has_parent_component(&mount.source) { + return None; + } + + let source = if mount.source.is_empty() { + "." + } else { + mount.source.as_str() + }; + let (origin, source_root) = match mount.from.as_deref() { + Some(from_ref) => ( + format!("stage:{from_ref}"), + resolve_stage_rootfs(from_ref, completed_stages).ok()?, + ), + None => ("context".to_string(), context_dir), + }; + + let source_hash = hash_context_sources(source_root, &[source.to_string()])?; + input.push_str(&origin); + input.push('\0'); + input.push_str(source); + input.push('\0'); + input.push_str(&source_hash); + input.push('\0'); + + if mount.from.is_none() { + let dockerignore = context_dir.join(".dockerignore"); + if let Ok(bytes) = std::fs::read(&dockerignore) { + input.push_str(".dockerignore"); + input.push('\0'); + input.push_str(&sha256_bytes(&bytes)); + input.push('\0'); + } + } + } + + Some(sha256_bytes(input.as_bytes())) +} + +fn run_cache_mount_input_hash( + completed_stages: &[(Option, PathBuf)], + cache_mounts: &[RunCacheMount], +) -> Option { + let mut input = String::new(); + let mut saw_seeded_cache = false; + + for mount in cache_mounts { + let Some(from_ref) = mount.from.as_deref() else { + continue; + }; + if has_parent_component(&mount.source) { + return None; + } + + saw_seeded_cache = true; + let source = if mount.source.is_empty() { + "." + } else { + mount.source.as_str() + }; + let source_root = resolve_stage_rootfs(from_ref, completed_stages).ok()?; + let source_hash = hash_context_sources(source_root, &[source.to_string()])?; + input.push_str("cache-seed:"); + input.push_str(from_ref); + input.push('\0'); + input.push_str(source); + input.push('\0'); + input.push_str(&source_hash); + input.push('\0'); + } + + saw_seeded_cache.then(|| sha256_bytes(input.as_bytes())) +} + +fn run_mount_input_hash( + context_dir: &Path, + completed_stages: &[(Option, PathBuf)], + cache_mounts: &[RunCacheMount], + bind_mounts: &[RunBindMount], +) -> Option { + let bind_hash = if bind_mounts.is_empty() { + None + } else { + run_bind_mount_input_hash(context_dir, completed_stages, bind_mounts) + }; + let cache_hash = run_cache_mount_input_hash(completed_stages, cache_mounts); + + match (bind_hash, cache_hash) { + (None, None) => None, + (Some(hash), None) | (None, Some(hash)) => Some(hash), + (Some(bind_hash), Some(cache_hash)) => Some(sha256_bytes( + format!("bind\0{bind_hash}\0cache\0{cache_hash}").as_bytes(), + )), + } +} + +fn has_parent_component(path: &str) -> bool { + Path::new(path) + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) +} + +async fn resolve_run_mount_source_roots( + completed_stages: &[(Option, PathBuf)], + bind_mounts: &[RunBindMount], + cache_mounts: &[RunCacheMount], + store: &Arc, + build_dir: &Path, + external_from_rootfs: &mut HashMap, +) -> Result, PathBuf)>>> { + let mut roots: Option, PathBuf)>> = None; + let mut external_refs = HashSet::new(); + + let mut from_refs: Vec<&str> = Vec::new(); + from_refs.extend(bind_mounts.iter().filter_map(|mount| mount.from.as_deref())); + from_refs.extend( + cache_mounts + .iter() + .filter_map(|mount| mount.from.as_deref()), + ); + + for from_ref in from_refs { + if resolve_stage_rootfs(from_ref, completed_stages).is_ok() + || roots + .as_deref() + .is_some_and(|resolved| resolve_stage_rootfs(from_ref, resolved).is_ok()) + { + continue; + } + + if !external_refs.insert(from_ref.to_string()) { + continue; + } + + let rootfs = resolve_external_from_rootfs( + from_ref, + "RUN bind mount", + store, + build_dir, + external_from_rootfs, + ) + .await?; + roots + .get_or_insert_with(|| completed_stages.to_vec()) + .push((Some(from_ref.to_string()), rootfs)); + } + + Ok(roots) +} + /// Mutable state accumulated during the build. pub(super) struct BuildState { /// Working directory inside the image @@ -166,6 +416,19 @@ impl BuildState { vars } + /// Environment for RUN: declared ARG values are available while executing + /// the command, and ENV values override ARGs with the same name. ARGs are + /// not persisted into the final image config unless an ENV stores them. + fn run_env(&self) -> Vec<(String, String)> { + let mut vars = self.declared_build_args(); + for (key, value) in &self.env { + vars.insert(key.clone(), value.clone()); + } + let mut pairs = vars.into_iter().collect::>(); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + pairs + } + /// Seed a global (pre-FROM) ARG into this stage: declare its name and apply /// its default unless a `--build-arg` already overrides it. fn seed_global_arg(&mut self, name: &str, default: Option<&str>) { @@ -233,7 +496,7 @@ pub async fn build(config: BuildConfig, store: Arc) -> Result, PathBuf)> = Vec::new(); // Cache external images already pulled+extracted for `COPY --from=` - // (keyed by image ref) so multiple copies from one image pull once. + // and RUN mount `from=` sources so repeated references pull once. let mut external_from_rootfs: HashMap = HashMap::new(); // Create temp directory for build workspace @@ -270,6 +533,7 @@ pub async fn build(config: BuildConfig, store: Arc) -> Result = Vec::new(); let mut base_diff_ids: Vec = Vec::new(); + let mut run_pool_session: Option = None; // Layer-level build cache (best-effort; None disables caching). let cache = if config.no_cache { @@ -285,6 +549,24 @@ pub async fn build(config: BuildConfig, store: Arc) -> Result) -> Result hash_context_sources(&config.context_dir, src), + Instruction::Run { + cache_mounts, + bind_mounts, + .. + } => run_mount_input_hash( + &config.context_dir, + run_mount_source_roots + .as_deref() + .unwrap_or(&completed_stages), + cache_mounts, + bind_mounts, + ), _ => None, }; chain_key = BuildCache::chain(&chain_key, &repr, input_hash.as_deref()); @@ -422,12 +716,16 @@ pub async fn build(config: BuildConfig, store: Arc) -> Result) -> Result stage_rootfs.to_path_buf(), Err(_) => { - resolve_external_image_rootfs( + resolve_external_from_rootfs( from_ref, + "COPY --from", &store, build_dir.path(), &mut external_from_rootfs, @@ -533,12 +832,16 @@ pub async fn build(config: BuildConfig, store: Arc) -> Result { let created_by = format!("ADD {} {}", src.join(" "), dst); if try_reuse_cached_layer( - cache_valid, - cache.as_ref(), - &chain_key, - &rootfs_dir, + CachedLayerReuse { + cache_valid, + cache: cache.as_ref(), + chain_key: &chain_key, + rootfs_dir: &rootfs_dir, + layers_dir: &layers_dir, + layer_index: state.layers.len() + base_layers.len(), + created_by: &created_by, + }, &mut state, - &created_by, )? .is_some() { @@ -584,15 +887,24 @@ pub async fn build(config: BuildConfig, store: Arc) -> Result { - let created_by = format!("RUN {}", command); + Instruction::Run { + command, + cache_mounts, + bind_mounts, + tmpfs_mounts, + } => { + let created_by = instruction_to_string(instruction); if try_reuse_cached_layer( - cache_valid, - cache.as_ref(), - &chain_key, - &rootfs_dir, + CachedLayerReuse { + cache_valid, + cache: cache.as_ref(), + chain_key: &chain_key, + rootfs_dir: &rootfs_dir, + layers_dir: &layers_dir, + layer_index: state.layers.len() + base_layers.len(), + created_by: &created_by, + }, &mut state, - &created_by, )? .is_some() { @@ -607,18 +919,57 @@ pub async fn build(config: BuildConfig, store: Arc) -> Result) -> Result) -> Result) -> Result { cache_valid: bool, - cache: Option<&BuildCache>, - chain_key: &str, - rootfs_dir: &Path, + cache: Option<&'a BuildCache>, + chain_key: &'a str, + rootfs_dir: &'a Path, + layers_dir: &'a Path, + layer_index: usize, + created_by: &'a str, +} + +fn try_reuse_cached_layer( + request: CachedLayerReuse<'_>, state: &mut BuildState, - created_by: &str, ) -> Result> { - if !cache_valid { + if !request.cache_valid { return Ok(None); } - let Some(cached) = cache.and_then(|c| c.lookup(chain_key)) else { + let Some(cached) = request.cache.and_then(|c| c.lookup(request.chain_key)) else { return Ok(None); }; + let local_layer = request.layers_dir.join(format!( + "cached_{}_{}.tar.gz", + request.layer_index, cached.digest + )); + if let Err(error) = std::fs::copy(&cached.blob_path, &local_layer) { + tracing::warn!( + key = %request.chain_key, + source = %cached.blob_path.display(), + error = %error, + "Build cache blob disappeared before it could be materialized; rebuilding instruction" + ); + return Ok(None); + } + // Apply the cached diff so subsequent instructions see the right rootfs. - extract_layer(&cached.blob_path, rootfs_dir)?; + extract_layer(&local_layer, request.rootfs_dir)?; + let local_size = std::fs::metadata(&local_layer) + .map(|metadata| metadata.len()) + .unwrap_or(cached.size); state.layers.push(LayerInfo { - path: cached.blob_path, + path: local_layer, digest: cached.digest, - size: cached.size, + size: local_size, }); state.diff_ids.push(cached.diff_id); state.history.push(HistoryEntry { - created_by: created_by.to_string(), + created_by: request.created_by.to_string(), empty_layer: false, }); Ok(Some(())) @@ -1004,11 +1382,12 @@ async fn handle_from( Ok((base_layers, base_diff_ids, config)) } -/// Resolve `COPY --from=` when `` is not a build stage: pull the -/// external image and extract it to a temp rootfs to copy from (Docker behavior). -/// Memoized per build so several copies from one image pull only once. -async fn resolve_external_image_rootfs( +/// Resolve an external image source when `from=` is not a build stage: +/// pull the image and extract it to a temp rootfs (Docker behavior). Memoized +/// per build so several copies or RUN bind mounts from one image pull only once. +async fn resolve_external_from_rootfs( image_ref: &str, + operation: &str, store: &Arc, build_dir: &Path, cache: &mut HashMap, @@ -1020,7 +1399,7 @@ async fn resolve_external_image_rootfs( let dir = build_dir.join(format!("copyfrom_{}", cache.len())); std::fs::create_dir_all(&dir).map_err(|e| { BoxError::BuildError(format!( - "Failed to create COPY --from image rootfs {}: {}", + "Failed to create {operation} image rootfs {}: {}", dir.display(), e )) @@ -1029,7 +1408,7 @@ async fn resolve_external_image_rootfs( let puller = ImagePuller::new(store.clone(), RegistryAuth::from_env()); let oci_image = puller.pull(image_ref).await.map_err(|e| { BoxError::BuildError(format!( - "COPY --from={}: not a build stage and could not be pulled as an image: {}", + "{operation} from={}: not a build stage and could not be pulled as an image: {}", image_ref, e )) })?; @@ -1106,8 +1485,7 @@ async fn assemble_image( for layer in base_layers { let blob_path = blobs_dir.join(&layer.digest); if !blob_path.exists() { - std::fs::copy(&layer.path, &blob_path) - .map_err(|e| BoxError::BuildError(format!("Failed to copy base layer: {}", e)))?; + copy_layer_blob(layer, &blob_path, "base layer")?; } all_layer_descriptors.push(serde_json::json!({ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", @@ -1120,8 +1498,7 @@ async fn assemble_image( for (i, layer) in state.layers.iter().enumerate() { let blob_path = blobs_dir.join(&layer.digest); if !blob_path.exists() { - std::fs::copy(&layer.path, &blob_path) - .map_err(|e| BoxError::BuildError(format!("Failed to copy layer {}: {}", i, e)))?; + copy_layer_blob(layer, &blob_path, &format!("layer {i}"))?; } all_layer_descriptors.push(serde_json::json!({ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", @@ -1296,3 +1673,24 @@ async fn assemble_image( layer_count: total_layers, }) } + +fn copy_layer_blob(layer: &LayerInfo, blob_path: &Path, label: &str) -> Result<()> { + if !layer.path.exists() { + return Err(BoxError::BuildError(format!( + "Failed to copy {label}: source layer {} for digest {} does not exist", + layer.path.display(), + layer.prefixed_digest() + ))); + } + + std::fs::copy(&layer.path, blob_path).map_err(|e| { + BoxError::BuildError(format!( + "Failed to copy {label} from {} to {} (digest {}): {}", + layer.path.display(), + blob_path.display(), + layer.prefixed_digest(), + e + )) + })?; + Ok(()) +} diff --git a/src/runtime/src/oci/build/engine/stages.rs b/src/runtime/src/oci/build/engine/stages.rs index e4265a75..3bed05bb 100644 --- a/src/runtime/src/oci/build/engine/stages.rs +++ b/src/runtime/src/oci/build/engine/stages.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use a3s_box_core::error::{BoxError, Result}; use super::super::dockerfile::Instruction; +#[cfg(test)] +use super::super::dockerfile::RunCommand; /// A build stage: a FROM instruction followed by its body instructions. pub(super) struct BuildStage { @@ -115,7 +117,10 @@ mod tests { fn make_run(cmd: &str) -> Instruction { Instruction::Run { - command: cmd.to_string(), + command: RunCommand::Shell(cmd.to_string()), + cache_mounts: vec![], + bind_mounts: vec![], + tmpfs_mounts: vec![], } } diff --git a/src/runtime/src/oci/build/engine/tests.rs b/src/runtime/src/oci/build/engine/tests.rs index 947285e0..3269e2d4 100644 --- a/src/runtime/src/oci/build/engine/tests.rs +++ b/src/runtime/src/oci/build/engine/tests.rs @@ -6,6 +6,7 @@ mod tests { use super::super::utils::*; use super::super::{ build, default_target_platform, scratch_config, validate_build_config, BuildConfig, + BuildState, }; use crate::oci::{ImageStore, OciImage}; use a3s_box_core::platform::Platform; @@ -48,6 +49,31 @@ mod tests { assert_eq!(expand_args("alpine:3.19", &args), "alpine:3.19"); } + #[test] + fn test_run_env_includes_declared_args_and_env_overrides() { + let mut build_args = HashMap::new(); + build_args.insert( + "ALPINE_MIRROR".to_string(), + "mirrors.tencent.com".to_string(), + ); + build_args.insert("MODE".to_string(), "prod".to_string()); + build_args.insert("UNDECLARED".to_string(), "ignored".to_string()); + + let mut state = BuildState::new(build_args); + state.declared_args.insert("ALPINE_MIRROR".to_string()); + state.declared_args.insert("MODE".to_string()); + state.env.push(("MODE".to_string(), "debug".to_string())); + + let env = state.run_env().into_iter().collect::>(); + + assert_eq!( + env.get("ALPINE_MIRROR").map(String::as_str), + Some("mirrors.tencent.com") + ); + assert_eq!(env.get("MODE").map(String::as_str), Some("debug")); + assert!(!env.contains_key("UNDECLARED")); + } + #[test] fn test_format_size() { assert_eq!(format_size(500), "500 B"); @@ -67,7 +93,56 @@ mod tests { target: None, no_cache: false, metrics: None, + run_pool: None, + } + } + + #[cfg(all(feature = "pool", not(windows)))] + fn parse_pool_volume_spec(spec: &str) -> (PathBuf, String, String) { + let parts = spec.rsplitn(3, ':').collect::>(); + assert_eq!(parts.len(), 3, "expected host:guest:mode volume spec"); + ( + PathBuf::from(parts[2]), + parts[1].to_string(), + parts[0].to_string(), + ) + } + + #[cfg(all(feature = "pool", not(windows)))] + fn image_layer_files(image: &OciImage) -> Vec { + let mut names = Vec::new(); + for layer in image.layer_paths() { + let file = std::fs::File::open(layer).unwrap(); + let dec = flate2::read::GzDecoder::new(file); + let mut ar = tar::Archive::new(dec); + names.extend( + ar.entries() + .unwrap() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.header().entry_type().is_file()) + .map(|entry| entry.path().unwrap().to_string_lossy().to_string()), + ); } + names.sort(); + names + } + + #[cfg(all(feature = "pool", not(windows)))] + fn tree_contains_file_named(root: &std::path::Path, file_name: &str) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { + return false; + }; + for entry in entries.filter_map(|entry| entry.ok()) { + let path = entry.path(); + if path.is_file() && path.file_name().and_then(|name| name.to_str()) == Some(file_name) + { + return true; + } + if path.is_dir() && tree_contains_file_named(&path, file_name) { + return true; + } + } + false } #[test] @@ -129,6 +204,7 @@ LABEL org.opencontainers.image.title="scratch-smoke" target: None, no_cache: false, metrics: None, + run_pool: None, }, store.clone(), ) @@ -150,6 +226,1303 @@ LABEL org.opencontainers.image.title="scratch-smoke" ); } + #[cfg(all(feature = "pool", not(windows)))] + #[tokio::test] + async fn test_build_run_pool_reuses_stage_lease_and_captures_rootfs_diff() { + use super::super::BuildRunPoolConfig; + use crate::pool::client::{read_frame, write_frame}; + use crate::pool::{ + PoolLeaseReleaseResponse, PoolLeaseResponse, PoolRequest, PoolRunResponse, + }; + use std::time::Duration; + use tokio::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let store_dir = tmp.path().join("images"); + let socket = tmp.path().join("pool.sock"); + let run_cache_dir = tmp.path().join("run-cache"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write(context.join("sh"), "fake shell").unwrap(); + std::fs::write( + context.join("Dockerfile"), + r#"FROM scratch +COPY sh /bin/sh +WORKDIR /app +ENV HELLO=warm +USER 1000:1001 +RUN echo "$HELLO" > out.txt +RUN cat out.txt > copied.txt +RUN ["/bin/sh", "-c", "printf exec > exec.txt"] +CMD ["cat", "/app/copied.txt"] +"#, + ) + .unwrap(); + + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = tokio::spawn(async move { + let mut lease_count = 0usize; + let mut exec_count = 0usize; + let mut release_count = 0usize; + let mut rootfs_host: Option = None; + + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let req: PoolRequest = + serde_json::from_slice(&read_frame(&mut stream).await.unwrap()).unwrap(); + + match req { + PoolRequest::Lease(req) => { + lease_count += 1; + assert_eq!(lease_count, 1, "stage should lease exactly one VM"); + assert_eq!(req.image.as_deref(), Some("helper:latest")); + assert_eq!(req.vcpus, Some(3)); + assert_eq!(req.memory_mb, Some(768)); + assert_eq!(req.volumes.len(), 1); + + let (host, guest, mode) = parse_pool_volume_spec(&req.volumes[0]); + assert_eq!(guest, "/run/a3s/test-rootfs"); + assert_eq!(mode, "rw"); + rootfs_host = Some(host); + + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseResponse { + lease_id: Some("lease-1".to_string()), + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Exec(req) => { + exec_count += 1; + assert_eq!(req.lease_id, "lease-1"); + assert_eq!(req.rootfs.as_deref(), Some("/run/a3s/test-rootfs")); + assert_eq!(req.timeout_ns, Some(12_000_000_000)); + assert_eq!(req.user.as_deref(), Some("1000:1001")); + assert!(req.env.iter().any(|entry| entry == "HELLO=warm")); + + let rootfs = rootfs_host.as_ref().expect("lease records rootfs host"); + match exec_count { + 1 => { + assert_eq!(req.working_dir.as_deref(), Some("/")); + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cd '/app' && echo \"$HELLO\" > out.txt".to_string(), + ] + ); + std::fs::write(rootfs.join("app/out.txt"), "warm\n").unwrap(); + } + 2 => { + assert_eq!(req.working_dir.as_deref(), Some("/")); + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cd '/app' && cat out.txt > copied.txt".to_string(), + ] + ); + let content = std::fs::read_to_string(rootfs.join("app/out.txt")) + .expect("second RUN sees first RUN output in the same rootfs"); + std::fs::write(rootfs.join("app/copied.txt"), content).unwrap(); + } + 3 => { + assert_eq!(req.working_dir.as_deref(), Some("/app")); + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf exec > exec.txt".to_string(), + ] + ); + std::fs::write(rootfs.join("app/exec.txt"), "exec").unwrap(); + } + other => panic!("unexpected exec request {other}"), + } + + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRunResponse { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Release(req) => { + release_count += 1; + assert_eq!(req.lease_id, "lease-1"); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseReleaseResponse { error: None }).unwrap(), + ) + .await + .unwrap(); + break; + } + other => panic!( + "unexpected pool request: {:?}", + std::mem::discriminant(&other) + ), + } + } + + assert_eq!(lease_count, 1); + assert_eq!(exec_count, 3); + assert_eq!(release_count, 1); + }); + + let store = Arc::new(ImageStore::new(&store_dir, 1024 * 1024 * 100).unwrap()); + let result = tokio::time::timeout( + Duration::from_secs(10), + build( + BuildConfig { + context_dir: context.clone(), + dockerfile_path: context.join("Dockerfile"), + tag: Some("run-pool:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: Some(BuildRunPoolConfig { + socket: socket.to_string_lossy().to_string(), + image: Some("helper:latest".to_string()), + vcpus: 3, + memory_mb: 768, + guest_rootfs: "/run/a3s/test-rootfs".to_string(), + timeout_ns: 12_000_000_000, + run_cache_dir: run_cache_dir.clone(), + }), + }, + store.clone(), + ), + ) + .await + .expect("build should not hang") + .unwrap(); + + daemon.await.unwrap(); + assert_eq!(result.layer_count, 4); + + let stored = store.get("run-pool:latest").await.unwrap(); + let image = OciImage::from_path(&stored.path).unwrap(); + let files = image_layer_files(&image); + assert_eq!(image.config().user.as_deref(), Some("1000:1001")); + assert!(files.iter().any(|path| path == "bin/sh")); + assert!(files.iter().any(|path| path == "app/out.txt")); + assert!(files.iter().any(|path| path == "app/copied.txt")); + assert!(files.iter().any(|path| path == "app/exec.txt")); + } + + #[cfg(all(feature = "pool", not(windows)))] + #[tokio::test] + async fn test_build_run_pool_bind_mount_context_is_not_committed() { + use super::super::BuildRunPoolConfig; + use crate::pool::client::{read_frame, write_frame}; + use crate::pool::{ + PoolLeaseReleaseResponse, PoolLeaseResponse, PoolRequest, PoolRunResponse, + }; + use std::time::Duration; + use tokio::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let store_dir = tmp.path().join("images"); + let socket = tmp.path().join("pool.sock"); + let run_cache_dir = tmp.path().join("run-cache"); + std::fs::create_dir_all(context.join("src")).unwrap(); + std::fs::write(context.join("sh"), "fake shell").unwrap(); + std::fs::write(context.join("src/input.txt"), "from-bind\n").unwrap(); + std::fs::write( + context.join("Dockerfile"), + r#"FROM scratch +COPY sh /bin/sh +WORKDIR /work +RUN --mount=type=bind,source=src,target=. cat input.txt > /out.txt +"#, + ) + .unwrap(); + + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = tokio::spawn(async move { + let mut rootfs_host: Option = None; + + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let req: PoolRequest = + serde_json::from_slice(&read_frame(&mut stream).await.unwrap()).unwrap(); + + match req { + PoolRequest::Lease(req) => { + let (host, guest, mode) = parse_pool_volume_spec(&req.volumes[0]); + assert_eq!(guest, "/run/a3s/test-rootfs"); + assert_eq!(mode, "rw"); + rootfs_host = Some(host); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseResponse { + lease_id: Some("lease-bind".to_string()), + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Exec(req) => { + assert_eq!(req.lease_id, "lease-bind"); + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cd '/work' && cat input.txt > /out.txt".to_string(), + ] + ); + let rootfs = rootfs_host.as_ref().expect("lease records rootfs host"); + assert_eq!( + std::fs::read_to_string(rootfs.join("work/input.txt")).unwrap(), + "from-bind\n" + ); + std::fs::write(rootfs.join("out.txt"), "from-bind\n").unwrap(); + std::fs::write(rootfs.join("work/generated.txt"), "discarded\n").unwrap(); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRunResponse { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Release(req) => { + assert_eq!(req.lease_id, "lease-bind"); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseReleaseResponse { error: None }).unwrap(), + ) + .await + .unwrap(); + break; + } + other => panic!( + "unexpected pool request: {:?}", + std::mem::discriminant(&other) + ), + } + } + }); + + let store = Arc::new(ImageStore::new(&store_dir, 1024 * 1024 * 100).unwrap()); + let result = tokio::time::timeout( + Duration::from_secs(10), + build( + BuildConfig { + context_dir: context.clone(), + dockerfile_path: context.join("Dockerfile"), + tag: Some("run-pool-bind:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: Some(BuildRunPoolConfig { + socket: socket.to_string_lossy().to_string(), + image: Some("helper:latest".to_string()), + vcpus: 2, + memory_mb: 512, + guest_rootfs: "/run/a3s/test-rootfs".to_string(), + timeout_ns: 12_000_000_000, + run_cache_dir: run_cache_dir.clone(), + }), + }, + store.clone(), + ), + ) + .await + .expect("build should not hang") + .unwrap(); + + daemon.await.unwrap(); + assert_eq!(result.layer_count, 2); + + let stored = store.get("run-pool-bind:latest").await.unwrap(); + let image = OciImage::from_path(&stored.path).unwrap(); + let files = image_layer_files(&image); + assert!(files.iter().any(|path| path == "out.txt")); + assert!(!files.iter().any(|path| path == "work/input.txt")); + assert!(!files.iter().any(|path| path == "work/generated.txt")); + } + + #[cfg(all(feature = "pool", not(windows)))] + #[tokio::test] + async fn test_build_run_pool_bind_mount_from_stage_is_not_committed() { + use super::super::BuildRunPoolConfig; + use crate::pool::client::{read_frame, write_frame}; + use crate::pool::{ + PoolLeaseReleaseResponse, PoolLeaseResponse, PoolRequest, PoolRunResponse, + }; + use std::collections::HashMap; + use std::time::Duration; + use tokio::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let store_dir = tmp.path().join("images"); + let socket = tmp.path().join("pool.sock"); + let run_cache_dir = tmp.path().join("run-cache"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write(context.join("sh"), "fake shell").unwrap(); + std::fs::write( + context.join("Dockerfile"), + r#"FROM scratch AS builder +COPY sh /bin/sh +RUN printf built > /artifact.txt + +FROM scratch +COPY sh /bin/sh +WORKDIR /work +RUN --mount=type=bind,from=builder,source=/artifact.txt,target=artifact.txt cat artifact.txt > /out.txt +"#, + ) + .unwrap(); + + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = tokio::spawn(async move { + let mut lease_count = 0usize; + let mut exec_count = 0usize; + let mut release_count = 0usize; + let mut rootfs_hosts: HashMap = HashMap::new(); + + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let req: PoolRequest = + serde_json::from_slice(&read_frame(&mut stream).await.unwrap()).unwrap(); + + match req { + PoolRequest::Lease(req) => { + lease_count += 1; + assert_eq!(req.volumes.len(), 1); + let (host, guest, mode) = parse_pool_volume_spec(&req.volumes[0]); + assert_eq!(guest, "/run/a3s/test-rootfs"); + assert_eq!(mode, "rw"); + let lease_id = format!("lease-{lease_count}"); + rootfs_hosts.insert(lease_id.clone(), host); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseResponse { + lease_id: Some(lease_id), + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Exec(req) => { + exec_count += 1; + let rootfs = rootfs_hosts + .get(&req.lease_id) + .expect("lease records rootfs host"); + match req.lease_id.as_str() { + "lease-1" => { + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf built > /artifact.txt".to_string(), + ] + ); + std::fs::write(rootfs.join("artifact.txt"), "built").unwrap(); + } + "lease-2" => { + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cd '/work' && cat artifact.txt > /out.txt".to_string(), + ] + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("work/artifact.txt")) + .unwrap(), + "built" + ); + std::fs::write(rootfs.join("out.txt"), "built").unwrap(); + std::fs::write(rootfs.join("work/artifact.txt"), "discarded") + .unwrap(); + } + other => panic!("unexpected lease id {other}"), + } + + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRunResponse { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Release(req) => { + release_count += 1; + assert!(matches!(req.lease_id.as_str(), "lease-1" | "lease-2")); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseReleaseResponse { error: None }).unwrap(), + ) + .await + .unwrap(); + if release_count == 2 { + break; + } + } + other => panic!( + "unexpected pool request: {:?}", + std::mem::discriminant(&other) + ), + } + } + + assert_eq!(lease_count, 2); + assert_eq!(exec_count, 2); + assert_eq!(release_count, 2); + }); + + let store = Arc::new(ImageStore::new(&store_dir, 1024 * 1024 * 100).unwrap()); + let result = tokio::time::timeout( + Duration::from_secs(10), + build( + BuildConfig { + context_dir: context.clone(), + dockerfile_path: context.join("Dockerfile"), + tag: Some("run-pool-stage-bind:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: Some(BuildRunPoolConfig { + socket: socket.to_string_lossy().to_string(), + image: Some("helper:latest".to_string()), + vcpus: 2, + memory_mb: 512, + guest_rootfs: "/run/a3s/test-rootfs".to_string(), + timeout_ns: 12_000_000_000, + run_cache_dir: run_cache_dir.clone(), + }), + }, + store.clone(), + ), + ) + .await + .expect("build should not hang") + .unwrap(); + + daemon.await.unwrap(); + assert_eq!(result.layer_count, 2); + + let stored = store.get("run-pool-stage-bind:latest").await.unwrap(); + let image = OciImage::from_path(&stored.path).unwrap(); + let files = image_layer_files(&image); + assert!(files.iter().any(|path| path == "bin/sh")); + assert!(files.iter().any(|path| path == "out.txt")); + assert!(!files.iter().any(|path| path == "work/artifact.txt")); + } + + #[cfg(all(feature = "pool", not(windows)))] + #[tokio::test] + async fn test_build_run_pool_bind_mount_from_external_image_is_not_committed() { + use super::super::BuildRunPoolConfig; + use crate::pool::client::{read_frame, write_frame}; + use crate::pool::{ + PoolLeaseReleaseResponse, PoolLeaseResponse, PoolRequest, PoolRunResponse, + }; + use std::time::Duration; + use tokio::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let source_context = tmp.path().join("source-context"); + let target_context = tmp.path().join("target-context"); + let store_dir = tmp.path().join("images"); + let socket = tmp.path().join("pool.sock"); + let run_cache_dir = tmp.path().join("run-cache"); + std::fs::create_dir_all(&source_context).unwrap(); + std::fs::create_dir_all(&target_context).unwrap(); + std::fs::write(source_context.join("artifact.txt"), "from-external").unwrap(); + std::fs::write( + source_context.join("Dockerfile"), + r#"FROM scratch +COPY artifact.txt /artifact.txt +"#, + ) + .unwrap(); + std::fs::write(target_context.join("sh"), "fake shell").unwrap(); + std::fs::write( + target_context.join("Dockerfile"), + r#"FROM scratch +COPY sh /bin/sh +WORKDIR /work +RUN --mount=type=bind,from=external-bind-source:latest,source=/artifact.txt,target=artifact.txt cat artifact.txt > /out.txt +"#, + ) + .unwrap(); + + let store = Arc::new(ImageStore::new(&store_dir, 1024 * 1024 * 100).unwrap()); + build( + BuildConfig { + context_dir: source_context.clone(), + dockerfile_path: source_context.join("Dockerfile"), + tag: Some("external-bind-source:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: None, + }, + store.clone(), + ) + .await + .expect("source image should build into the local store"); + + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = tokio::spawn(async move { + let mut rootfs_host: Option = None; + + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let req: PoolRequest = + serde_json::from_slice(&read_frame(&mut stream).await.unwrap()).unwrap(); + + match req { + PoolRequest::Lease(req) => { + assert_eq!(req.volumes.len(), 1); + let (host, guest, mode) = parse_pool_volume_spec(&req.volumes[0]); + assert_eq!(guest, "/run/a3s/test-rootfs"); + assert_eq!(mode, "rw"); + rootfs_host = Some(host); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseResponse { + lease_id: Some("lease-external-bind".to_string()), + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Exec(req) => { + assert_eq!(req.lease_id, "lease-external-bind"); + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cd '/work' && cat artifact.txt > /out.txt".to_string(), + ] + ); + let rootfs = rootfs_host.as_ref().expect("lease records rootfs host"); + assert_eq!( + std::fs::read_to_string(rootfs.join("work/artifact.txt")).unwrap(), + "from-external" + ); + std::fs::write(rootfs.join("out.txt"), "from-external").unwrap(); + std::fs::write(rootfs.join("work/artifact.txt"), "discarded").unwrap(); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRunResponse { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Release(req) => { + assert_eq!(req.lease_id, "lease-external-bind"); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseReleaseResponse { error: None }).unwrap(), + ) + .await + .unwrap(); + break; + } + other => panic!( + "unexpected pool request: {:?}", + std::mem::discriminant(&other) + ), + } + } + }); + + let result = tokio::time::timeout( + Duration::from_secs(10), + build( + BuildConfig { + context_dir: target_context.clone(), + dockerfile_path: target_context.join("Dockerfile"), + tag: Some("run-pool-external-bind:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: Some(BuildRunPoolConfig { + socket: socket.to_string_lossy().to_string(), + image: Some("helper:latest".to_string()), + vcpus: 2, + memory_mb: 512, + guest_rootfs: "/run/a3s/test-rootfs".to_string(), + timeout_ns: 12_000_000_000, + run_cache_dir: run_cache_dir.clone(), + }), + }, + store.clone(), + ), + ) + .await + .expect("build should not hang") + .unwrap(); + + daemon.await.unwrap(); + assert_eq!(result.layer_count, 2); + + let stored = store.get("run-pool-external-bind:latest").await.unwrap(); + let image = OciImage::from_path(&stored.path).unwrap(); + let files = image_layer_files(&image); + assert!(files.iter().any(|path| path == "bin/sh")); + assert!(files.iter().any(|path| path == "out.txt")); + assert!(!files.iter().any(|path| path == "work/artifact.txt")); + } + + #[cfg(all(feature = "pool", not(windows)))] + #[tokio::test] + async fn test_build_run_pool_tmpfs_mount_is_not_committed() { + use super::super::BuildRunPoolConfig; + use crate::pool::client::{read_frame, write_frame}; + use crate::pool::{ + PoolLeaseReleaseResponse, PoolLeaseResponse, PoolRequest, PoolRunResponse, + }; + use std::time::Duration; + use tokio::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let store_dir = tmp.path().join("images"); + let socket = tmp.path().join("pool.sock"); + let run_cache_dir = tmp.path().join("run-cache"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write(context.join("sh"), "fake shell").unwrap(); + std::fs::write(context.join("original.txt"), "from-rootfs\n").unwrap(); + std::fs::write( + context.join("Dockerfile"), + r#"FROM scratch +COPY sh /bin/sh +WORKDIR /work +COPY original.txt tmp/original.txt +RUN --mount=type=tmpfs,target=tmp printf ok > /out.txt +"#, + ) + .unwrap(); + + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = tokio::spawn(async move { + let mut rootfs_host: Option = None; + + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let req: PoolRequest = + serde_json::from_slice(&read_frame(&mut stream).await.unwrap()).unwrap(); + + match req { + PoolRequest::Lease(req) => { + let (host, guest, mode) = parse_pool_volume_spec(&req.volumes[0]); + assert_eq!(guest, "/run/a3s/test-rootfs"); + assert_eq!(mode, "rw"); + rootfs_host = Some(host); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseResponse { + lease_id: Some("lease-tmpfs".to_string()), + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Exec(req) => { + assert_eq!(req.lease_id, "lease-tmpfs"); + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cd '/work' && printf ok > /out.txt".to_string(), + ] + ); + let rootfs = rootfs_host.as_ref().expect("lease records rootfs host"); + assert!(rootfs.join("work/tmp").is_dir()); + assert!( + !rootfs.join("work/tmp/original.txt").exists(), + "tmpfs mount should hide the original target during RUN" + ); + std::fs::write(rootfs.join("out.txt"), "ok").unwrap(); + std::fs::write(rootfs.join("work/tmp/transient.txt"), "discarded").unwrap(); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRunResponse { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Release(req) => { + assert_eq!(req.lease_id, "lease-tmpfs"); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseReleaseResponse { error: None }).unwrap(), + ) + .await + .unwrap(); + break; + } + other => panic!( + "unexpected pool request: {:?}", + std::mem::discriminant(&other) + ), + } + } + }); + + let store = Arc::new(ImageStore::new(&store_dir, 1024 * 1024 * 100).unwrap()); + let result = tokio::time::timeout( + Duration::from_secs(10), + build( + BuildConfig { + context_dir: context.clone(), + dockerfile_path: context.join("Dockerfile"), + tag: Some("run-pool-tmpfs:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: Some(BuildRunPoolConfig { + socket: socket.to_string_lossy().to_string(), + image: Some("helper:latest".to_string()), + vcpus: 2, + memory_mb: 512, + guest_rootfs: "/run/a3s/test-rootfs".to_string(), + timeout_ns: 12_000_000_000, + run_cache_dir: run_cache_dir.clone(), + }), + }, + store.clone(), + ), + ) + .await + .expect("build should not hang") + .unwrap(); + + daemon.await.unwrap(); + assert_eq!(result.layer_count, 3); + + let stored = store.get("run-pool-tmpfs:latest").await.unwrap(); + let image = OciImage::from_path(&stored.path).unwrap(); + let files = image_layer_files(&image); + assert!(files.iter().any(|path| path == "out.txt")); + assert!(files.iter().any(|path| path == "work/tmp/original.txt")); + assert!(!files.iter().any(|path| path == "work/tmp/transient.txt")); + } + + #[cfg(all(feature = "pool", not(windows)))] + #[tokio::test] + async fn test_build_run_pool_releases_stage_lease_after_run_failure() { + use super::super::BuildRunPoolConfig; + use crate::pool::client::{read_frame, write_frame}; + use crate::pool::{ + PoolLeaseReleaseResponse, PoolLeaseResponse, PoolRequest, PoolRunResponse, + }; + use std::time::Duration; + use tokio::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let store_dir = tmp.path().join("images"); + let socket = tmp.path().join("pool.sock"); + let run_cache_dir = tmp.path().join("run-cache"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write(context.join("sh"), "fake shell").unwrap(); + std::fs::write(context.join("cache-marker"), "original\n").unwrap(); + std::fs::write( + context.join("Dockerfile"), + r#"FROM scratch +COPY sh /bin/sh +COPY cache-marker /root/.cache/original.txt +RUN --mount=type=cache,id=failed,target=/root/.cache echo before-failure > /root/.cache/failed.txt && false +"#, + ) + .unwrap(); + + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = tokio::spawn(async move { + let mut lease_count = 0usize; + let mut exec_count = 0usize; + let mut release_count = 0usize; + let mut rootfs_host: Option = None; + + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let req: PoolRequest = + serde_json::from_slice(&read_frame(&mut stream).await.unwrap()).unwrap(); + + match req { + PoolRequest::Lease(req) => { + lease_count += 1; + assert_eq!(req.volumes.len(), 1); + let (host, guest, mode) = parse_pool_volume_spec(&req.volumes[0]); + assert_eq!(guest, "/run/a3s/test-rootfs"); + assert_eq!(mode, "rw"); + rootfs_host = Some(host); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseResponse { + lease_id: Some("lease-fail".to_string()), + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Exec(req) => { + exec_count += 1; + assert_eq!(req.lease_id, "lease-fail"); + let rootfs = rootfs_host.as_ref().expect("lease records rootfs host"); + std::fs::write(rootfs.join("root/.cache/failed.txt"), "failed\n").unwrap(); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRunResponse { + stdout: b"before-failure\n".to_vec(), + stderr: b"boom\n".to_vec(), + exit_code: 42, + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Release(req) => { + release_count += 1; + assert_eq!(req.lease_id, "lease-fail"); + let response = + serde_json::to_vec(&PoolLeaseReleaseResponse { error: None }).unwrap(); + let _ = write_frame(&mut stream, &response).await; + break; + } + other => panic!( + "unexpected pool request: {:?}", + std::mem::discriminant(&other) + ), + } + } + + assert_eq!(lease_count, 1); + assert_eq!(exec_count, 1); + assert_eq!(release_count, 1); + }); + + let store = Arc::new(ImageStore::new(&store_dir, 1024 * 1024 * 100).unwrap()); + let error = tokio::time::timeout( + Duration::from_secs(10), + build( + BuildConfig { + context_dir: context.clone(), + dockerfile_path: context.join("Dockerfile"), + tag: Some("run-pool-failure:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: Some(BuildRunPoolConfig { + socket: socket.to_string_lossy().to_string(), + image: Some("helper:latest".to_string()), + vcpus: 2, + memory_mb: 512, + guest_rootfs: "/run/a3s/test-rootfs".to_string(), + timeout_ns: 12_000_000_000, + run_cache_dir: run_cache_dir.clone(), + }), + }, + store, + ), + ) + .await + .expect("build should not hang") + .expect_err("RUN failure should fail the build"); + + assert!(error.to_string().contains("exit 42")); + assert!(error.to_string().contains("boom")); + daemon.await.unwrap(); + assert!( + !tree_contains_file_named(&run_cache_dir, "failed.txt"), + "failed RUN cache mount contents must not be persisted" + ); + } + + #[cfg(all(feature = "pool", not(windows)))] + #[tokio::test] + async fn test_build_run_pool_cache_mount_is_not_committed_to_layer() { + use super::super::BuildRunPoolConfig; + use crate::pool::client::{read_frame, write_frame}; + use crate::pool::{ + PoolLeaseReleaseResponse, PoolLeaseResponse, PoolRequest, PoolRunResponse, + }; + use std::time::Duration; + use tokio::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let store_dir = tmp.path().join("images"); + let socket = tmp.path().join("pool.sock"); + let run_cache_dir = tmp.path().join("run-cache"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write(context.join("sh"), "fake shell").unwrap(); + std::fs::write(context.join("cache-marker"), "original\n").unwrap(); + std::fs::write( + context.join("Dockerfile"), + r#"FROM scratch +COPY sh /bin/sh +COPY cache-marker /root/.cache/original.txt +RUN --mount=type=cache,id=warm,target=/root/.cache echo warm > /root/.cache/cache-only.txt +RUN --mount=type=cache,id=warm,target=/root/.cache cat /root/.cache/cache-only.txt > /result.txt +"#, + ) + .unwrap(); + + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = tokio::spawn(async move { + let mut rootfs_host: Option = None; + let mut exec_count = 0usize; + + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let req: PoolRequest = + serde_json::from_slice(&read_frame(&mut stream).await.unwrap()).unwrap(); + + match req { + PoolRequest::Lease(req) => { + let (host, guest, mode) = parse_pool_volume_spec(&req.volumes[0]); + assert_eq!(guest, "/run/a3s/test-rootfs"); + assert_eq!(mode, "rw"); + rootfs_host = Some(host); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseResponse { + lease_id: Some("lease-cache".to_string()), + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Exec(req) => { + exec_count += 1; + assert_eq!(req.lease_id, "lease-cache"); + assert_eq!(req.rootfs.as_deref(), Some("/run/a3s/test-rootfs")); + let rootfs = rootfs_host.as_ref().expect("lease records rootfs host"); + + assert!( + !rootfs.join("root/.cache/original.txt").exists(), + "cache mount should hide original target contents during RUN" + ); + match exec_count { + 1 => { + std::fs::write(rootfs.join("root/.cache/cache-only.txt"), "warm\n") + .unwrap(); + } + 2 => { + let cached = std::fs::read_to_string( + rootfs.join("root/.cache/cache-only.txt"), + ) + .expect("second RUN sees persistent cache mount content"); + std::fs::write(rootfs.join("result.txt"), cached).unwrap(); + } + other => panic!("unexpected exec request {other}"), + } + + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRunResponse { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Release(req) => { + assert_eq!(req.lease_id, "lease-cache"); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseReleaseResponse { error: None }).unwrap(), + ) + .await + .unwrap(); + assert_eq!(exec_count, 2); + break; + } + other => panic!( + "unexpected pool request: {:?}", + std::mem::discriminant(&other) + ), + } + } + }); + + let store = Arc::new(ImageStore::new(&store_dir, 1024 * 1024 * 100).unwrap()); + let result = tokio::time::timeout( + Duration::from_secs(10), + build( + BuildConfig { + context_dir: context.clone(), + dockerfile_path: context.join("Dockerfile"), + tag: Some("run-pool-cache:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: Some(BuildRunPoolConfig { + socket: socket.to_string_lossy().to_string(), + image: Some("helper:latest".to_string()), + vcpus: 2, + memory_mb: 512, + guest_rootfs: "/run/a3s/test-rootfs".to_string(), + timeout_ns: 60_000_000_000, + run_cache_dir, + }), + }, + store.clone(), + ), + ) + .await + .expect("build should not hang") + .unwrap(); + + daemon.await.unwrap(); + + let stored = store.get("run-pool-cache:latest").await.unwrap(); + let image = OciImage::from_path(&stored.path).unwrap(); + let files = image_layer_files(&image); + assert_eq!(result.layer_count, 3); + assert!(files.iter().any(|path| path == "root/.cache/original.txt")); + assert!(files.iter().any(|path| path == "result.txt")); + assert!(!files + .iter() + .any(|path| path == "root/.cache/cache-only.txt")); + } + + #[cfg(all(feature = "pool", not(windows)))] + #[tokio::test] + async fn test_build_run_pool_cache_mount_from_stage_seeds_cache() { + use super::super::BuildRunPoolConfig; + use crate::pool::client::{read_frame, write_frame}; + use crate::pool::{ + PoolLeaseReleaseResponse, PoolLeaseResponse, PoolRequest, PoolRunResponse, + }; + use std::time::Duration; + use tokio::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let context = tmp.path().join("context"); + let store_dir = tmp.path().join("images"); + let socket = tmp.path().join("pool.sock"); + let run_cache_dir = tmp.path().join("run-cache"); + std::fs::create_dir_all(&context).unwrap(); + std::fs::write(context.join("sh"), "fake shell").unwrap(); + std::fs::write(context.join("seed.txt"), "seeded\n").unwrap(); + std::fs::write( + context.join("Dockerfile"), + r#"FROM scratch AS builder +COPY seed.txt /seed-cache/seed.txt + +FROM scratch +COPY sh /bin/sh +RUN --mount=type=cache,id=seeded,sharing=locked,from=builder,source=/seed-cache,target=/root/.cache cat /root/.cache/seed.txt > /out.txt +"#, + ) + .unwrap(); + + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = tokio::spawn(async move { + let mut rootfs_host: Option = None; + + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let req: PoolRequest = + serde_json::from_slice(&read_frame(&mut stream).await.unwrap()).unwrap(); + + match req { + PoolRequest::Lease(req) => { + assert_eq!(req.volumes.len(), 1); + let (host, guest, mode) = parse_pool_volume_spec(&req.volumes[0]); + assert_eq!(guest, "/run/a3s/test-rootfs"); + assert_eq!(mode, "rw"); + rootfs_host = Some(host); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseResponse { + lease_id: Some("lease-cache-seed".to_string()), + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Exec(req) => { + assert_eq!(req.lease_id, "lease-cache-seed"); + assert_eq!( + req.cmd, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "cat /root/.cache/seed.txt > /out.txt".to_string(), + ] + ); + let rootfs = rootfs_host.as_ref().expect("lease records rootfs host"); + assert_eq!( + std::fs::read_to_string(rootfs.join("root/.cache/seed.txt")).unwrap(), + "seeded\n" + ); + std::fs::write(rootfs.join("out.txt"), "seeded\n").unwrap(); + std::fs::write(rootfs.join("root/.cache/generated.txt"), "persisted") + .unwrap(); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRunResponse { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + error: None, + }) + .unwrap(), + ) + .await + .unwrap(); + } + PoolRequest::Release(req) => { + assert_eq!(req.lease_id, "lease-cache-seed"); + write_frame( + &mut stream, + &serde_json::to_vec(&PoolLeaseReleaseResponse { error: None }).unwrap(), + ) + .await + .unwrap(); + break; + } + other => panic!( + "unexpected pool request: {:?}", + std::mem::discriminant(&other) + ), + } + } + }); + + let store = Arc::new(ImageStore::new(&store_dir, 1024 * 1024 * 100).unwrap()); + let result = tokio::time::timeout( + Duration::from_secs(10), + build( + BuildConfig { + context_dir: context.clone(), + dockerfile_path: context.join("Dockerfile"), + tag: Some("run-pool-cache-seed:latest".to_string()), + build_args: HashMap::new(), + quiet: true, + platforms: vec![], + target: None, + no_cache: true, + metrics: None, + run_pool: Some(BuildRunPoolConfig { + socket: socket.to_string_lossy().to_string(), + image: Some("helper:latest".to_string()), + vcpus: 2, + memory_mb: 512, + guest_rootfs: "/run/a3s/test-rootfs".to_string(), + timeout_ns: 60_000_000_000, + run_cache_dir: run_cache_dir.clone(), + }), + }, + store.clone(), + ), + ) + .await + .expect("build should not hang") + .unwrap(); + + daemon.await.unwrap(); + assert_eq!(result.layer_count, 2); + + let stored = store.get("run-pool-cache-seed:latest").await.unwrap(); + let image = OciImage::from_path(&stored.path).unwrap(); + let files = image_layer_files(&image); + assert!(files.iter().any(|path| path == "bin/sh")); + assert!(files.iter().any(|path| path == "out.txt")); + assert!(!files.iter().any(|path| path == "root/.cache/seed.txt")); + assert!(!files.iter().any(|path| path == "root/.cache/generated.txt")); + assert!(tree_contains_file_named(&run_cache_dir, "seed.txt")); + assert!(tree_contains_file_named(&run_cache_dir, "generated.txt")); + } + /// Regression: a multi-stage `COPY --from= /abs/path` must resolve /// the absolute source inside the source stage's rootfs. Previously /// `context_dir.join("/abs")` discarded the base (Path::join semantics) and @@ -183,6 +1556,7 @@ LABEL org.opencontainers.image.title="scratch-smoke" target: Some("builder".to_string()), no_cache: false, metrics: None, + run_pool: None, }, store.clone(), ) @@ -208,6 +1582,7 @@ LABEL org.opencontainers.image.title="scratch-smoke" target: Some("nope".to_string()), no_cache: false, metrics: None, + run_pool: None, }, store.clone(), ) @@ -250,6 +1625,7 @@ LABEL org.opencontainers.image.title="scratch-smoke" target: None, no_cache: false, metrics: None, + run_pool: None, }, store.clone(), ) @@ -318,6 +1694,7 @@ CMD ["/work/run.sh"] target: None, no_cache: false, metrics: None, + run_pool: None, }, store.clone(), ) diff --git a/src/runtime/src/oci/build/layer.rs b/src/runtime/src/oci/build/layer.rs index 9f3c8cb5..4e72be68 100644 --- a/src/runtime/src/oci/build/layer.rs +++ b/src/runtime/src/oci/build/layer.rs @@ -22,6 +22,11 @@ pub struct FileEntry { /// this — not size or mtime — so it must be part of the change check or the /// new mode is silently dropped from the layer. pub mode: u32, + /// Unix owner user ID. A `chown` can change only ownership, so uid/gid must + /// be tracked or the generated layer silently drops the change. + pub uid: u32, + /// Unix owner group ID. + pub gid: u32, /// Whether this is a directory pub is_dir: bool, } @@ -60,6 +65,8 @@ impl DirSnapshot { if before_entry.size != after_entry.size || before_entry.mtime != after_entry.mtime || before_entry.mode != after_entry.mode + || before_entry.uid != after_entry.uid + || before_entry.gid != after_entry.gid { changed.push(path.clone()); } @@ -147,12 +154,16 @@ fn walk_dir(root: &Path, current: &Path, entries: &mut HashMap String { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; use std::fs; use tempfile::TempDir; @@ -656,6 +670,41 @@ mod tests { assert_eq!(before.diff(&after), vec![PathBuf::from("entry.sh")]); } + #[test] + fn test_snapshot_diff_detects_chown_only() { + let path = PathBuf::from("app/node_modules/pkg/package.json"); + let before = DirSnapshot { + entries: HashMap::from([( + path.clone(), + FileEntry { + path: path.clone(), + size: 1300, + mtime: 1, + mode: 0o100640, + uid: 0, + gid: 0, + is_dir: false, + }, + )]), + }; + let after = DirSnapshot { + entries: HashMap::from([( + path.clone(), + FileEntry { + path: path.clone(), + size: 1300, + mtime: 1, + mode: 0o100640, + uid: 1001, + gid: 1001, + is_dir: false, + }, + )]), + }; + + assert_eq!(before.diff(&after), vec![path]); + } + #[test] fn test_snapshot_diff_no_changes() { let tmp = TempDir::new().unwrap(); diff --git a/src/runtime/src/oci/build/mod.rs b/src/runtime/src/oci/build/mod.rs index c41ca9a9..6887a363 100644 --- a/src/runtime/src/oci/build/mod.rs +++ b/src/runtime/src/oci/build/mod.rs @@ -11,7 +11,7 @@ //! //! # Supported Instructions //! -//! FROM, shell-form RUN, shell-form COPY/ADD, WORKDIR, ENV, ENTRYPOINT, CMD, +//! FROM, shell/exec-form RUN, shell-form COPY/ADD, WORKDIR, ENV, ENTRYPOINT, CMD, //! EXPOSE, LABEL, USER, ARG, SHELL, STOPSIGNAL, HEALTHCHECK, ONBUILD metadata //! triggers, VOLUME. //! @@ -25,5 +25,5 @@ pub mod engine; pub mod layer; pub use dockerfile::{Dockerfile, Instruction}; -pub use engine::{build, BuildConfig, BuildResult}; +pub use engine::{build, BuildConfig, BuildResult, BuildRunPoolConfig}; pub use layer::{DirSnapshot, LayerInfo}; diff --git a/src/runtime/src/oci/credentials.rs b/src/runtime/src/oci/credentials.rs index 02e1918a..b61602b1 100644 --- a/src/runtime/src/oci/credentials.rs +++ b/src/runtime/src/oci/credentials.rs @@ -4,9 +4,10 @@ //! Uses atomic writes (write tmp, rename) for safety. use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use a3s_box_core::error::{BoxError, Result}; +use base64::Engine as _; use serde::{Deserialize, Serialize}; /// Per-registry credential entry. @@ -22,6 +23,28 @@ struct CredentialFile { registries: HashMap, } +#[derive(Debug, Default, Deserialize)] +struct DockerConfigFile { + #[serde(default)] + auths: HashMap, + #[serde(default, rename = "credsStore")] + creds_store: Option, + #[serde(default, rename = "credHelpers")] + cred_helpers: HashMap, +} + +#[derive(Debug, Default, Deserialize)] +struct DockerAuthEntry { + #[serde(default)] + auth: Option, + #[serde(default)] + username: Option, + #[serde(default)] + password: Option, + #[serde(default, rename = "identitytoken")] + identity_token: Option, +} + /// Persistent credential store for container registries. /// /// Stores credentials at `~/.a3s/auth/credentials.json`. @@ -164,6 +187,214 @@ impl CredentialStore { } } +/// Get credentials from Docker's config (`~/.docker/config.json` or +/// `$DOCKER_CONFIG/config.json`), including credential helpers. +pub(crate) fn docker_credentials(registry: &str) -> Option<(String, String)> { + let config_path = docker_config_path()?; + let config = load_docker_config(&config_path).ok()?; + let candidates = docker_registry_candidates(registry); + + if let Some((key, helper)) = matching_credential_helper(&config, &candidates) { + if let Some(creds) = + docker_credential_helper_get(helper, &helper_server_candidates(key, registry)) + { + return Some(creds); + } + } + + if let Some(helper) = config.creds_store.as_deref() { + if let Some(creds) = docker_credential_helper_get(helper, &candidates) { + return Some(creds); + } + } + + matching_docker_auth(&config, &candidates).and_then(docker_auth_entry_credentials) +} + +fn docker_config_path() -> Option { + if let Ok(config_dir) = std::env::var("DOCKER_CONFIG") { + return Some(PathBuf::from(config_dir).join("config.json")); + } + dirs::home_dir().map(|home| home.join(".docker").join("config.json")) +} + +fn load_docker_config(path: &Path) -> Result { + let data = std::fs::read_to_string(path).map_err(|e| { + BoxError::ConfigError(format!( + "Failed to read Docker credential config {}: {}", + path.display(), + e + )) + })?; + serde_json::from_str(&data).map_err(|e| { + BoxError::ConfigError(format!( + "Failed to parse Docker credential config {}: {}", + path.display(), + e + )) + }) +} + +fn docker_registry_candidates(registry: &str) -> Vec { + let normalized = normalize_registry(registry); + let mut candidates = vec![ + registry.trim().to_string(), + normalized.clone(), + format!("https://{}", registry.trim()), + format!("http://{}", registry.trim()), + format!("https://{normalized}"), + format!("http://{normalized}"), + ]; + + if normalized == "index.docker.io" { + candidates.extend( + [ + "docker.io", + "registry-1.docker.io", + "https://index.docker.io/v1/", + "https://index.docker.io/v1", + "index.docker.io/v1/", + "index.docker.io/v1", + ] + .into_iter() + .map(str::to_string), + ); + } + + candidates.sort(); + candidates.dedup(); + candidates +} + +fn matching_credential_helper<'a>( + config: &'a DockerConfigFile, + candidates: &[String], +) -> Option<(&'a str, &'a str)> { + config + .cred_helpers + .iter() + .find(|(key, _)| registry_key_matches(key, candidates)) + .map(|(key, helper)| (key.as_str(), helper.as_str())) +} + +fn matching_docker_auth<'a>( + config: &'a DockerConfigFile, + candidates: &[String], +) -> Option<&'a DockerAuthEntry> { + config + .auths + .iter() + .find(|(key, _)| registry_key_matches(key, candidates)) + .map(|(_, entry)| entry) +} + +fn registry_key_matches(key: &str, candidates: &[String]) -> bool { + let key_norm = normalize_docker_server_key(key); + candidates + .iter() + .any(|candidate| key == candidate || key_norm == normalize_docker_server_key(candidate)) +} + +fn normalize_docker_server_key(value: &str) -> String { + let trimmed = value.trim().trim_end_matches('/'); + let without_scheme = trimmed + .strip_prefix("https://") + .or_else(|| trimmed.strip_prefix("http://")) + .unwrap_or(trimmed); + let without_v1 = without_scheme.strip_suffix("/v1").unwrap_or(without_scheme); + normalize_registry(without_v1) +} + +fn helper_server_candidates(matched_key: &str, registry: &str) -> Vec { + let mut candidates = vec![matched_key.to_string()]; + candidates.extend(docker_registry_candidates(registry)); + candidates.sort(); + candidates.dedup(); + candidates +} + +fn docker_auth_entry_credentials(entry: &DockerAuthEntry) -> Option<(String, String)> { + if let (Some(username), Some(password)) = (&entry.username, &entry.password) { + if !username.is_empty() && !password.is_empty() { + return Some((username.clone(), password.clone())); + } + } + + if let Some(auth) = entry.auth.as_deref() { + if let Some(creds) = decode_docker_auth(auth) { + return Some(creds); + } + } + + entry + .identity_token + .as_ref() + .filter(|token| !token.is_empty()) + .map(|token| ("oauth2accesstoken".to_string(), token.clone())) +} + +fn decode_docker_auth(auth: &str) -> Option<(String, String)> { + let decoded = base64::engine::general_purpose::STANDARD + .decode(auth.trim()) + .ok()?; + let text = String::from_utf8(decoded).ok()?; + let (username, password) = text.split_once(':')?; + if username.is_empty() || password.is_empty() { + return None; + } + Some((username.to_string(), password.to_string())) +} + +fn docker_credential_helper_get( + helper: &str, + server_candidates: &[String], +) -> Option<(String, String)> { + for server in server_candidates { + if let Some(creds) = docker_credential_helper_get_one(helper, server) { + return Some(creds); + } + } + None +} + +fn docker_credential_helper_get_one(helper: &str, server: &str) -> Option<(String, String)> { + let program = format!("docker-credential-{helper}"); + let mut child = std::process::Command::new(program) + .arg("get") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + + { + use std::io::Write as _; + let stdin = child.stdin.as_mut()?; + if stdin.write_all(server.as_bytes()).is_err() { + let _ = child.kill(); + return None; + } + } + + let output = child.wait_with_output().ok()?; + if !output.status.success() { + return None; + } + + #[derive(Deserialize)] + #[allow(non_snake_case)] + struct HelperResponse { + Username: String, + Secret: String, + } + + let response: HelperResponse = serde_json::from_slice(&output.stdout).ok()?; + if response.Username.is_empty() || response.Secret.is_empty() { + return None; + } + Some((response.Username, response.Secret)) +} + /// Normalize registry names (e.g., "docker.io" and "index.docker.io" → "index.docker.io"). fn normalize_registry(registry: &str) -> String { let r = registry.trim().to_lowercase(); @@ -191,12 +422,30 @@ impl a3s_box_core::traits::CredentialProvider for CredentialStore { #[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, OnceLock}; use tempfile::TempDir; fn test_store(dir: &TempDir) -> CredentialStore { CredentialStore::new(dir.path().join("credentials.json")) } + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn with_docker_config_dir(dir: &TempDir, f: impl FnOnce() -> R) -> R { + let _guard = env_lock(); + let previous = std::env::var_os("DOCKER_CONFIG"); + std::env::set_var("DOCKER_CONFIG", dir.path()); + let result = f(); + match previous { + Some(value) => std::env::set_var("DOCKER_CONFIG", value), + None => std::env::remove_var("DOCKER_CONFIG"), + } + result + } + // The advisory lock is per-open-file-description, so separate // FileLock::acquire calls serialize even across threads in one process — // which lets this exercise the lost-update fix in-process. @@ -312,6 +561,70 @@ mod tests { assert_eq!(creds, Some(("user".to_string(), "pass".to_string()))); } + #[test] + fn docker_credentials_reads_auths_for_host_port_registry() { + use base64::Engine as _; + + let dir = TempDir::new().unwrap(); + let auth = base64::engine::general_purpose::STANDARD.encode("user:pass"); + std::fs::write( + dir.path().join("config.json"), + format!( + r#"{{ + "auths": {{ + "10.12.111.133:49164": {{ "auth": "{auth}" }} + }} +}}"# + ), + ) + .unwrap(); + + let creds = with_docker_config_dir(&dir, || docker_credentials("10.12.111.133:49164")); + assert_eq!(creds, Some(("user".to_string(), "pass".to_string()))); + } + + #[test] + fn docker_credentials_matches_docker_hub_legacy_url() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("config.json"), + r#"{ + "auths": { + "https://index.docker.io/v1/": { + "username": "dock", + "password": "secret" + } + } +}"#, + ) + .unwrap(); + + let creds = with_docker_config_dir(&dir, || docker_credentials("docker.io")); + assert_eq!(creds, Some(("dock".to_string(), "secret".to_string()))); + } + + #[test] + fn docker_credentials_uses_identity_token_as_oauth_password() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("config.json"), + r#"{ + "auths": { + "registry.example.com": { + "identitytoken": "token-value" + } + } +}"#, + ) + .unwrap(); + + let creds = with_docker_config_dir(&dir, || docker_credentials("registry.example.com")); + assert_eq!( + creds, + Some(("oauth2accesstoken".to_string(), "token-value".to_string())) + ); + } + #[test] fn test_persistence() { let dir = TempDir::new().unwrap(); diff --git a/src/runtime/src/oci/layers.rs b/src/runtime/src/oci/layers.rs index 989c60aa..f24cb5a7 100644 --- a/src/runtime/src/oci/layers.rs +++ b/src/runtime/src/oci/layers.rs @@ -3,7 +3,12 @@ //! Handles extraction of OCI image layers (gzip, zstd, or uncompressed tar). use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::rootfs_metadata::{ + RootfsEntryKind, RootfsMetadataEntry, RootfsMetadataManifest, IMAGE_ROOTFS_METADATA_PATH, +}; +use base64::Engine; use flate2::read::GzDecoder; +use std::collections::BTreeMap; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; @@ -29,13 +34,25 @@ pub fn extract_layer(layer_path: &Path, target_dir: &Path) -> Result<()> { // Generous default; tune with A3S_BOX_MAX_LAYER_BYTES. let max_layer_bytes = super::limited_reader::cap_from_env("A3S_BOX_MAX_LAYER_BYTES", 16 * 1024 * 1024 * 1024); - extract_layer_with_cap(layer_path, target_dir, max_layer_bytes) + extract_layer_with_cap(layer_path, target_dir, max_layer_bytes, false) +} + +/// Extract a layer and retain the Linux ownership encoded in its tar headers. +/// +/// Rootless macOS extraction cannot apply arbitrary uid/gid values to APFS. +/// The generated rootfs-private manifest is replayed by guest-init before any +/// nested filesystems are mounted. +pub(crate) fn extract_layer_with_metadata(layer_path: &Path, target_dir: &Path) -> Result<()> { + let max_layer_bytes = + super::limited_reader::cap_from_env("A3S_BOX_MAX_LAYER_BYTES", 16 * 1024 * 1024 * 1024); + extract_layer_with_cap(layer_path, target_dir, max_layer_bytes, true) } fn extract_layer_with_cap( layer_path: &Path, target_dir: &Path, max_layer_bytes: u64, + track_metadata: bool, ) -> Result<()> { // Validate layer exists if !layer_path.exists() { @@ -121,6 +138,12 @@ fn extract_layer_with_cap( } } + let mut metadata = if track_metadata { + load_image_metadata(target_dir)? + } else { + BTreeMap::new() + }; + let entries = archive .entries() .map_err(|e| BoxError::OciImageError(format!("Failed to read layer entries: {e}")))?; @@ -142,6 +165,16 @@ fn extract_layer_with_cap( continue; } + let normalized = normalize_layer_path(&path).ok_or_else(|| { + BoxError::OciImageError(format!("Invalid layer entry path: {}", path.display())) + })?; + if track_metadata && normalized == image_metadata_relative_path() { + return Err(BoxError::OciImageError(format!( + "OCI layer contains reserved internal path {}", + IMAGE_ROOTFS_METADATA_PATH + ))); + } + let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if file_name == ".wh..wh..opq" { @@ -161,10 +194,30 @@ fn extract_layer_with_cap( tracing::warn!(parent = %parent.display(), "Skipping opaque whiteout: parent escapes the rootfs"); } } + if track_metadata { + let parent = normalize_layer_path(path.parent().unwrap_or_else(|| Path::new(""))) + .ok_or_else(|| { + BoxError::OciImageError("Invalid opaque whiteout path".to_string()) + })?; + remove_metadata_descendants(&mut metadata, &parent, false); + } continue; } if let Some(victim_name) = file_name.strip_prefix(".wh.") { + let victim = normalize_layer_path( + &path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(victim_name), + ) + .ok_or_else(|| BoxError::OciImageError("Invalid whiteout path".to_string()))?; + if track_metadata && victim == image_metadata_relative_path() { + return Err(BoxError::OciImageError(format!( + "OCI layer whiteouts reserved internal path {}", + IMAGE_ROOTFS_METADATA_PATH + ))); + } // Whiteout marker: remove the named sibling from a lower layer. Resolve // the parent within the rootfs so a symlinked parent cannot redirect the // deletion to a host file outside the extraction target. @@ -175,10 +228,25 @@ fn extract_layer_with_cap( tracing::warn!(parent = %parent.display(), "Skipping whiteout: parent escapes the rootfs"); } } + if track_metadata { + remove_metadata_descendants(&mut metadata, &victim, true); + } continue; } - entry.unpack_in(target_dir).map_err(|e| { + if entry.header().entry_type() == tar::EntryType::Symlink { + prepare_symlink_destination(target_dir, &path)?; + } else if entry.header().entry_type().is_hard_link() { + prepare_hardlink_destination(target_dir, &path)?; + } + reject_overlay_private_xattrs(&mut entry, &path)?; + + let desired = if track_metadata { + Some(metadata_from_header(&entry, &normalized)?) + } else { + None + }; + let unpacked = entry.unpack_in(target_dir).map_err(|e| { // Surface the underlying cause (e.g. the LimitedReader's size-cap // error) — tar's wrapper Display alone would just say "failed to // unpack " and hide a decompression-bomb abort from the operator. @@ -190,6 +258,18 @@ fn extract_layer_with_cap( target_dir.display(), )) })?; + if track_metadata && unpacked { + if let Some(desired) = desired { + if desired.kind != RootfsEntryKind::Directory { + remove_metadata_descendants(&mut metadata, &normalized, false); + } + metadata.insert(normalized, desired); + } + } + } + + if track_metadata { + finalize_image_metadata(target_dir, &mut metadata)?; } tracing::debug!( @@ -201,6 +281,418 @@ fn extract_layer_with_cap( Ok(()) } +#[cfg(unix)] +fn reject_overlay_private_xattrs( + entry: &mut tar::Entry<'_, R>, + path: &Path, +) -> Result<()> { + const PAX_XATTR_PREFIX: &[u8] = b"SCHILY.xattr."; + let Some(extensions) = entry.pax_extensions().map_err(|error| { + BoxError::OciImageError(format!( + "Failed to inspect extended attributes for {}: {error}", + path.display() + )) + })? + else { + return Ok(()); + }; + + for extension in extensions { + let extension = extension.map_err(|error| { + BoxError::OciImageError(format!( + "Invalid PAX extended attribute for {}: {error}", + path.display() + )) + })?; + let Some(name) = extension.key_bytes().strip_prefix(PAX_XATTR_PREFIX) else { + continue; + }; + if name.starts_with(b"trusted.overlay.") || name.starts_with(b"user.overlay.") { + return Err(BoxError::OciImageError(format!( + "OCI layer entry {} contains reserved overlayfs metadata", + path.display() + ))); + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn reject_overlay_private_xattrs( + _entry: &mut tar::Entry<'_, R>, + _path: &Path, +) -> Result<()> { + Ok(()) +} + +fn image_metadata_relative_path() -> PathBuf { + PathBuf::from(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')) +} + +fn normalize_layer_path(path: &Path) -> Option { + use std::path::Component; + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(name) => normalized.push(name), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, + } + } + Some(normalized) +} + +fn metadata_from_header( + entry: &tar::Entry<'_, R>, + path: &Path, +) -> Result { + let entry_type = entry.header().entry_type(); + let (kind, link_target_base64) = if entry_type.is_dir() { + (RootfsEntryKind::Directory, None) + } else if entry_type.is_symlink() { + let target = entry + .link_name() + .map_err(|error| BoxError::OciImageError(format!("Invalid symlink target: {error}")))? + .ok_or_else(|| BoxError::OciImageError("Missing symlink target".to_string()))?; + ( + RootfsEntryKind::Symlink, + Some( + base64::engine::general_purpose::STANDARD + .encode(target.as_os_str().as_encoded_bytes()), + ), + ) + } else if entry_type.is_file() || entry_type.is_hard_link() { + (RootfsEntryKind::Regular, None) + } else { + return Err(BoxError::OciImageError(format!( + "Unsupported OCI layer entry type at {}", + path.display() + ))); + }; + let path_base64 = base64::engine::general_purpose::STANDARD + .encode(archive_metadata_path(path).as_os_str().as_encoded_bytes()); + Ok(RootfsMetadataEntry { + path_base64, + kind, + mode: entry.header().mode().map_err(|error| { + BoxError::OciImageError(format!("Invalid mode at {}: {error}", path.display())) + })?, + uid: entry.header().uid().map_err(|error| { + BoxError::OciImageError(format!("Invalid uid at {}: {error}", path.display())) + })?, + gid: entry.header().gid().map_err(|error| { + BoxError::OciImageError(format!("Invalid gid at {}: {error}", path.display())) + })?, + mtime: entry.header().mtime().map_err(|error| { + BoxError::OciImageError(format!("Invalid mtime at {}: {error}", path.display())) + })?, + size: entry.header().size().map_err(|error| { + BoxError::OciImageError(format!("Invalid size at {}: {error}", path.display())) + })?, + link_target_base64, + }) +} + +fn archive_metadata_path(path: &Path) -> PathBuf { + if path.as_os_str().is_empty() { + PathBuf::from(".") + } else { + Path::new(".").join(path) + } +} + +fn remove_metadata_descendants( + metadata: &mut BTreeMap, + path: &Path, + include_path: bool, +) { + metadata.retain(|candidate, _| { + !(candidate.starts_with(path) && (include_path || candidate != path)) + }); +} + +fn load_image_metadata(target_dir: &Path) -> Result> { + let path = target_dir.join(image_metadata_relative_path()); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(error) => { + return Err(BoxError::OciImageError(format!( + "Failed to read image metadata {}: {error}", + path.display() + ))) + } + }; + let manifest: RootfsMetadataManifest = serde_json::from_slice(&bytes).map_err(|error| { + BoxError::OciImageError(format!( + "Invalid image metadata {}: {error}", + path.display() + )) + })?; + manifest.validate().map_err(BoxError::OciImageError)?; + let mut result = BTreeMap::new(); + for entry in manifest.entries { + let raw = base64::engine::general_purpose::STANDARD + .decode(&entry.path_base64) + .map_err(|error| BoxError::OciImageError(format!("Invalid metadata path: {error}")))?; + let archive_path = PathBuf::from(os_string_from_encoded_bytes(raw)); + let relative = normalize_layer_path(&archive_path) + .ok_or_else(|| BoxError::OciImageError("Unsafe path in image metadata".to_string()))?; + if relative == image_metadata_relative_path() || result.insert(relative, entry).is_some() { + return Err(BoxError::OciImageError( + "Duplicate or reserved path in image metadata".to_string(), + )); + } + } + Ok(result) +} + +pub(crate) fn finalize_rootfs_metadata(target_dir: &Path) -> Result<()> { + let mut metadata = load_image_metadata(target_dir)?; + finalize_image_metadata(target_dir, &mut metadata)?; + prepare_rootless_metadata_replay(target_dir, &metadata) +} + +fn prepare_rootless_metadata_replay( + target_dir: &Path, + metadata: &BTreeMap, +) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + if unsafe { libc::geteuid() } == 0 { + return Ok(()); + } + // virtiofs stores synthetic uid/gid as filesystem metadata. A guest + // cannot update it on a 0444/0555 host-owned inode, so make only the + // owner's write bit temporarily available. Guest-init restores the + // exact manifest mode before launching any container process. + for (relative, entry) in metadata { + if entry.kind == RootfsEntryKind::Symlink || entry.mode & 0o200 != 0 { + continue; + } + let target = target_dir.join(relative); + let current = std::fs::symlink_metadata(&target).map_err(|error| { + BoxError::OciImageError(format!( + "Failed to prepare metadata replay for {}: {error}", + target.display() + )) + })?; + std::fs::set_permissions( + &target, + std::fs::Permissions::from_mode((current.mode() & 0o7777) | 0o200), + ) + .map_err(|error| { + BoxError::OciImageError(format!( + "Failed to prepare metadata replay for {}: {error}", + target.display() + )) + })?; + } + } + #[cfg(not(unix))] + { + let _ = (target_dir, metadata); + } + Ok(()) +} + +fn finalize_image_metadata( + target_dir: &Path, + metadata: &mut BTreeMap, +) -> Result<()> { + let mut final_entries = BTreeMap::new(); + collect_final_metadata( + target_dir, + target_dir, + Path::new(""), + metadata, + &mut final_entries, + )?; + let manifest = RootfsMetadataManifest::new(final_entries.into_values().collect()); + let destination = target_dir.join(image_metadata_relative_path()); + let temporary = destination.with_extension("json.tmp"); + let bytes = serde_json::to_vec(&manifest).map_err(|error| { + BoxError::OciImageError(format!("Failed to encode image metadata: {error}")) + })?; + std::fs::write(&temporary, bytes).map_err(|error| { + BoxError::OciImageError(format!( + "Failed to write image metadata {}: {error}", + temporary.display() + )) + })?; + std::fs::rename(&temporary, &destination).map_err(|error| { + BoxError::OciImageError(format!( + "Failed to activate image metadata {}: {error}", + destination.display() + )) + })?; + *metadata = manifest + .entries + .into_iter() + .filter_map(|entry| decode_metadata_key(&entry).map(|key| (key, entry))) + .collect(); + Ok(()) +} + +fn decode_metadata_key(entry: &RootfsMetadataEntry) -> Option { + let raw = base64::engine::general_purpose::STANDARD + .decode(&entry.path_base64) + .ok()?; + normalize_layer_path(Path::new(&os_string_from_encoded_bytes(raw))) +} + +fn os_string_from_encoded_bytes(raw: Vec) -> std::ffi::OsString { + // Every metadata manifest is produced and consumed on the same host. The + // bytes therefore use this platform's `OsStr` encoding and can be restored + // losslessly, including non-UTF-8 Unix paths and Windows WTF-8 paths. + unsafe { std::ffi::OsString::from_encoded_bytes_unchecked(raw) } +} + +fn collect_final_metadata( + root: &Path, + source: &Path, + relative: &Path, + desired: &BTreeMap, + output: &mut BTreeMap, +) -> Result<()> { + if relative == image_metadata_relative_path() + || relative == Path::new(".a3s_image_metadata_v1.json.tmp") + { + return Ok(()); + } + let filesystem = std::fs::symlink_metadata(source).map_err(|error| { + BoxError::OciImageError(format!("Failed to inspect {}: {error}", source.display())) + })?; + let file_type = filesystem.file_type(); + let (kind, link_target_base64) = if file_type.is_dir() { + (RootfsEntryKind::Directory, None) + } else if file_type.is_file() { + (RootfsEntryKind::Regular, None) + } else if file_type.is_symlink() { + let target = std::fs::read_link(source).map_err(|error| { + BoxError::OciImageError(format!("Failed to read {}: {error}", source.display())) + })?; + ( + RootfsEntryKind::Symlink, + Some( + base64::engine::general_purpose::STANDARD + .encode(target.as_os_str().as_encoded_bytes()), + ), + ) + } else { + return Ok(()); + }; + let previous = desired.get(relative); + #[cfg(unix)] + let (mode, mtime, size) = { + use std::os::unix::fs::MetadataExt; + ( + filesystem.mode(), + filesystem.mtime().max(0) as u64, + filesystem.size(), + ) + }; + #[cfg(not(unix))] + let (mode, mtime, size) = previous + .map(|entry| (entry.mode, entry.mtime, entry.size)) + .unwrap_or_else(|| { + ( + if file_type.is_dir() { 0o755 } else { 0o644 }, + 0, + filesystem.len(), + ) + }); + let entry = RootfsMetadataEntry { + path_base64: base64::engine::general_purpose::STANDARD.encode( + archive_metadata_path(relative) + .as_os_str() + .as_encoded_bytes(), + ), + kind, + mode, + uid: previous.map_or(0, |entry| entry.uid), + gid: previous.map_or(0, |entry| entry.gid), + mtime, + size, + link_target_base64, + }; + output.insert(relative.to_path_buf(), entry); + if file_type.is_dir() { + let mut children: Vec<_> = std::fs::read_dir(source) + .map_err(|error| { + BoxError::OciImageError(format!("Failed to read {}: {error}", source.display())) + })? + .collect::>() + .map_err(|error| BoxError::OciImageError(format!("Failed to read entry: {error}")))?; + children.sort_by_key(|entry| entry.file_name()); + for child in children { + collect_final_metadata( + root, + &child.path(), + &relative.join(child.file_name()), + desired, + output, + )?; + } + } + let _ = root; + Ok(()) +} + +fn prepare_symlink_destination(target_dir: &Path, path: &Path) -> Result<()> { + let Some(name) = path.file_name() else { + return Ok(()); + }; + let parent = path.parent().unwrap_or_else(|| Path::new("")); + let Some(parent) = resolve_within_or_base(target_dir, parent) else { + tracing::warn!(parent = %parent.display(), "Skipping symlink destination preparation: parent escapes the rootfs"); + return Ok(()); + }; + let candidate = parent.join(name); + let Ok(metadata) = std::fs::symlink_metadata(&candidate) else { + return Ok(()); + }; + if metadata.is_dir() { + std::fs::remove_dir_all(&candidate).map_err(|e| { + BoxError::OciImageError(format!( + "Failed to replace directory {} with symlink from layer: {}", + candidate.display(), + e + )) + })?; + } + Ok(()) +} + +fn prepare_hardlink_destination(target_dir: &Path, path: &Path) -> Result<()> { + let Some(name) = path.file_name() else { + return Ok(()); + }; + let parent = path.parent().unwrap_or_else(|| Path::new("")); + let Some(parent) = resolve_within_or_base(target_dir, parent) else { + tracing::warn!(parent = %parent.display(), "Skipping hardlink destination preparation: parent escapes the rootfs"); + return Ok(()); + }; + let candidate = parent.join(name); + let Ok(metadata) = std::fs::symlink_metadata(&candidate) else { + return Ok(()); + }; + let result = if metadata.is_dir() { + std::fs::remove_dir_all(&candidate) + } else { + std::fs::remove_file(&candidate) + }; + result.map_err(|error| { + BoxError::OciImageError(format!( + "Failed to replace {} with hardlink from layer: {error}", + candidate.display() + )) + }) +} + /// Resolve `rel` beneath `target_dir`, following symlinks, returning the real /// path ONLY if it stays inside `target_dir`. /// @@ -212,7 +704,17 @@ fn extract_layer_with_cap( /// are allowed — the image may already mutate its own files; only escapes past /// `target_dir` are blocked. fn resolve_within(target_dir: &Path, rel: &Path) -> Option { + if rel.as_os_str().is_empty() { + return target_dir.canonicalize().ok(); + } + resolve_within_or_base(target_dir, rel) +} + +fn resolve_within_or_base(target_dir: &Path, rel: &Path) -> Option { let base = target_dir.canonicalize().ok()?; + if rel.as_os_str().is_empty() { + return Some(base); + } let resolved = base.join(rel).canonicalize().ok()?; resolved.starts_with(&base).then_some(resolved) } @@ -342,6 +844,41 @@ mod tests { assert_eq!(content2, "version 2"); } + #[test] + fn test_extract_layer_overwrites_existing_hardlink_destination() { + let temp_dir = TempDir::new().unwrap(); + let layer1_path = temp_dir.path().join("layer1.tar.gz"); + let layer2_path = temp_dir.path().join("layer2.tar.gz"); + let target_dir = temp_dir.path().join("extracted"); + + create_test_layer( + &layer1_path, + &[ + ("usr/bin/perl", b"current interpreter"), + ("usr/bin/perl5.38.2", b"stale interpreter"), + ], + ); + create_hardlink_test_layer(&layer2_path, "usr/bin/perl5.38.2", "usr/bin/perl"); + + extract_layer(&layer1_path, &target_dir).unwrap(); + extract_layer(&layer2_path, &target_dir).unwrap(); + + assert_eq!( + fs::read(target_dir.join("usr/bin/perl5.38.2")).unwrap(), + b"current interpreter" + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!( + fs::metadata(target_dir.join("usr/bin/perl")).unwrap().ino(), + fs::metadata(target_dir.join("usr/bin/perl5.38.2")) + .unwrap() + .ino(), + ); + } + } + #[test] fn test_extract_layer_applies_whiteout() { let temp_dir = TempDir::new().unwrap(); @@ -391,6 +928,73 @@ mod tests { assert!(!target.join("d/.wh..wh..opq").exists()); } + #[test] + fn tracked_metadata_preserves_header_ownership_and_whiteouts() { + let temp_dir = TempDir::new().unwrap(); + let layer1 = temp_dir.path().join("metadata-1.tar.gz"); + let layer2 = temp_dir.path().join("metadata-2.tar.gz"); + let target = temp_dir.path().join("rootfs"); + create_owned_test_layer(&layer1, "dir/owned", b"payload", 123, 456, 0o750); + create_test_layer(&layer2, &[("dir/.wh.owned", b"")]); + + extract_layer_with_metadata(&layer1, &target).unwrap(); + let manifest = read_image_manifest(&target); + let owned = manifest + .entries + .iter() + .find(|entry| { + base64::engine::general_purpose::STANDARD + .decode(&entry.path_base64) + .is_ok_and(|raw| raw == b"./dir/owned") + }) + .unwrap(); + assert_eq!( + (owned.uid, owned.gid, owned.mode & 0o7777), + (123, 456, 0o750) + ); + + extract_layer_with_metadata(&layer2, &target).unwrap(); + let manifest = read_image_manifest(&target); + assert!(!manifest.entries.iter().any(|entry| { + base64::engine::general_purpose::STANDARD + .decode(&entry.path_base64) + .is_ok_and(|raw| raw.ends_with(b"dir/owned")) + })); + } + + #[test] + fn tracked_metadata_rejects_reserved_image_path() { + let temp_dir = TempDir::new().unwrap(); + let layer = temp_dir.path().join("reserved.tar.gz"); + let target = temp_dir.path().join("rootfs"); + create_test_layer(&layer, &[(".a3s_image_metadata_v1.json", b"forged")]); + + let error = extract_layer_with_metadata(&layer, &target).unwrap_err(); + assert!(error.to_string().contains("reserved internal path")); + } + + #[cfg(unix)] + #[test] + fn extraction_rejects_overlayfs_private_xattrs() { + let temp_dir = TempDir::new().unwrap(); + for (index, xattr) in ["trusted.overlay.metacopy", "user.overlay.redirect"] + .into_iter() + .enumerate() + { + let layer = temp_dir + .path() + .join(format!("overlay-xattr-{index}.tar.gz")); + let target = temp_dir.path().join(format!("rootfs-{index}")); + create_overlay_xattr_test_layer(&layer, xattr); + + let error = extract_layer_with_metadata(&layer, &target).unwrap_err(); + assert!(error + .to_string() + .contains("contains reserved overlayfs metadata")); + assert!(!target.join("payload").exists()); + } + } + #[test] fn extract_layer_rejects_decompression_bomb_past_cap() { let temp_dir = TempDir::new().unwrap(); @@ -402,7 +1006,7 @@ mod tests { create_test_layer(&layer, &[("big", &big)]); // A 4 KiB cap must abort the extraction... - let result = extract_layer_with_cap(&layer, &target, 4 * 1024); + let result = extract_layer_with_cap(&layer, &target, 4 * 1024, false); assert!( result.is_err(), "the cap must abort an oversized (bomb) layer, got: {result:?}" @@ -424,7 +1028,7 @@ mod tests { let target = temp_dir.path().join("out"); create_test_layer(&layer, &[("file.txt", b"hello")]); // A generous cap must not regress a normal small layer. - extract_layer_with_cap(&layer, &target, 16 * 1024 * 1024).unwrap(); + extract_layer_with_cap(&layer, &target, 16 * 1024 * 1024, false).unwrap(); assert!(target.join("file.txt").exists()); } @@ -456,6 +1060,80 @@ mod tests { builder.finish().unwrap(); } + fn create_owned_test_layer( + path: &Path, + name: &str, + content: &[u8], + uid: u64, + gid: u64, + mode: u32, + ) { + use flate2::write::GzEncoder; + use flate2::Compression; + use tar::Builder; + + let file = File::create(path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(mode); + header.set_uid(uid); + header.set_gid(gid); + header.set_cksum(); + builder.append_data(&mut header, name, content).unwrap(); + builder.finish().unwrap(); + } + + #[cfg(unix)] + fn create_overlay_xattr_test_layer(path: &Path, xattr: &str) { + use flate2::write::GzEncoder; + use flate2::Compression; + use tar::Builder; + + let file = File::create(path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + let key = format!("SCHILY.xattr.{xattr}"); + builder + .append_pax_extensions([(key.as_str(), b"".as_slice())]) + .unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_size(7); + header.set_mode(0o644); + header.set_uid(0); + header.set_gid(0); + header.set_cksum(); + builder + .append_data(&mut header, "payload", b"payload".as_slice()) + .unwrap(); + builder.finish().unwrap(); + } + + fn create_hardlink_test_layer(path: &Path, name: &str, target: &str) { + use flate2::write::GzEncoder; + use flate2::Compression; + use tar::Builder; + + let file = File::create(path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Link); + header.set_size(0); + header.set_mode(0o755); + header.set_uid(0); + header.set_gid(0); + builder.append_link(&mut header, name, target).unwrap(); + builder.finish().unwrap(); + } + + fn read_image_manifest(target: &Path) -> RootfsMetadataManifest { + let bytes = + std::fs::read(target.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'))).unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + fn write_test_tar(writer: W, files: &[(&str, &[u8])]) { use tar::Builder; let mut builder = Builder::new(writer); diff --git a/src/runtime/src/oci/mod.rs b/src/runtime/src/oci/mod.rs index fdf4d48d..7f7f91ca 100644 --- a/src/runtime/src/oci/mod.rs +++ b/src/runtime/src/oci/mod.rs @@ -38,13 +38,13 @@ pub mod signing; pub mod store; #[cfg(feature = "build")] -pub use build::{BuildConfig, BuildResult, Dockerfile, Instruction}; +pub use build::{BuildConfig, BuildResult, BuildRunPoolConfig, Dockerfile, Instruction}; pub use credentials::CredentialStore; pub use image::{OciHealthCheck, OciImage, OciImageConfig}; pub use layers::extract_layer; pub use pull::ImagePuller; pub use reference::ImageReference; -pub use registry::{PushResult, RegistryAuth, RegistryPusher}; +pub use registry::{PushResult, RegistryAuth, RegistryProtocol, RegistryPusher}; pub use rootfs::OciRootfsBuilder; pub use signing::{SignResult, SignaturePolicy, VerifyResult}; pub use store::ImageStore; diff --git a/src/runtime/src/oci/pull.rs b/src/runtime/src/oci/pull.rs index f1066e01..5d234b65 100644 --- a/src/runtime/src/oci/pull.rs +++ b/src/runtime/src/oci/pull.rs @@ -54,6 +54,16 @@ impl ImagePuller { Self::with_platform(store, auth, None) } + #[cfg(test)] + pub(crate) fn with_registry_puller(store: Arc, puller: RegistryPuller) -> Self { + Self { + store, + puller, + metrics: None, + mirrors: std::collections::HashMap::new(), + } + } + /// Create an image puller that resolves multi-arch images to an explicit /// platform (e.g. "linux/arm64") instead of the host architecture. `None` /// keeps the host-architecture default. diff --git a/src/runtime/src/oci/registry.rs b/src/runtime/src/oci/registry.rs index fbf35c52..36f5a3ea 100644 --- a/src/runtime/src/oci/registry.rs +++ b/src/runtime/src/oci/registry.rs @@ -8,20 +8,57 @@ use std::sync::Arc; use a3s_box_core::error::{BoxError, Result}; use oci_distribution::client::{ClientConfig, ClientProtocol, Config, ImageLayer, PushResponse}; -use oci_distribution::manifest::{ImageIndexEntry, OciImageManifest}; +use oci_distribution::errors::{OciDistributionError, OciErrorCode}; +use oci_distribution::manifest::{ImageIndexEntry, OciImageManifest, OCI_IMAGE_MEDIA_TYPE}; use oci_distribution::secrets::RegistryAuth as OciRegistryAuth; use oci_distribution::{Client, Reference}; +use oci_reqwest::header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, LOCATION}; use super::credentials::CredentialStore; use super::reference::ImageReference; use super::signing::{verify_image_signature, SignaturePolicy, VerifyResult}; +mod basic_pull; +mod blob_pull; + +use basic_pull::{BasicImageManifest, BasicPullClient}; +#[cfg(test)] +use blob_pull::HashingFileWriter; +use blob_pull::{stream_and_verify_blob, BlobPullTransport}; + const REGISTRY_PROTOCOL_ENV: &str = "A3S_REGISTRY_PROTOCOL"; +const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json"; + +/// Transport protocol used for registry operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistryProtocol { + /// Use HTTPS and verify TLS certificates. + Https, + /// Use plain HTTP for trusted private registries. + Http, +} -fn registry_protocol_from_env() -> ClientProtocol { - match std::env::var(REGISTRY_PROTOCOL_ENV) { - Ok(value) if value.eq_ignore_ascii_case("http") => ClientProtocol::Http, - _ => ClientProtocol::Https, +impl RegistryProtocol { + /// Return the default protocol, honoring the legacy environment override. + pub fn from_env() -> Self { + match std::env::var(REGISTRY_PROTOCOL_ENV) { + Ok(value) if value.eq_ignore_ascii_case("http") => Self::Http, + _ => Self::Https, + } + } + + fn client_protocol(self) -> ClientProtocol { + match self { + Self::Https => ClientProtocol::Https, + Self::Http => ClientProtocol::Http, + } + } + + fn scheme(self) -> &'static str { + match self { + Self::Https => "https", + Self::Http => "http", + } } } @@ -48,136 +85,16 @@ pub(crate) fn validated_digest_hex(digest: &str) -> Result<&str> { }) } -/// An `AsyncWrite` that streams bytes straight to a file while computing their -/// SHA-256, so a pulled blob is hashed and written in bounded chunks instead of -/// being fully buffered in memory. -struct HashingFileWriter { - file: tokio::fs::File, - hasher: sha2::Sha256, -} - -impl HashingFileWriter { - fn new(file: tokio::fs::File) -> Self { - use sha2::Digest; - Self { - file, - hasher: sha2::Sha256::new(), - } - } - - fn finalize_hex(self) -> String { - use sha2::Digest; - format!("{:x}", self.hasher.finalize()) - } -} - -impl tokio::io::AsyncWrite for HashingFileWriter { - fn poll_write( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &[u8], - ) -> std::task::Poll> { - use sha2::Digest; - let this = self.get_mut(); - match std::pin::Pin::new(&mut this.file).poll_write(cx, buf) { - std::task::Poll::Ready(Ok(n)) => { - this.hasher.update(&buf[..n]); - std::task::Poll::Ready(Ok(n)) - } - other => other, - } - } - - fn poll_flush( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.get_mut().file).poll_flush(cx) - } - - fn poll_shutdown( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.get_mut().file).poll_shutdown(cx) - } -} - -/// Stream a blob to `dest`, verifying its SHA-256 against `descriptor.digest` -/// as it downloads. The blob is written to a `.partial` temp file and only -/// renamed into place once the digest checks out, so a failed/corrupted pull -/// never leaves a bad blob under its content-addressed name. A blob whose digest -/// uses an unsupported algorithm (anything but sha256) is rejected rather than -/// stored unverified. -async fn stream_and_verify_blob( - client: &Client, - oci_ref: &Reference, - descriptor: &oci_distribution::manifest::OciDescriptor, - dest: &Path, - what: &str, - registry: &str, -) -> Result<()> { - use tokio::io::AsyncWriteExt; - - let tmp = dest.with_extension("partial"); - let file = tokio::fs::File::create(&tmp) - .await - .map_err(|e| BoxError::RegistryError { - registry: registry.to_string(), - message: format!("Failed to create {what} file: {e}"), - })?; - let mut writer = HashingFileWriter::new(file); - - if let Err(e) = client.pull_blob(oci_ref, descriptor, &mut writer).await { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(BoxError::RegistryError { - registry: registry.to_string(), - message: format!("Failed to pull {what}: {e}"), - }); - } - let _ = writer.flush().await; - let _ = writer.shutdown().await; - let actual_hex = writer.finalize_hex(); - - match descriptor.digest.strip_prefix("sha256:") { - Some(expected_hex) if actual_hex.eq_ignore_ascii_case(expected_hex) => {} - Some(expected_hex) => { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(BoxError::RegistryError { - registry: registry.to_string(), - message: format!( - "{what} digest mismatch: expected sha256:{expected_hex}, computed sha256:{actual_hex}" - ), - }); - } - None => { - // We can only verify sha256. Refuse to store a blob whose digest - // algorithm we cannot check rather than silently trust the registry's - // bytes — otherwise a malicious/MITM registry could serve arbitrary - // content under a sha512:/unknown digest and have it reach the rootfs. - let _ = tokio::fs::remove_file(&tmp).await; - return Err(BoxError::RegistryError { - registry: registry.to_string(), - message: format!( - "{what} uses an unsupported digest algorithm ({}); refusing to store \ - unverifiable content (only sha256 is supported)", - descriptor.digest - ), - }); - } - } - - tokio::fs::rename(&tmp, dest) - .await - .map_err(|e| BoxError::RegistryError { - registry: registry.to_string(), - message: format!("Failed to store {what} blob: {e}"), - }) -} - /// Callback type for layer pull progress: `(current, total, digest, size_bytes)`. type PullProgressFn = Arc; +struct PulledImageManifest { + manifest: OciImageManifest, + digest: String, + bytes: Option>, + used_basic: bool, +} + /// Authentication credentials for a container registry. #[derive(Debug, Clone)] pub struct RegistryAuth { @@ -220,13 +137,39 @@ impl RegistryAuth { /// Create authentication from the credential store, falling back to env vars, /// then anonymous. pub fn from_credential_store(registry: &str) -> Self { - // Try credential store first if let Ok(store) = CredentialStore::default_path() { - if let Ok(Some((username, password))) = store.get(registry) { - return Self::basic(username, password); + if let Some(auth) = Self::from_store(&store, registry) { + return auth; } } - // Fall back to env vars, then anonymous + Self::from_external_sources(registry) + } + + /// Create authentication from an explicit A3S home credential store. + /// + /// Runtime services can own a home directory without mutating the process + /// `A3S_HOME`. Registry-specific A3S credentials still take precedence over + /// supported Docker credentials and environment fallback. + pub fn from_credential_store_at(home_dir: &Path, registry: &str) -> Self { + let store = CredentialStore::new(home_dir.join("auth").join("credentials.json")); + if let Some(auth) = Self::from_store(&store, registry) { + return auth; + } + Self::from_external_sources(registry) + } + + fn from_store(store: &CredentialStore, registry: &str) -> Option { + store + .get(registry) + .ok() + .flatten() + .map(|(username, password)| Self::basic(username, password)) + } + + fn from_external_sources(registry: &str) -> Self { + if let Some((username, password)) = super::credentials::docker_credentials(registry) { + return Self::basic(username, password); + } Self::from_env() } @@ -237,12 +180,24 @@ impl RegistryAuth { _ => OciRegistryAuth::Anonymous, } } + + /// Return basic credentials when this auth value is not anonymous. + pub fn basic_credentials(&self) -> Option<(String, String)> { + match (&self.username, &self.password) { + (Some(username), Some(password)) if !username.is_empty() && !password.is_empty() => { + Some((username.clone(), password.clone())) + } + _ => None, + } + } } /// Pulls OCI images from container registries. pub(crate) struct RegistryPuller { client: Client, auth: RegistryAuth, + protocol: RegistryProtocol, + target_arch: String, /// Signature verification policy (default: Skip). signature_policy: SignaturePolicy, /// Optional layer progress callback: (current, total, digest, size_bytes). @@ -263,19 +218,11 @@ impl RegistryPuller { /// Create a new registry puller with the given authentication. pub fn with_auth(auth: RegistryAuth) -> Self { - let config = ClientConfig { - protocol: registry_protocol_from_env(), - platform_resolver: Some(Box::new(linux_platform_resolver)), - ..Default::default() - }; - let client = Client::new(config); - - Self { - client, + Self::with_auth_arch_and_protocol( auth, - signature_policy: SignaturePolicy::default(), - progress_fn: None, - } + resolve_target_arch(None), + RegistryProtocol::from_env(), + ) } /// Like [`with_auth`](Self::with_auth) but resolves multi-arch image indexes @@ -286,9 +233,17 @@ impl RegistryPuller { return Self::with_auth(auth); }; let arch = resolve_target_arch(Some(&platform)); + Self::with_auth_arch_and_protocol(auth, arch, RegistryProtocol::from_env()) + } + + fn with_auth_arch_and_protocol( + auth: RegistryAuth, + target_arch: String, + protocol: RegistryProtocol, + ) -> Self { let config = ClientConfig { - protocol: registry_protocol_from_env(), - platform_resolver: Some(Box::new(platform_resolver_for(arch))), + protocol: protocol.client_protocol(), + platform_resolver: Some(Box::new(platform_resolver_for(target_arch.clone()))), ..Default::default() }; let client = Client::new(config); @@ -296,6 +251,8 @@ impl RegistryPuller { Self { client, auth, + protocol, + target_arch, signature_policy: SignaturePolicy::default(), progress_fn: None, } @@ -336,15 +293,11 @@ impl RegistryPuller { })?; // Pull manifest (resolves multi-arch image indexes to current platform) - let auth = self.auth.to_oci_auth(); - let (image_manifest, manifest_digest) = self - .client - .pull_image_manifest(&oci_ref, &auth) - .await - .map_err(|e| BoxError::RegistryError { - registry: reference.registry.clone(), - message: format!("Failed to pull manifest: {}", e), - })?; + let pulled_manifest = self + .pull_image_manifest_with_auth_fallback(reference, &oci_ref) + .await?; + let image_manifest = pulled_manifest.manifest; + let manifest_digest = pulled_manifest.digest; // Verify image signature before downloading layers let verify_result = verify_image_signature( @@ -379,7 +332,9 @@ impl RegistryPuller { // the Docker-Content-Digest header verbatim, and feeding `sha256:../../x` // into blobs_dir.join() would write the (attacker-shaped) manifest JSON to // an arbitrary host path outside the store. - let manifest_json = serde_json::to_vec(&image_manifest)?; + let manifest_json = pulled_manifest + .bytes + .unwrap_or(serde_json::to_vec(&image_manifest)?); let manifest_digest_hex = validated_digest_hex(&manifest_digest)?; std::fs::write(blobs_dir.join(manifest_digest_hex), &manifest_json).map_err(|e| { BoxError::RegistryError { @@ -389,8 +344,14 @@ impl RegistryPuller { })?; // Pull image config and layers - self.pull_image_content(&oci_ref, &image_manifest, &blobs_dir, &reference.registry) - .await?; + self.pull_image_content( + reference, + &oci_ref, + &image_manifest, + &blobs_dir, + pulled_manifest.used_basic, + ) + .await?; // Write oci-layout file std::fs::write( @@ -434,43 +395,165 @@ impl RegistryPuller { let oci_ref = self.to_oci_reference(reference)?; let auth = self.auth.to_oci_auth(); - let (_manifest, digest) = - self.client - .pull_manifest(&oci_ref, &auth) - .await - .map_err(|e| BoxError::RegistryError { - registry: reference.registry.clone(), - message: format!("Failed to pull manifest: {}", e), - })?; + match self.client.pull_manifest(&oci_ref, &auth).await { + Ok((_manifest, digest)) => { + validated_digest_hex(&digest)?; + Ok(digest) + } + Err(first_error) + if is_unauthorized_registry_error(&first_error) + && self.auth.basic_credentials().is_some() => + { + tracing::warn!( + reference = %reference, + error = %registry_error_summary(&first_error, &self.auth), + "Registry rejected the default OCI manifest auth flow; retrying with preemptive Basic auth" + ); + let basic_client = self + .basic_pull_client(reference) + .map_err(|fallback_error| { + self.combined_pull_error(reference, &first_error, &fallback_error) + })?; + basic_client + .pull_manifest_digest(reference) + .await + .map_err(|fallback_error| { + self.combined_pull_error(reference, &first_error, &fallback_error) + }) + } + Err(error) => Err(self.pull_error(reference, &error)), + } + } - Ok(digest) + async fn pull_image_manifest_with_auth_fallback( + &self, + reference: &ImageReference, + oci_ref: &Reference, + ) -> Result { + let auth = self.auth.to_oci_auth(); + match self.client.pull_image_manifest(oci_ref, &auth).await { + Ok((manifest, digest)) => Ok(PulledImageManifest { + manifest, + digest, + bytes: None, + used_basic: false, + }), + Err(first_error) + if is_unauthorized_registry_error(&first_error) + && self.auth.basic_credentials().is_some() => + { + tracing::warn!( + reference = %reference, + error = %registry_error_summary(&first_error, &self.auth), + "Registry rejected the default OCI manifest auth flow; retrying with preemptive Basic auth" + ); + let basic_client = self + .basic_pull_client(reference) + .map_err(|fallback_error| { + self.combined_pull_error(reference, &first_error, &fallback_error) + })?; + let BasicImageManifest { + manifest, + digest, + bytes, + } = basic_client.pull_image_manifest(reference).await.map_err( + |fallback_error| { + self.combined_pull_error(reference, &first_error, &fallback_error) + }, + )?; + Ok(PulledImageManifest { + manifest, + digest, + bytes: Some(bytes), + used_basic: true, + }) + } + Err(error) => Err(self.pull_error(reference, &error)), + } + } + + fn basic_pull_client( + &self, + reference: &ImageReference, + ) -> std::result::Result { + BasicPullClient::new( + self.protocol, + reference, + &self.auth, + self.target_arch.clone(), + ) + } + + fn pull_error(&self, reference: &ImageReference, error: &OciDistributionError) -> BoxError { + BoxError::RegistryError { + registry: reference.registry.clone(), + message: format!( + "Failed to pull manifest: {}", + registry_error_summary(error, &self.auth) + ), + } + } + + fn combined_pull_error( + &self, + reference: &ImageReference, + first_error: &OciDistributionError, + fallback_error: &OciDistributionError, + ) -> BoxError { + BoxError::RegistryError { + registry: reference.registry.clone(), + message: format!( + "Failed to pull manifest: default auth failed: {}; preemptive Basic retry failed: {}", + registry_error_summary(first_error, &self.auth), + registry_error_summary(fallback_error, &self.auth) + ), + } } /// Pull config and layers for an image manifest, writing blobs to disk. async fn pull_image_content( &self, + reference: &ImageReference, oci_ref: &Reference, manifest: &OciImageManifest, blobs_dir: &Path, - registry: &str, + force_basic: bool, ) -> Result<()> { + let basic_client = if self.auth.basic_credentials().is_some() { + Some( + self.basic_pull_client(reference) + .map_err(|error| BoxError::RegistryError { + registry: reference.registry.clone(), + message: format!( + "Failed to prepare authenticated blob pull: {}", + registry_error_summary(&error, &self.auth) + ), + })?, + ) + } else { + None + }; + let transport = BlobPullTransport { + client: &self.client, + oci_ref, + basic_client: basic_client.as_ref(), + force_basic, + auth: &self.auth, + registry: &reference.registry, + }; + // Stream the config blob to disk, verifying its digest on the fly. // pull_blob delivers raw bytes without validation, so the streaming // hasher both bounds memory and guards against a buggy/malicious // registry or a corrupted transfer being stored content-addressed and // later extracted into the guest. let config_descriptor = &manifest.config; - let config_digest_hex = config_descriptor - .digest - .strip_prefix("sha256:") - .unwrap_or(&config_descriptor.digest); + let config_digest_hex = validated_digest_hex(&config_descriptor.digest)?; stream_and_verify_blob( - &self.client, - oci_ref, + &transport, config_descriptor, &blobs_dir.join(config_digest_hex), "config blob", - registry, ) .await?; @@ -487,17 +570,12 @@ impl RegistryPuller { f(idx + 1, total, &layer.digest, layer.size); } - let layer_digest_hex = layer - .digest - .strip_prefix("sha256:") - .unwrap_or(&layer.digest); + let layer_digest_hex = validated_digest_hex(&layer.digest)?; stream_and_verify_blob( - &self.client, - oci_ref, + &transport, layer, &blobs_dir.join(layer_digest_hex), "layer", - registry, ) .await?; @@ -537,10 +615,20 @@ pub struct PushResult { pub manifest_digest: String, } +struct PushUpload<'a> { + oci_ref: &'a Reference, + layers: &'a [ImageLayer], + config: &'a Config, + manifest: &'a OciImageManifest, + manifest_data: &'a [u8], + expected_manifest_digest: &'a str, +} + /// Pushes OCI images to container registries. pub struct RegistryPusher { client: Client, auth: RegistryAuth, + protocol: RegistryProtocol, } impl Default for RegistryPusher { @@ -557,12 +645,21 @@ impl RegistryPusher { /// Create a new registry pusher with the given authentication. pub fn with_auth(auth: RegistryAuth) -> Self { + Self::with_auth_and_protocol(auth, RegistryProtocol::from_env()) + } + + /// Create a new registry pusher with explicit authentication and protocol. + pub fn with_auth_and_protocol(auth: RegistryAuth, protocol: RegistryProtocol) -> Self { let config = ClientConfig { - protocol: registry_protocol_from_env(), + protocol: protocol.client_protocol(), ..Default::default() }; let client = Client::new(config); - Self { client, auth } + Self { + client, + auth, + protocol, + } } /// Push a local OCI image layout to a registry. @@ -634,16 +731,19 @@ impl RegistryPusher { )); } - // Push to registry - let auth = self.auth.to_oci_auth(); - let response: PushResponse = self - .client - .push(&oci_ref, &layers, config, &auth, Some(manifest)) - .await - .map_err(|e| BoxError::RegistryError { - registry: reference.registry.clone(), - message: format!("Failed to push image: {}", e), - })?; + let response = self + .push_with_repository_create_retry( + reference, + PushUpload { + oci_ref: &oci_ref, + layers: &layers, + config: &config, + manifest: &manifest, + manifest_data: &manifest_data, + expected_manifest_digest: manifest_digest, + }, + ) + .await?; tracing::info!( reference = %reference, @@ -670,6 +770,531 @@ impl RegistryPusher { BoxError::OciImageError(format!("Invalid OCI reference '{}': {}", ref_str, e)) }) } + + async fn push_with_repository_create_retry( + &self, + reference: &ImageReference, + upload: PushUpload<'_>, + ) -> Result { + let response = match self + .push_once( + reference, + upload.oci_ref, + upload.layers, + upload.config, + upload.manifest, + upload.manifest_data, + ) + .await + { + Ok(response) => Ok(response), + Err(first_error) if is_repository_already_exists_push_error(&first_error) => { + tracing::warn!( + reference = %reference, + error = %push_error_summary(&first_error), + "Registry reported repository already exists during push; retrying once" + ); + self.push_once( + reference, + upload.oci_ref, + upload.layers, + upload.config, + upload.manifest, + upload.manifest_data, + ) + .await + .map_err(|retry_error| BoxError::RegistryError { + registry: reference.registry.clone(), + message: format!( + "Failed to push image after retrying repository creation race: first error: {}; retry error: {}", + push_error_summary(&first_error), + push_error_summary(&retry_error) + ), + }) + } + Err(error) => Err(BoxError::RegistryError { + registry: reference.registry.clone(), + message: format!("Failed to push image: {}", push_error_summary(&error)), + }), + }?; + + self.verify_pushed_manifest(reference, upload.oci_ref, upload.expected_manifest_digest) + .await?; + Ok(response) + } + + async fn push_once( + &self, + reference: &ImageReference, + oci_ref: &Reference, + layers: &[ImageLayer], + config: &Config, + manifest: &OciImageManifest, + manifest_data: &[u8], + ) -> std::result::Result { + let auth = self.auth.to_oci_auth(); + match self + .client + .push( + oci_ref, + layers, + config.clone(), + &auth, + Some(manifest.clone()), + ) + .await + { + Err(first_error) + if is_unauthorized_registry_error(&first_error) + && self.auth.basic_credentials().is_some() => + { + tracing::warn!( + reference = %reference, + error = %push_error_summary(&first_error), + "Registry rejected the default OCI push auth flow; retrying with preemptive Basic auth" + ); + self.push_with_preemptive_basic_auth( + reference, + layers, + config, + manifest, + manifest_data, + ) + .await + .map_err(|fallback_error| { + OciDistributionError::GenericError(Some(format!( + "default push auth failed: {}; preemptive Basic auth retry failed: {}", + push_error_summary(&first_error), + push_error_summary(&fallback_error) + ))) + }) + } + result => result, + } + } + + async fn push_with_preemptive_basic_auth( + &self, + reference: &ImageReference, + layers: &[ImageLayer], + config: &Config, + manifest: &OciImageManifest, + manifest_data: &[u8], + ) -> std::result::Result { + let (username, password) = self.auth.basic_credentials().ok_or_else(|| { + OciDistributionError::GenericError(Some( + "preemptive Basic auth retry requires non-empty credentials".to_string(), + )) + })?; + let http = oci_reqwest::Client::new(); + let base = registry_base_url(self.protocol, reference)?; + + for (layer, descriptor) in layers.iter().zip(&manifest.layers) { + push_blob_with_basic_auth( + &http, + &base, + &reference.repository, + &username, + &password, + &descriptor.digest, + &layer.data, + ) + .await?; + } + + let config_url = push_blob_with_basic_auth( + &http, + &base, + &reference.repository, + &username, + &password, + &manifest.config.digest, + &config.data, + ) + .await?; + + let manifest_ref = reference + .tag + .as_deref() + .or(reference.digest.as_deref()) + .unwrap_or("latest"); + let manifest_url = registry_manifest_url(&base, &reference.repository, manifest_ref)?; + let media_type = manifest + .media_type + .as_deref() + .unwrap_or(OCI_IMAGE_MEDIA_TYPE); + let response = http + .put(manifest_url.clone()) + .basic_auth(&username, Some(&password)) + .header(CONTENT_TYPE, media_type) + .body(manifest_data.to_vec()) + .send() + .await?; + let response = ensure_registry_status( + response, + &[ + oci_reqwest::StatusCode::CREATED, + oci_reqwest::StatusCode::OK, + ], + manifest_url.as_str(), + ) + .await?; + let manifest_url = response_location_or_url(&response, &manifest_url)?; + + Ok(PushResponse { + config_url, + manifest_url, + }) + } + + async fn verify_pushed_manifest( + &self, + reference: &ImageReference, + oci_ref: &Reference, + expected_digest: &str, + ) -> Result<()> { + let auth = self.auth.to_oci_auth(); + match self.client.pull_manifest(oci_ref, &auth).await { + Ok((_manifest, remote_digest)) => { + verify_remote_manifest_digest(reference, expected_digest, &remote_digest) + } + Err(error) + if is_unauthorized_registry_error(&error) + && self.auth.basic_credentials().is_some() => + { + let remote_digest = self + .fetch_manifest_digest_with_basic_auth(reference) + .await + .map_err(|fallback_error| BoxError::RegistryError { + registry: reference.registry.clone(), + message: format!( + "Manifest creation could not be verified after push: default verification failed: {}; preemptive Basic verification failed: {}", + push_error_summary(&error), + push_error_summary(&fallback_error) + ), + })?; + verify_remote_manifest_digest(reference, expected_digest, &remote_digest) + } + Err(error) => Err(BoxError::RegistryError { + registry: reference.registry.clone(), + message: format!( + "Manifest creation could not be verified after push: {}; blobs may have uploaded but the manifest may be missing", + push_error_summary(&error) + ), + }), + } + } + + async fn fetch_manifest_digest_with_basic_auth( + &self, + reference: &ImageReference, + ) -> std::result::Result { + let (username, password) = self.auth.basic_credentials().ok_or_else(|| { + OciDistributionError::GenericError(Some( + "preemptive Basic manifest verification requires non-empty credentials".to_string(), + )) + })?; + let http = oci_reqwest::Client::new(); + let base = registry_base_url(self.protocol, reference)?; + let manifest_ref = reference + .tag + .as_deref() + .or(reference.digest.as_deref()) + .unwrap_or("latest"); + let manifest_url = registry_manifest_url(&base, &reference.repository, manifest_ref)?; + let response = http + .get(manifest_url.clone()) + .basic_auth(username, Some(password)) + .header(ACCEPT, MANIFEST_ACCEPT) + .send() + .await?; + let response = ensure_registry_status( + response, + &[oci_reqwest::StatusCode::OK], + manifest_url.as_str(), + ) + .await?; + let header_digest = response + .headers() + .get("docker-content-digest") + .map(|value| value.to_str().map(str::to_string)) + .transpose()?; + if let Some(digest) = header_digest { + return Ok(digest); + } + + let bytes = response.bytes().await?; + Ok(manifest_digest_from_bytes(&bytes)) + } +} + +fn registry_base_url( + protocol: RegistryProtocol, + reference: &ImageReference, +) -> std::result::Result { + oci_reqwest::Url::parse(&format!("{}://{}", protocol.scheme(), reference.registry)) + .map_err(|e| OciDistributionError::UrlParseError(e.to_string())) +} + +fn registry_blob_upload_url( + base: &oci_reqwest::Url, + repository: &str, +) -> std::result::Result { + oci_reqwest::Url::parse(&format!( + "{}/v2/{repository}/blobs/uploads/", + base.as_str().trim_end_matches('/') + )) + .map_err(|e| OciDistributionError::UrlParseError(e.to_string())) +} + +fn registry_manifest_url( + base: &oci_reqwest::Url, + repository: &str, + reference: &str, +) -> std::result::Result { + oci_reqwest::Url::parse(&format!( + "{}/v2/{repository}/manifests/{reference}", + base.as_str().trim_end_matches('/') + )) + .map_err(|e| OciDistributionError::UrlParseError(e.to_string())) +} + +fn registry_blob_url( + base: &oci_reqwest::Url, + repository: &str, + digest: &str, +) -> std::result::Result { + oci_reqwest::Url::parse(&format!( + "{}/v2/{repository}/blobs/{digest}", + base.as_str().trim_end_matches('/') + )) + .map_err(|e| OciDistributionError::UrlParseError(e.to_string())) +} + +fn resolve_registry_location( + base: &oci_reqwest::Url, + location: &str, +) -> std::result::Result { + oci_reqwest::Url::parse(location) + .or_else(|_| base.join(location)) + .map_err(|e| OciDistributionError::UrlParseError(e.to_string())) +} + +fn append_digest_param(location: &oci_reqwest::Url, digest: &str) -> oci_reqwest::Url { + let mut url = location.clone(); + url.query_pairs_mut().append_pair("digest", digest); + url +} + +fn response_location_or_url( + response: &oci_reqwest::Response, + fallback: &oci_reqwest::Url, +) -> std::result::Result { + response + .headers() + .get(LOCATION) + .map(|value| value.to_str().map(str::to_string)) + .transpose() + .map(|location| location.unwrap_or_else(|| fallback.as_str().to_string())) + .map_err(OciDistributionError::HeaderValueError) +} + +fn verify_remote_manifest_digest( + reference: &ImageReference, + expected_digest: &str, + remote_digest: &str, +) -> Result<()> { + if manifest_digests_match(expected_digest, remote_digest) { + return Ok(()); + } + + Err(BoxError::RegistryError { + registry: reference.registry.clone(), + message: format!( + "Manifest verification failed after push for {}/{}: expected {}, registry returned {}", + reference.registry, reference.repository, expected_digest, remote_digest + ), + }) +} + +fn manifest_digests_match(expected_digest: &str, remote_digest: &str) -> bool { + expected_digest.eq_ignore_ascii_case(remote_digest) +} + +fn manifest_digest_from_bytes(bytes: &[u8]) -> String { + use sha2::Digest as _; + + format!("sha256:{:x}", sha2::Sha256::digest(bytes)) +} + +async fn ensure_registry_status( + response: oci_reqwest::Response, + expected: &[oci_reqwest::StatusCode], + url: &str, +) -> std::result::Result { + let status = response.status(); + if expected.contains(&status) { + return Ok(response); + } + + let message = response.text().await.unwrap_or_default(); + if status == oci_reqwest::StatusCode::UNAUTHORIZED { + return Err(OciDistributionError::UnauthorizedError { + url: url.to_string(), + }); + } + + Err(OciDistributionError::ServerError { + code: status.as_u16(), + url: url.to_string(), + message, + }) +} + +async fn push_blob_with_basic_auth( + http: &oci_reqwest::Client, + base: &oci_reqwest::Url, + repository: &str, + username: &str, + password: &str, + digest: &str, + data: &[u8], +) -> std::result::Result { + if data.is_empty() { + return Err(OciDistributionError::PushNoDataError); + } + + let upload_url = registry_blob_upload_url(base, repository)?; + let response = http + .post(upload_url.clone()) + .basic_auth(username, Some(password)) + .header(CONTENT_LENGTH, "0") + .send() + .await?; + let response = ensure_registry_status( + response, + &[oci_reqwest::StatusCode::ACCEPTED], + upload_url.as_str(), + ) + .await?; + let location = response + .headers() + .get(LOCATION) + .ok_or(OciDistributionError::RegistryNoLocationError)? + .to_str()?; + let location = resolve_registry_location(base, location)?; + + let response = http + .patch(location.clone()) + .basic_auth(username, Some(password)) + .header(CONTENT_TYPE, "application/octet-stream") + .header(CONTENT_LENGTH, data.len().to_string()) + .body(data.to_vec()) + .send() + .await?; + let response = ensure_registry_status( + response, + &[oci_reqwest::StatusCode::ACCEPTED], + location.as_str(), + ) + .await?; + let location = response + .headers() + .get(LOCATION) + .map(|value| value.to_str()) + .transpose()? + .map(|location| resolve_registry_location(base, location)) + .transpose()? + .unwrap_or(location); + + let complete_url = append_digest_param(&location, digest); + let response = http + .put(complete_url.clone()) + .basic_auth(username, Some(password)) + .header(CONTENT_LENGTH, "0") + .send() + .await?; + let response = ensure_registry_status( + response, + &[oci_reqwest::StatusCode::CREATED], + complete_url.as_str(), + ) + .await?; + response_location_or_url(&response, &complete_url) +} + +fn is_unauthorized_registry_error(error: &OciDistributionError) -> bool { + match error { + OciDistributionError::UnauthorizedError { .. } + | OciDistributionError::AuthenticationFailure(_) + | OciDistributionError::ServerError { code: 401, .. } => true, + OciDistributionError::RequestError(error) => error + .status() + .is_some_and(|status| status == oci_reqwest::StatusCode::UNAUTHORIZED), + OciDistributionError::RegistryError { envelope, .. } => envelope + .errors + .iter() + .any(|err| matches!(err.code, OciErrorCode::Unauthorized)), + _ => false, + } +} + +fn registry_error_summary(error: &OciDistributionError, auth: &RegistryAuth) -> String { + let mut message = error.to_string(); + if let Some((username, password)) = auth.basic_credentials() { + for secret in [username, password] { + if !secret.is_empty() { + message = message.replace(&secret, "[redacted]"); + } + } + } + message +} + +fn is_repository_already_exists_push_error(error: &OciDistributionError) -> bool { + match error { + OciDistributionError::ServerError { code, message, .. } => { + *code == 409 || looks_like_repository_already_exists(message) + } + OciDistributionError::RegistryError { envelope, .. } => envelope.errors.iter().any(|err| { + let name_error = matches!( + &err.code, + OciErrorCode::NameInvalid | OciErrorCode::NameUnknown + ); + (name_error || matches!(&err.code, OciErrorCode::Denied)) + && (looks_like_repository_already_exists(&err.message) + || looks_like_repository_already_exists(&err.detail.to_string())) + }), + OciDistributionError::GenericError(Some(message)) + | OciDistributionError::SpecViolationError(message) => { + looks_like_repository_already_exists(message) + } + _ => false, + } +} + +fn looks_like_repository_already_exists(message: &str) -> bool { + let message = message.to_lowercase(); + message.contains("already exists") + || message.contains("resource exists") + || message.contains("duplicate") + || message.contains("已存在") + || message.contains("重复创建") +} + +fn push_error_summary(error: &OciDistributionError) -> String { + let mut message = error.to_string(); + if matches!( + error, + OciDistributionError::UnauthorizedError { .. } + | OciDistributionError::AuthenticationFailure(_) + | OciDistributionError::ServerError { code: 401, .. } + ) { + message.push_str( + "; checked A3S credentials, Docker config/credential helpers, and REGISTRY_USERNAME/REGISTRY_PASSWORD", + ); + } + message } /// Platform resolver that always selects linux images matching the host architecture. @@ -714,11 +1339,6 @@ fn platform_resolver_for(arch: String) -> impl Fn(&[ImageIndexEntry]) -> Option< } } -/// Platform resolver selecting the linux manifest matching the host architecture. -fn linux_platform_resolver(manifests: &[ImageIndexEntry]) -> Option { - platform_resolver_for(resolve_target_arch(None))(manifests) -} - #[cfg(test)] mod tests { use super::*; @@ -828,6 +1448,56 @@ mod tests { assert_eq!(auth.password, Some("pass".to_string())); } + #[test] + fn explicit_home_registry_credentials_are_loaded() { + let home = tempfile::tempdir().unwrap(); + let store = CredentialStore::new(home.path().join("auth/credentials.json")); + store + .store( + "manager-auth.invalid:5443", + "manager-user", + "manager-secret", + ) + .unwrap(); + + let auth = RegistryAuth::from_credential_store_at(home.path(), "manager-auth.invalid:5443"); + + assert_eq!( + auth.basic_credentials(), + Some(("manager-user".to_string(), "manager-secret".to_string())) + ); + } + + #[test] + fn malformed_explicit_home_store_falls_back_to_environment() { + let _guard = env_lock(); + let previous_username = std::env::var_os("REGISTRY_USERNAME"); + let previous_password = std::env::var_os("REGISTRY_PASSWORD"); + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join("auth")).unwrap(); + std::fs::write(home.path().join("auth/credentials.json"), b"not-json").unwrap(); + std::env::set_var("REGISTRY_USERNAME", "fallback-user"); + std::env::set_var("REGISTRY_PASSWORD", "fallback-secret"); + + let auth = RegistryAuth::from_credential_store_at( + home.path(), + "malformed-manager-auth.invalid:5443", + ); + + assert_eq!( + auth.basic_credentials(), + Some(("fallback-user".to_string(), "fallback-secret".to_string())) + ); + match previous_username { + Some(value) => std::env::set_var("REGISTRY_USERNAME", value), + None => std::env::remove_var("REGISTRY_USERNAME"), + } + match previous_password { + Some(value) => std::env::set_var("REGISTRY_PASSWORD", value), + None => std::env::remove_var("REGISTRY_PASSWORD"), + } + } + #[test] fn test_registry_auth_to_oci_anonymous() { let auth = RegistryAuth::anonymous(); @@ -846,17 +1516,14 @@ mod tests { fn test_registry_protocol_defaults_to_https() { let _guard = env_lock(); std::env::remove_var(REGISTRY_PROTOCOL_ENV); - assert!(matches!( - registry_protocol_from_env(), - ClientProtocol::Https - )); + assert_eq!(RegistryProtocol::from_env(), RegistryProtocol::Https); } #[test] fn test_registry_protocol_can_use_http_for_local_testing() { let _guard = env_lock(); std::env::set_var(REGISTRY_PROTOCOL_ENV, "http"); - assert!(matches!(registry_protocol_from_env(), ClientProtocol::Http)); + assert_eq!(RegistryProtocol::from_env(), RegistryProtocol::Http); std::env::remove_var(REGISTRY_PROTOCOL_ENV); } @@ -864,13 +1531,91 @@ mod tests { fn test_registry_protocol_rejects_unknown_values_to_https() { let _guard = env_lock(); std::env::set_var(REGISTRY_PROTOCOL_ENV, "ftp"); - assert!(matches!( - registry_protocol_from_env(), - ClientProtocol::Https - )); + assert_eq!(RegistryProtocol::from_env(), RegistryProtocol::Https); std::env::remove_var(REGISTRY_PROTOCOL_ENV); } + #[test] + fn registry_base_url_uses_explicit_protocol() { + let reference = test_image_reference(); + + assert_eq!( + registry_base_url(RegistryProtocol::Https, &reference) + .unwrap() + .as_str(), + "https://registry.example.com/" + ); + assert_eq!( + registry_base_url(RegistryProtocol::Http, &reference) + .unwrap() + .as_str(), + "http://registry.example.com/" + ); + } + + #[test] + fn test_repository_exists_push_error_matches_chinese_registry_message() { + let error = OciDistributionError::ServerError { + code: 500, + url: "http://10.12.111.133:49164/v2/a3s/api/blobs/uploads/".to_string(), + message: "该资源已存在,请勿重复创建".to_string(), + }; + + assert!(is_repository_already_exists_push_error(&error)); + } + + #[test] + fn test_repository_exists_push_error_retries_conflict_status() { + let error = OciDistributionError::ServerError { + code: 409, + url: "http://registry.example.com/v2/a3s/api/blobs/uploads/".to_string(), + message: "conflict".to_string(), + }; + + assert!(is_repository_already_exists_push_error(&error)); + } + + #[test] + fn test_unauthorized_push_error_is_not_repository_retryable() { + let error = OciDistributionError::UnauthorizedError { + url: "http://registry.example.com/v2/a3s/web/blobs/uploads/".to_string(), + }; + + assert!(!is_repository_already_exists_push_error(&error)); + assert!(push_error_summary(&error).contains("Docker config/credential helpers")); + } + + #[test] + fn test_manifest_digest_from_bytes_uses_sha256() { + assert_eq!( + manifest_digest_from_bytes(b"hello"), + "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + #[test] + fn test_verify_remote_manifest_digest_accepts_matching_digest() { + let reference = test_image_reference(); + let digest = format!("sha256:{}", "a".repeat(64)); + + verify_remote_manifest_digest(&reference, &digest, &digest.to_uppercase()).unwrap(); + } + + #[test] + fn test_verify_remote_manifest_digest_rejects_mismatch() { + let reference = test_image_reference(); + let err = verify_remote_manifest_digest( + &reference, + &format!("sha256:{}", "a".repeat(64)), + &format!("sha256:{}", "b".repeat(64)), + ) + .unwrap_err(); + + let message = err.to_string(); + assert!(message.contains("Manifest verification failed after push")); + assert!(message.contains("registry.example.com/a3s/app")); + } + #[test] fn test_to_oci_reference_with_tag() { let puller = RegistryPuller::new(); @@ -1070,3 +1815,6 @@ mod tests { assert!(err.to_string().contains(&layer_digest)); } } + +#[cfg(test)] +mod basic_pull_tests; diff --git a/src/runtime/src/oci/registry/basic_pull.rs b/src/runtime/src/oci/registry/basic_pull.rs new file mode 100644 index 00000000..d83173d1 --- /dev/null +++ b/src/runtime/src/oci/registry/basic_pull.rs @@ -0,0 +1,293 @@ +use oci_distribution::errors::OciDistributionError; +use oci_distribution::manifest::{ + OciDescriptor, OciImageManifest, OciManifest, IMAGE_MANIFEST_LIST_MEDIA_TYPE, + IMAGE_MANIFEST_MEDIA_TYPE, OCI_IMAGE_INDEX_MEDIA_TYPE, OCI_IMAGE_MEDIA_TYPE, +}; +use oci_reqwest::header::ACCEPT; +use sha2::Digest as _; +use tokio::io::{AsyncWrite, AsyncWriteExt}; + +use super::{ + registry_base_url, registry_blob_url, registry_manifest_url, ImageReference, RegistryAuth, + RegistryProtocol, MANIFEST_ACCEPT, +}; + +pub(super) struct BasicImageManifest { + pub(super) manifest: OciImageManifest, + pub(super) digest: String, + pub(super) bytes: Vec, +} + +struct RawManifest { + manifest: OciManifest, + digest: String, + bytes: Vec, +} + +/// Minimal OCI pull transport for registries that require preemptive HTTP +/// Basic authentication on protected endpoints but do not advertise that +/// challenge from `/v2/`. +pub(super) struct BasicPullClient { + http: oci_reqwest::Client, + base: oci_reqwest::Url, + repository: String, + username: String, + password: String, + target_arch: String, +} + +impl BasicPullClient { + pub(super) fn new( + protocol: RegistryProtocol, + reference: &ImageReference, + auth: &RegistryAuth, + target_arch: String, + ) -> std::result::Result { + let (username, password) = auth.basic_credentials().ok_or_else(|| { + OciDistributionError::GenericError(Some( + "preemptive Basic pull requires non-empty credentials".to_string(), + )) + })?; + + Ok(Self { + http: oci_reqwest::Client::builder().build()?, + base: registry_base_url(protocol, reference)?, + repository: reference.repository.clone(), + username, + password, + target_arch, + }) + } + + pub(super) async fn pull_manifest_digest( + &self, + reference: &ImageReference, + ) -> std::result::Result { + let manifest_ref = manifest_reference(reference); + let response = self + .fetch_manifest(manifest_ref, reference.digest.as_deref(), None) + .await?; + Ok(response.digest) + } + + pub(super) async fn pull_image_manifest( + &self, + reference: &ImageReference, + ) -> std::result::Result { + let manifest_ref = manifest_reference(reference); + let root = self + .fetch_manifest(manifest_ref, reference.digest.as_deref(), None) + .await?; + + match root.manifest { + OciManifest::Image(manifest) => Ok(BasicImageManifest { + manifest, + digest: root.digest, + bytes: root.bytes, + }), + OciManifest::ImageIndex(index) => { + let entry = index + .manifests + .iter() + .find(|entry| { + entry.platform.as_ref().is_some_and(|platform| { + platform.os == "linux" && platform.architecture == self.target_arch + }) + }) + .cloned() + .ok_or_else(|| { + OciDistributionError::ImageManifestNotFoundError(format!( + "no linux/{} entry found in image index", + self.target_arch + )) + })?; + let selected = self + .fetch_manifest(&entry.digest, Some(&entry.digest), Some(entry.size)) + .await?; + match selected.manifest { + OciManifest::Image(manifest) => Ok(BasicImageManifest { + manifest, + digest: selected.digest, + bytes: selected.bytes, + }), + OciManifest::ImageIndex(_) => { + Err(OciDistributionError::ImageManifestNotFoundError( + "selected image-index entry resolved to another image index" + .to_string(), + )) + } + } + } + } + } + + pub(super) async fn pull_blob( + &self, + descriptor: &OciDescriptor, + writer: &mut W, + ) -> std::result::Result<(), OciDistributionError> + where + W: AsyncWrite + Unpin, + { + validate_sha256_digest(&descriptor.digest)?; + let url = registry_blob_url(&self.base, &self.repository, &descriptor.digest)?; + let response = self + .http + .get(url.clone()) + .basic_auth(&self.username, Some(&self.password)) + .send() + .await?; + let mut response = ensure_pull_status(response, url.as_str())?; + + while let Some(chunk) = response.chunk().await? { + writer.write_all(&chunk).await?; + } + Ok(()) + } + + async fn fetch_manifest( + &self, + reference: &str, + expected_digest: Option<&str>, + expected_size: Option, + ) -> std::result::Result { + let url = registry_manifest_url(&self.base, &self.repository, reference)?; + let response = self + .http + .get(url.clone()) + .basic_auth(&self.username, Some(&self.password)) + .header(ACCEPT, MANIFEST_ACCEPT) + .send() + .await?; + let response = ensure_pull_status(response, url.as_str())?; + let header_digest = response + .headers() + .get("docker-content-digest") + .map(|value| value.to_str().map(str::to_string)) + .transpose()?; + let bytes = response.bytes().await?.to_vec(); + + if let Some(expected_size) = expected_size { + if expected_size < 0 || bytes.len() as i64 != expected_size { + return Err(OciDistributionError::SpecViolationError(format!( + "manifest size mismatch: expected {expected_size} bytes, received {}", + bytes.len() + ))); + } + } + + let computed_digest = format!("sha256:{:x}", sha2::Sha256::digest(&bytes)); + if let Some(expected_digest) = expected_digest { + validate_sha256_digest(expected_digest)?; + ensure_digest_matches("manifest descriptor", expected_digest, &computed_digest)?; + } + if let Some(header_digest) = header_digest.as_deref() { + validate_sha256_digest(header_digest)?; + ensure_digest_matches( + "Docker-Content-Digest header", + header_digest, + &computed_digest, + )?; + } + let digest = header_digest.unwrap_or(computed_digest); + + let manifest: OciManifest = serde_json::from_slice(&bytes) + .map_err(|error| OciDistributionError::ManifestParsingError(error.to_string()))?; + validate_manifest(&manifest)?; + + Ok(RawManifest { + manifest, + digest, + bytes, + }) + } +} + +fn manifest_reference(reference: &ImageReference) -> &str { + reference + .tag + .as_deref() + .or(reference.digest.as_deref()) + .unwrap_or("latest") +} + +fn validate_manifest(manifest: &OciManifest) -> std::result::Result<(), OciDistributionError> { + let (schema_version, media_type) = match manifest { + OciManifest::Image(manifest) => (manifest.schema_version, manifest.media_type.as_deref()), + OciManifest::ImageIndex(index) => (index.schema_version, index.media_type.as_deref()), + }; + if schema_version != 2 { + return Err(OciDistributionError::UnsupportedSchemaVersionError( + i32::from(schema_version), + )); + } + if let Some(media_type) = media_type { + if ![ + IMAGE_MANIFEST_MEDIA_TYPE, + OCI_IMAGE_MEDIA_TYPE, + IMAGE_MANIFEST_LIST_MEDIA_TYPE, + OCI_IMAGE_INDEX_MEDIA_TYPE, + ] + .contains(&media_type) + { + return Err(OciDistributionError::UnsupportedMediaTypeError( + media_type.to_string(), + )); + } + } + Ok(()) +} + +fn validate_sha256_digest(digest: &str) -> std::result::Result<(), OciDistributionError> { + let valid = digest.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }); + if valid { + Ok(()) + } else { + Err(OciDistributionError::SpecViolationError( + "registry returned a malformed content digest (expected sha256:<64 lowercase hex>)" + .to_string(), + )) + } +} + +fn ensure_digest_matches( + source: &str, + expected: &str, + computed: &str, +) -> std::result::Result<(), OciDistributionError> { + if expected.eq_ignore_ascii_case(computed) { + Ok(()) + } else { + Err(OciDistributionError::SpecViolationError(format!( + "{source} digest mismatch: expected {expected}, computed {computed}" + ))) + } +} + +fn ensure_pull_status( + response: oci_reqwest::Response, + url: &str, +) -> std::result::Result { + let status = response.status(); + if status == oci_reqwest::StatusCode::OK { + return Ok(response); + } + if status == oci_reqwest::StatusCode::UNAUTHORIZED { + return Err(OciDistributionError::UnauthorizedError { + url: url.to_string(), + }); + } + + // Do not include a registry-controlled response body. A hostile endpoint + // could echo the Authorization header or submitted credentials into it. + Err(OciDistributionError::ServerError { + code: status.as_u16(), + url: url.to_string(), + message: "registry request failed".to_string(), + }) +} diff --git a/src/runtime/src/oci/registry/basic_pull_tests.rs b/src/runtime/src/oci/registry/basic_pull_tests.rs new file mode 100644 index 00000000..f5e6f4af --- /dev/null +++ b/src/runtime/src/oci/registry/basic_pull_tests.rs @@ -0,0 +1,705 @@ +use std::collections::HashMap; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{Method, Request, Response, StatusCode}; +use axum::routing::any; +use axum::Router; +use base64::Engine as _; +use serde_json::json; +use sha2::Digest as _; + +use super::super::pull::ImagePuller; +use super::super::store::ImageStore; +use super::{ImageReference, RegistryAuth, RegistryProtocol, RegistryPuller}; + +const USERNAME: &str = "fixture-user"; +const PASSWORD: &str = "fixture-secret-password"; +const REPOSITORY: &str = "a3s/app"; + +#[derive(Clone, Debug)] +struct RecordedRequest { + path: String, + authorization: Option, +} + +#[derive(Clone)] +struct RegistryFixtureState { + expected_authorization: String, + manifests: Arc>, + blobs: Arc>, + redirected_layer_digest: String, + public_manifests: Arc, + external_layer_redirect: Arc>>, + corrupt_manifest: Arc>>, + corrupt_blob: Arc>>, + requests: Arc>>, +} + +#[derive(Clone)] +struct FixtureContent { + bytes: Vec, + media_type: &'static str, + digest: String, +} + +struct RegistryFixture { + reference: ImageReference, + index_digest: String, + manifest_digest: String, + config_digest: String, + layer_digest: String, + manifest_bytes: Vec, + config_bytes: Vec, + layer_bytes: Vec, + public_manifests: Arc, + external_layer_redirect: Arc>>, + corrupt_manifest: Arc>>, + corrupt_blob: Arc>>, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl RegistryFixture { + async fn start() -> Self { + let config_bytes = serde_json::to_vec(&json!({ + "architecture": "amd64", + "os": "linux", + "config": {"Cmd": ["sh", "-c", "echo fixture-ok"]}, + "rootfs": {"type": "layers", "diff_ids": []}, + "history": [] + })) + .unwrap(); + let layer_bytes = b"streamed-layer-payload".repeat(256); + let config_digest = digest(&config_bytes); + let layer_digest = digest(&layer_bytes); + + let manifest_bytes = serde_json::to_vec(&json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": config_digest, + "size": config_bytes.len() + }, + "layers": [{ + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": layer_digest, + "size": layer_bytes.len() + }] + })) + .unwrap(); + let manifest_digest = digest(&manifest_bytes); + + let wrong_manifest = serde_json::to_vec(&json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": config_digest, + "size": config_bytes.len() + }, + "layers": [] + })) + .unwrap(); + let wrong_manifest_digest = digest(&wrong_manifest); + + let index_bytes = serde_json::to_vec(&json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": wrong_manifest_digest, + "size": wrong_manifest.len(), + "platform": {"architecture": "arm64", "os": "linux"} + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": manifest_digest, + "size": manifest_bytes.len(), + "platform": {"architecture": "amd64", "os": "linux"} + } + ] + })) + .unwrap(); + let index_digest = digest(&index_bytes); + + let manifests = HashMap::from([ + ( + "latest".to_string(), + FixtureContent { + bytes: index_bytes, + media_type: "application/vnd.oci.image.index.v1+json", + digest: index_digest.clone(), + }, + ), + ( + manifest_digest.clone(), + FixtureContent { + bytes: manifest_bytes.clone(), + media_type: "application/vnd.oci.image.manifest.v1+json", + digest: manifest_digest.clone(), + }, + ), + ( + wrong_manifest_digest.clone(), + FixtureContent { + bytes: wrong_manifest, + media_type: "application/vnd.oci.image.manifest.v1+json", + digest: wrong_manifest_digest, + }, + ), + ]); + let blobs = HashMap::from([ + ( + config_digest.clone(), + FixtureContent { + bytes: config_bytes.clone(), + media_type: "application/octet-stream", + digest: config_digest.clone(), + }, + ), + ( + layer_digest.clone(), + FixtureContent { + bytes: layer_bytes.clone(), + media_type: "application/octet-stream", + digest: layer_digest.clone(), + }, + ), + ]); + + let requests = Arc::new(Mutex::new(Vec::new())); + let public_manifests = Arc::new(AtomicBool::new(false)); + let external_layer_redirect = Arc::new(Mutex::new(None)); + let corrupt_manifest = Arc::new(Mutex::new(None)); + let corrupt_blob = Arc::new(Mutex::new(None)); + let state = RegistryFixtureState { + expected_authorization: format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{USERNAME}:{PASSWORD}")) + ), + manifests: Arc::new(manifests), + blobs: Arc::new(blobs), + redirected_layer_digest: layer_digest.clone(), + public_manifests: Arc::clone(&public_manifests), + external_layer_redirect: Arc::clone(&external_layer_redirect), + corrupt_manifest: Arc::clone(&corrupt_manifest), + corrupt_blob: Arc::clone(&corrupt_blob), + requests: Arc::clone(&requests), + }; + let app = Router::new() + .route("/*path", any(registry_handler)) + .with_state(state); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::Server::from_tcp(listener) + .unwrap() + .serve(app.into_make_service()) + .await + .unwrap(); + }); + + Self { + reference: ImageReference { + registry: addr.to_string(), + repository: REPOSITORY.to_string(), + tag: Some("latest".to_string()), + digest: None, + }, + index_digest, + manifest_digest, + config_digest, + layer_digest, + manifest_bytes, + config_bytes, + layer_bytes, + public_manifests, + external_layer_redirect, + corrupt_manifest, + corrupt_blob, + requests, + task, + } + } + + fn request_snapshot(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + + fn corrupt_manifest(&self, reference: &str) { + *self.corrupt_manifest.lock().unwrap() = Some(reference.to_string()); + } + + fn corrupt_blob(&self, digest: &str) { + *self.corrupt_blob.lock().unwrap() = Some(digest.to_string()); + } + + fn redirect_layer_to(&self, location: String) { + *self.external_layer_redirect.lock().unwrap() = Some(location); + } + + fn allow_anonymous_manifests(&self) { + self.public_manifests.store(true, Ordering::Relaxed); + } +} + +impl Drop for RegistryFixture { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[derive(Clone)] +struct RedirectTargetState { + content: FixtureContent, + requests: Arc>>, +} + +struct RedirectTarget { + url: String, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl RedirectTarget { + async fn start(bytes: Vec, digest: String) -> Self { + let requests = Arc::new(Mutex::new(Vec::new())); + let state = RedirectTargetState { + content: FixtureContent { + bytes, + media_type: "application/octet-stream", + digest, + }, + requests: Arc::clone(&requests), + }; + let app = Router::new() + .route("/external-layer", any(redirect_target_handler)) + .with_state(state); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::Server::from_tcp(listener) + .unwrap() + .serve(app.into_make_service()) + .await + .unwrap(); + }); + Self { + url: format!("http://{addr}/external-layer"), + requests, + task, + } + } +} + +impl Drop for RedirectTarget { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn redirect_target_handler( + State(state): State, + request: Request, +) -> Response { + state.requests.lock().unwrap().push(RecordedRequest { + path: request.uri().path().to_string(), + authorization: request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_string), + }); + response( + StatusCode::OK, + Some(state.content.media_type), + Some(&state.content.digest), + state.content.bytes, + ) +} + +async fn registry_handler( + State(state): State, + request: Request, +) -> Response { + let path = request.uri().path().to_string(); + let authorization = request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + state.requests.lock().unwrap().push(RecordedRequest { + path: path.clone(), + authorization: authorization.clone(), + }); + + if request.method() != Method::GET { + return response(StatusCode::METHOD_NOT_ALLOWED, None, None, Vec::new()); + } + if path == "/v2/" { + // This registry advertises Basic only on protected resources. That is + // the production behavior that oci-distribution does not negotiate. + return response(StatusCode::OK, None, None, Vec::new()); + } + let manifest_prefix = format!("/v2/{REPOSITORY}/manifests/"); + let anonymous_manifest_allowed = + state.public_manifests.load(Ordering::Relaxed) && path.starts_with(&manifest_prefix); + if authorization.as_deref() != Some(&state.expected_authorization) + && !anonymous_manifest_allowed + { + return Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("www-authenticate", "Basic realm=\"A3S OCI Registry\"") + .header("content-type", "application/json") + .body(Body::from( + r#"{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":{}}]}"#, + )) + .unwrap(); + } + + if let Some(reference) = path.strip_prefix(&manifest_prefix) { + return match state.manifests.get(reference) { + Some(content) => { + let mut bytes = content.bytes.clone(); + if state.corrupt_manifest.lock().unwrap().as_deref() == Some(reference) { + bytes[0] ^= 0x01; + } + response( + StatusCode::OK, + Some(content.media_type), + Some(&content.digest), + bytes, + ) + } + None => response(StatusCode::NOT_FOUND, None, None, Vec::new()), + }; + } + + let blob_prefix = format!("/v2/{REPOSITORY}/blobs/"); + if let Some(blob_digest) = path.strip_prefix(&blob_prefix) { + if blob_digest == state.redirected_layer_digest { + let location = state + .external_layer_redirect + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| format!("/redirected/{blob_digest}")); + return Response::builder() + .status(StatusCode::TEMPORARY_REDIRECT) + .header("location", location) + .body(Body::empty()) + .unwrap(); + } + return match state.blobs.get(blob_digest) { + Some(content) => blob_response(&state, blob_digest, content), + None => response(StatusCode::NOT_FOUND, None, None, Vec::new()), + }; + } + + if let Some(blob_digest) = path.strip_prefix("/redirected/") { + return match state.blobs.get(blob_digest) { + Some(content) => blob_response(&state, blob_digest, content), + None => response(StatusCode::NOT_FOUND, None, None, Vec::new()), + }; + } + + response(StatusCode::NOT_FOUND, None, None, Vec::new()) +} + +fn blob_response( + state: &RegistryFixtureState, + digest: &str, + content: &FixtureContent, +) -> Response { + let mut bytes = content.bytes.clone(); + if state.corrupt_blob.lock().unwrap().as_deref() == Some(digest) { + bytes[0] ^= 0x01; + } + response( + StatusCode::OK, + Some(content.media_type), + Some(&content.digest), + bytes, + ) +} + +fn response( + status: StatusCode, + media_type: Option<&str>, + digest: Option<&str>, + body: Vec, +) -> Response { + let mut builder = Response::builder().status(status); + if let Some(media_type) = media_type { + builder = builder.header("content-type", media_type); + } + if let Some(digest) = digest { + builder = builder.header("docker-content-digest", digest); + } + builder.body(Body::from(body)).unwrap() +} + +fn digest(bytes: &[u8]) -> String { + format!("sha256:{:x}", sha2::Sha256::digest(bytes)) +} + +fn assert_blob(path: &Path, digest: &str, expected: &[u8]) { + let hex = digest.strip_prefix("sha256:").unwrap(); + assert_eq!( + std::fs::read(path.join("blobs/sha256").join(hex)).unwrap(), + expected + ); +} + +#[tokio::test] +async fn basic_challenge_pull_resolves_index_streams_blobs_and_follows_redirect() { + let fixture = RegistryFixture::start().await; + let puller = RegistryPuller::with_auth_arch_and_protocol( + RegistryAuth::basic(USERNAME, PASSWORD), + "amd64".to_string(), + RegistryProtocol::Http, + ); + let target = tempfile::tempdir().unwrap(); + + assert_eq!( + puller + .pull_manifest_digest(&fixture.reference) + .await + .unwrap(), + fixture.index_digest + ); + puller + .pull(&fixture.reference, target.path()) + .await + .unwrap(); + + assert_blob( + target.path(), + &fixture.manifest_digest, + &fixture.manifest_bytes, + ); + assert_blob(target.path(), &fixture.config_digest, &fixture.config_bytes); + assert_blob(target.path(), &fixture.layer_digest, &fixture.layer_bytes); + let index: serde_json::Value = + serde_json::from_slice(&std::fs::read(target.path().join("index.json")).unwrap()).unwrap(); + assert_eq!(index["manifests"][0]["digest"], fixture.manifest_digest); + + let requests = fixture.request_snapshot(); + assert!(requests.iter().any(|request| { + request.path.ends_with("/manifests/latest") && request.authorization.is_none() + })); + for suffix in [ + "/manifests/latest", + &format!("/manifests/{}", fixture.manifest_digest), + &format!("/blobs/{}", fixture.config_digest), + &format!("/blobs/{}", fixture.layer_digest), + &format!("/redirected/{}", fixture.layer_digest), + ] { + assert!( + requests.iter().any(|request| { + request.path.ends_with(suffix) + && request.authorization.as_deref() + == Some(&format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{USERNAME}:{PASSWORD}")) + )) + }), + "missing authenticated request ending in {suffix}" + ); + } +} + +#[tokio::test] +async fn image_puller_common_to_explicit_pull_and_run_uses_basic_fallback() { + let fixture = RegistryFixture::start().await; + let root = tempfile::tempdir().unwrap(); + let store = Arc::new(ImageStore::new(root.path(), 10 * 1024 * 1024).unwrap()); + let registry_puller = RegistryPuller::with_auth_arch_and_protocol( + RegistryAuth::basic(USERNAME, PASSWORD), + "amd64".to_string(), + RegistryProtocol::Http, + ); + // Both `a3s-box pull` and run's implicit image preparation call this same + // cache-first ImagePuller path. + let puller = ImagePuller::with_registry_puller(Arc::clone(&store), registry_puller); + let full_reference = fixture.reference.full_reference(); + + let image = puller.pull(&full_reference).await.unwrap(); + + assert_eq!(image.manifest_digest(), fixture.manifest_digest); + let stored = store.get(&full_reference).await.unwrap(); + assert_eq!(stored.digest, fixture.index_digest); + assert_blob(&stored.path, &fixture.config_digest, &fixture.config_bytes); + assert_blob(&stored.path, &fixture.layer_digest, &fixture.layer_bytes); +} + +#[tokio::test] +async fn cross_origin_blob_redirect_does_not_forward_basic_credentials() { + let fixture = RegistryFixture::start().await; + let redirect_target = + RedirectTarget::start(fixture.layer_bytes.clone(), fixture.layer_digest.clone()).await; + fixture.redirect_layer_to(redirect_target.url.clone()); + let puller = RegistryPuller::with_auth_arch_and_protocol( + RegistryAuth::basic(USERNAME, PASSWORD), + "amd64".to_string(), + RegistryProtocol::Http, + ); + let target = tempfile::tempdir().unwrap(); + + puller + .pull(&fixture.reference, target.path()) + .await + .unwrap(); + + let requests = redirect_target.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/external-layer"); + assert!(requests[0].authorization.is_none()); +} + +#[tokio::test] +async fn blob_unauthorized_after_public_manifest_retries_with_basic() { + let fixture = RegistryFixture::start().await; + fixture.allow_anonymous_manifests(); + let puller = RegistryPuller::with_auth_arch_and_protocol( + RegistryAuth::basic(USERNAME, PASSWORD), + "amd64".to_string(), + RegistryProtocol::Http, + ); + let target = tempfile::tempdir().unwrap(); + + puller + .pull(&fixture.reference, target.path()) + .await + .unwrap(); + + let config_path = format!("/v2/{REPOSITORY}/blobs/{}", fixture.config_digest); + let requests = fixture.request_snapshot(); + assert!(requests + .iter() + .any(|request| { request.path == config_path && request.authorization.is_none() })); + assert!(requests + .iter() + .any(|request| { request.path == config_path && request.authorization.is_some() })); + assert_blob(target.path(), &fixture.config_digest, &fixture.config_bytes); + assert_blob(target.path(), &fixture.layer_digest, &fixture.layer_bytes); +} + +#[tokio::test] +async fn anonymous_pull_does_not_attempt_preemptive_basic() { + let fixture = RegistryFixture::start().await; + let puller = RegistryPuller::with_auth_arch_and_protocol( + RegistryAuth::anonymous(), + "amd64".to_string(), + RegistryProtocol::Http, + ); + + let error = puller + .pull_manifest_digest(&fixture.reference) + .await + .unwrap_err(); + assert!(error.to_string().contains("Failed to pull manifest")); + assert!(fixture + .request_snapshot() + .iter() + .all(|request| request.authorization.is_none())); +} + +#[tokio::test] +async fn empty_basic_credentials_do_not_enable_preemptive_retry() { + for auth in [ + RegistryAuth::basic("", PASSWORD), + RegistryAuth::basic(USERNAME, ""), + ] { + let fixture = RegistryFixture::start().await; + let puller = RegistryPuller::with_auth_arch_and_protocol( + auth, + "amd64".to_string(), + RegistryProtocol::Http, + ); + + puller + .pull_manifest_digest(&fixture.reference) + .await + .unwrap_err(); + assert!(fixture + .request_snapshot() + .iter() + .all(|request| request.authorization.is_none())); + } +} + +#[tokio::test] +async fn failed_basic_pull_never_exposes_credentials() { + let fixture = RegistryFixture::start().await; + let wrong_username = "credential-user-must-not-leak"; + let wrong_password = "credential-password-must-not-leak"; + let puller = RegistryPuller::with_auth_arch_and_protocol( + RegistryAuth::basic(wrong_username, wrong_password), + "amd64".to_string(), + RegistryProtocol::Http, + ); + + let message = puller + .pull_manifest_digest(&fixture.reference) + .await + .unwrap_err() + .to_string(); + assert!(!message.contains(wrong_username)); + assert!(!message.contains(wrong_password)); +} + +#[tokio::test] +async fn basic_pull_rejects_corrupted_manifest_bytes() { + let fixture = RegistryFixture::start().await; + fixture.corrupt_manifest("latest"); + let puller = RegistryPuller::with_auth_arch_and_protocol( + RegistryAuth::basic(USERNAME, PASSWORD), + "amd64".to_string(), + RegistryProtocol::Http, + ); + + let message = puller + .pull_manifest_digest(&fixture.reference) + .await + .unwrap_err() + .to_string(); + assert!(message.contains("Docker-Content-Digest header digest mismatch")); + assert!(!message.contains(USERNAME)); + assert!(!message.contains(PASSWORD)); +} + +#[tokio::test] +async fn basic_pull_rejects_corrupted_layer_and_removes_partial_blob() { + let fixture = RegistryFixture::start().await; + fixture.corrupt_blob(&fixture.layer_digest); + let puller = RegistryPuller::with_auth_arch_and_protocol( + RegistryAuth::basic(USERNAME, PASSWORD), + "amd64".to_string(), + RegistryProtocol::Http, + ); + let target = tempfile::tempdir().unwrap(); + + let message = puller + .pull(&fixture.reference, target.path()) + .await + .unwrap_err() + .to_string(); + assert!(message.contains("layer digest mismatch")); + assert!(!message.contains(USERNAME)); + assert!(!message.contains(PASSWORD)); + + let layer_hex = fixture.layer_digest.strip_prefix("sha256:").unwrap(); + let layer_path = target.path().join("blobs/sha256").join(layer_hex); + assert!(!layer_path.exists()); + assert!(!layer_path.with_extension("partial").exists()); +} diff --git a/src/runtime/src/oci/registry/blob_pull.rs b/src/runtime/src/oci/registry/blob_pull.rs new file mode 100644 index 00000000..7b45d8d5 --- /dev/null +++ b/src/runtime/src/oci/registry/blob_pull.rs @@ -0,0 +1,235 @@ +use std::path::Path; + +use a3s_box_core::error::{BoxError, Result}; +use oci_distribution::errors::OciDistributionError; +use oci_distribution::manifest::OciDescriptor; +use oci_distribution::{Client, Reference}; + +use super::basic_pull::BasicPullClient; +use super::{is_unauthorized_registry_error, registry_error_summary, RegistryAuth}; + +/// An `AsyncWrite` that streams bytes straight to a file while computing its +/// SHA-256 and size, so a pulled blob is never fully buffered in memory. +pub(super) struct HashingFileWriter { + file: tokio::fs::File, + hasher: sha2::Sha256, + bytes_written: u64, +} + +impl HashingFileWriter { + pub(super) fn new(file: tokio::fs::File) -> Self { + use sha2::Digest as _; + + Self { + file, + hasher: sha2::Sha256::new(), + bytes_written: 0, + } + } + + fn bytes_written(&self) -> u64 { + self.bytes_written + } + + pub(super) fn finalize_hex(self) -> String { + use sha2::Digest as _; + + format!("{:x}", self.hasher.finalize()) + } +} + +impl tokio::io::AsyncWrite for HashingFileWriter { + fn poll_write( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + use sha2::Digest as _; + + let this = self.get_mut(); + match std::pin::Pin::new(&mut this.file).poll_write(cx, buf) { + std::task::Poll::Ready(Ok(written)) => { + this.hasher.update(&buf[..written]); + this.bytes_written = this.bytes_written.saturating_add(written as u64); + std::task::Poll::Ready(Ok(written)) + } + other => other, + } + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.get_mut().file).poll_flush(cx) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.get_mut().file).poll_shutdown(cx) + } +} + +pub(super) struct BlobPullTransport<'a> { + pub(super) client: &'a Client, + pub(super) oci_ref: &'a Reference, + pub(super) basic_client: Option<&'a BasicPullClient>, + pub(super) force_basic: bool, + pub(super) auth: &'a RegistryAuth, + pub(super) registry: &'a str, +} + +/// Stream a blob to `dest`, verifying its declared size and SHA-256 before +/// atomically publishing it under its content-addressed name. +pub(super) async fn stream_and_verify_blob( + transport: &BlobPullTransport<'_>, + descriptor: &OciDescriptor, + dest: &Path, + what: &str, +) -> Result<()> { + use tokio::io::AsyncWriteExt; + + let tmp = dest.with_extension("partial"); + let mut writer = create_blob_writer(&tmp, what, transport.registry).await?; + + let first_result = if transport.force_basic { + match transport.basic_client { + Some(basic_client) => basic_client.pull_blob(descriptor, &mut writer).await, + None => Err(OciDistributionError::GenericError(Some( + "preemptive Basic blob pull requires non-empty credentials".to_string(), + ))), + } + } else { + transport + .client + .pull_blob(transport.oci_ref, descriptor, &mut writer) + .await + }; + + if let Err(first_error) = first_result { + if !transport.force_basic && is_unauthorized_registry_error(&first_error) { + if let Some(basic_client) = transport.basic_client { + tracing::warn!( + error = %registry_error_summary(&first_error, transport.auth), + "Registry rejected the default OCI blob auth flow; retrying with preemptive Basic auth" + ); + drop(writer); + let _ = tokio::fs::remove_file(&tmp).await; + writer = create_blob_writer(&tmp, what, transport.registry).await?; + if let Err(fallback_error) = basic_client.pull_blob(descriptor, &mut writer).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(BoxError::RegistryError { + registry: transport.registry.to_string(), + message: format!( + "Failed to pull {what}: default auth failed: {}; preemptive Basic retry failed: {}", + registry_error_summary(&first_error, transport.auth), + registry_error_summary(&fallback_error, transport.auth) + ), + }); + } + } else { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(blob_pull_error( + transport.registry, + what, + &first_error, + transport.auth, + )); + } + } else { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(blob_pull_error( + transport.registry, + what, + &first_error, + transport.auth, + )); + } + } + + if let Err(error) = writer.flush().await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(blob_io_error(transport.registry, what, "flush", error)); + } + if let Err(error) = writer.shutdown().await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(blob_io_error(transport.registry, what, "close", error)); + } + let actual_size = writer.bytes_written(); + let actual_hex = writer.finalize_hex(); + + if descriptor.size < 0 || actual_size != descriptor.size as u64 { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(BoxError::RegistryError { + registry: transport.registry.to_string(), + message: format!( + "{what} size mismatch: expected {} bytes, received {actual_size}", + descriptor.size + ), + }); + } + + match descriptor.digest.strip_prefix("sha256:") { + Some(expected_hex) if actual_hex.eq_ignore_ascii_case(expected_hex) => {} + Some(expected_hex) => { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(BoxError::RegistryError { + registry: transport.registry.to_string(), + message: format!( + "{what} digest mismatch: expected sha256:{expected_hex}, computed sha256:{actual_hex}" + ), + }); + } + None => { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(BoxError::RegistryError { + registry: transport.registry.to_string(), + message: format!( + "{what} uses an unsupported digest algorithm ({}); refusing to store \ + unverifiable content (only sha256 is supported)", + descriptor.digest + ), + }); + } + } + + if let Err(error) = tokio::fs::rename(&tmp, dest).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(BoxError::RegistryError { + registry: transport.registry.to_string(), + message: format!("Failed to store {what} blob: {error}"), + }); + } + Ok(()) +} + +fn blob_pull_error( + registry: &str, + what: &str, + error: &OciDistributionError, + auth: &RegistryAuth, +) -> BoxError { + BoxError::RegistryError { + registry: registry.to_string(), + message: format!( + "Failed to pull {what}: {}", + registry_error_summary(error, auth) + ), + } +} + +async fn create_blob_writer(tmp: &Path, what: &str, registry: &str) -> Result { + tokio::fs::File::create(tmp) + .await + .map(HashingFileWriter::new) + .map_err(|error| blob_io_error(registry, what, "create", error)) +} + +fn blob_io_error(registry: &str, what: &str, operation: &str, error: std::io::Error) -> BoxError { + BoxError::RegistryError { + registry: registry.to_string(), + message: format!("Failed to {operation} {what} file: {error}"), + } +} diff --git a/src/runtime/src/oci/rootfs.rs b/src/runtime/src/oci/rootfs.rs index 1e98786a..e6b321b4 100644 --- a/src/runtime/src/oci/rootfs.rs +++ b/src/runtime/src/oci/rootfs.rs @@ -5,9 +5,10 @@ use a3s_box_core::error::{BoxError, Result}; use std::path::PathBuf; +use std::path::{Component, Path}; use super::image::OciImage; -use super::layers::extract_layer; +use super::layers::{extract_layer_with_metadata, finalize_rootfs_metadata}; /// Builder for creating a guest rootfs from an OCI image. /// @@ -94,6 +95,7 @@ impl OciRootfsBuilder { } self.create_essential_files()?; + finalize_rootfs_metadata(&self.rootfs_path)?; tracing::info!("OCI rootfs built successfully"); Ok(()) @@ -134,6 +136,24 @@ impl OciRootfsBuilder { tracing::debug!(dir = %full_path.display(), "Created directory"); } + // The service can run with a restrictive umask (the production smoke + // uses 077), but the root of a Linux container must remain traversable + // by image users other than root. Layer archives normally omit an + // explicit `.` entry, so without this normalization the host-created + // rootfs directory becomes `/` with mode 0700 inside the container. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&self.rootfs_path, std::fs::Permissions::from_mode(0o755)) + .map_err(|error| { + BoxError::BuildError(format!( + "Failed to set rootfs permissions on {}: {error}", + self.rootfs_path.display() + )) + })?; + } + Ok(()) } @@ -149,7 +169,7 @@ impl OciRootfsBuilder { ); for layer_path in image.layer_paths() { - extract_layer(layer_path, &self.rootfs_path)?; + extract_layer_with_metadata(layer_path, &self.rootfs_path)?; } Ok(()) @@ -295,7 +315,7 @@ impl OciRootfsBuilder { } fn ensure_passwd_entries(&self, required: &[(&str, &str)]) -> Result<()> { - let passwd_path = self.rootfs_path.join("etc/passwd"); + let passwd_path = self.rootfs_file_path("etc/passwd")?; let existing = std::fs::read_to_string(&passwd_path).unwrap_or_default(); let mut content = existing.clone(); @@ -317,7 +337,7 @@ impl OciRootfsBuilder { } fn ensure_group_entries(&self, required: &[(&str, &str)]) -> Result<()> { - let group_path = self.rootfs_path.join("etc/group"); + let group_path = self.rootfs_file_path("etc/group")?; let existing = std::fs::read_to_string(&group_path).unwrap_or_default(); let mut content = existing.clone(); @@ -339,9 +359,16 @@ impl OciRootfsBuilder { } fn write_file(&self, relative_path: &str, content: &str) -> Result<()> { - let full_path = self.rootfs_path.join(relative_path); + let full_path = self.rootfs_file_path(relative_path)?; if let Some(parent) = full_path.parent() { + if parent.exists() && !parent.is_dir() { + return Err(BoxError::BuildError(format!( + "Cannot write {} because parent {} exists and is not a directory", + full_path.display(), + parent.display() + ))); + } std::fs::create_dir_all(parent).map_err(|e| { BoxError::BuildError(format!("Failed to create parent directory: {}", e)) })?; @@ -351,10 +378,94 @@ impl OciRootfsBuilder { BoxError::BuildError(format!("Failed to write {}: {}", full_path.display(), e)) })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&full_path, std::fs::Permissions::from_mode(0o644)).map_err( + |e| { + BoxError::BuildError(format!( + "Failed to set permissions on {}: {}", + full_path.display(), + e + )) + }, + )?; + } + tracing::debug!(path = %full_path.display(), "Created file"); Ok(()) } + fn rootfs_file_path(&self, relative_path: &str) -> Result { + let relative = Path::new(relative_path); + if relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_))) + { + return Err(BoxError::BuildError(format!( + "Invalid rootfs relative path: {relative_path}" + ))); + } + + let file_name = relative.file_name().ok_or_else(|| { + BoxError::BuildError(format!("Invalid rootfs file path: {relative_path}")) + })?; + let parent = relative.parent().unwrap_or_else(|| Path::new("")); + Ok(self.resolve_rootfs_dir(parent)?.join(file_name)) + } + + fn resolve_rootfs_dir(&self, relative_dir: &Path) -> Result { + let mut current = self.rootfs_path.clone(); + + for component in relative_dir.components() { + let Component::Normal(name) = component else { + if matches!(component, Component::CurDir) { + continue; + } + return Err(BoxError::BuildError(format!( + "Invalid rootfs directory path: {}", + relative_dir.display() + ))); + }; + + let candidate = current.join(name); + match std::fs::symlink_metadata(&candidate) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let target = std::fs::read_link(&candidate).map_err(|e| { + BoxError::BuildError(format!( + "Failed to resolve rootfs symlink {}: {}", + candidate.display(), + e + )) + })?; + current = if target.is_absolute() { + let stripped = target.strip_prefix("/").map_err(|_| { + BoxError::BuildError(format!( + "Invalid absolute rootfs symlink target {}", + target.display() + )) + })?; + self.rootfs_path.join(stripped) + } else { + current.join(target) + }; + } + Ok(metadata) if !metadata.is_dir() => { + return Err(BoxError::BuildError(format!( + "Cannot use {} as a rootfs directory because it is not a directory", + candidate.display() + ))); + } + Ok(_) | Err(_) => { + current = candidate; + } + } + } + + Ok(current) + } + /// Get the OCI image configuration. /// /// Useful for extracting entrypoint, environment, working directory, etc. @@ -392,6 +503,32 @@ mod tests { assert!(rootfs_path.join("workspace").exists()); } + #[cfg(unix)] + #[test] + fn test_oci_rootfs_builder_makes_root_searchable_by_image_users() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().unwrap(); + let rootfs_path = temp_dir.path().join("rootfs"); + let image = temp_dir.path().join("image"); + + std::fs::create_dir_all(&rootfs_path).unwrap(); + std::fs::set_permissions(&rootfs_path, std::fs::Permissions::from_mode(0o700)).unwrap(); + create_test_oci_image(&image); + + OciRootfsBuilder::new(&rootfs_path) + .with_image(&image) + .build() + .unwrap(); + + let mode = std::fs::metadata(&rootfs_path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o755); + } + #[test] fn test_oci_rootfs_builder_creates_essential_files() { let temp_dir = TempDir::new().unwrap(); @@ -414,6 +551,35 @@ mod tests { assert!(passwd.contains("root:x:0:0")); } + #[cfg(unix)] + #[test] + fn test_oci_rootfs_builder_makes_essential_files_world_readable() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().unwrap(); + let rootfs_path = temp_dir.path().join("rootfs"); + let builder = OciRootfsBuilder::new(&rootfs_path); + let essential_files = ["passwd", "group", "hosts", "resolv.conf", "nsswitch.conf"]; + + fs::create_dir_all(rootfs_path.join("etc")).unwrap(); + for name in essential_files { + let path = rootfs_path.join("etc").join(name); + fs::write(&path, "image content\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + + builder.create_essential_files().unwrap(); + + for name in essential_files { + let mode = fs::metadata(rootfs_path.join("etc").join(name)) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o644, "unexpected mode for /etc/{name}"); + } + } + #[test] fn test_oci_rootfs_builder_extracts_image_at_root() { let temp_dir = TempDir::new().unwrap(); @@ -478,6 +644,32 @@ mod tests { assert!(resolv_conf.contains("nameserver 8.8.4.4")); } + #[test] + fn test_oci_rootfs_builder_writes_essential_files_inside_absolute_etc_symlink() { + let temp_dir = TempDir::new().unwrap(); + let rootfs_path = temp_dir.path().join("rootfs"); + let image = temp_dir.path().join("image"); + + create_test_oci_image_with_etc_symlink(&image); + + OciRootfsBuilder::new(&rootfs_path) + .with_image(&image) + .build() + .unwrap(); + + assert!(rootfs_path + .join("etc") + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); + assert!(rootfs_path.join("usr/etc/passwd").exists()); + assert!(rootfs_path.join("usr/etc/group").exists()); + assert!(rootfs_path.join("usr/etc/hosts").exists()); + assert!(rootfs_path.join("usr/etc/resolv.conf").exists()); + assert!(rootfs_path.join("usr/etc/nsswitch.conf").exists()); + } + #[test] fn test_oci_rootfs_builder_preserves_existing_passwd_and_group_entries() { let temp_dir = TempDir::new().unwrap(); @@ -625,6 +817,10 @@ mod tests { create_test_oci_image_with_files(path, &[(filename, content)]); } + fn create_test_oci_image_with_etc_symlink(path: &Path) { + create_test_oci_image_with_entries(path, &[], Some(("/usr/etc", "etc"))); + } + fn entry_count(content: &str, name: &str) -> usize { content .lines() @@ -633,6 +829,14 @@ mod tests { } fn create_test_oci_image_with_files(path: &Path, files: &[(&str, &[u8])]) { + create_test_oci_image_with_entries(path, files, None); + } + + fn create_test_oci_image_with_entries( + path: &Path, + files: &[(&str, &[u8])], + symlink: Option<(&str, &str)>, + ) { use flate2::write::GzEncoder; use flate2::Compression; use tar::Builder; @@ -661,6 +865,19 @@ mod tests { .append_data(&mut header, *filename, *content) .unwrap(); } + if let Some((target, link_name)) = symlink { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_uid(0); + header.set_gid(0); + header.set_link_name(target).unwrap(); + header.set_cksum(); + builder + .append_data(&mut header, link_name, std::io::empty()) + .unwrap(); + } builder.finish().unwrap(); } diff --git a/src/runtime/src/oci/store.rs b/src/runtime/src/oci/store.rs index 68b043cc..aab67b73 100644 --- a/src/runtime/src/oci/store.rs +++ b/src/runtime/src/oci/store.rs @@ -34,6 +34,10 @@ pub struct ImageStore { max_size_bytes: u64, } +fn state_dir_hint() -> &'static str { + "Set A3S_HOME to a writable directory to change the A3S Box state directory." +} + impl ImageStore { /// Create a new image store. /// @@ -42,9 +46,10 @@ impl ImageStore { pub fn new(store_dir: &Path, max_size_bytes: u64) -> Result { std::fs::create_dir_all(store_dir).map_err(|e| { BoxError::OciImageError(format!( - "Failed to create image store directory {}: {}", + "Failed to create image store directory {}: {}. {}", store_dir.display(), - e + e, + state_dir_hint() )) })?; @@ -389,8 +394,9 @@ impl ImageStore { .map_err(|e| BoxError::OciImageError(format!("index lock task failed: {e}")))? .map_err(|e| { BoxError::OciImageError(format!( - "failed to lock image index {}: {e}", - index_path.display() + "failed to lock image index {}: {e}. {}", + index_path.display(), + state_dir_hint() )) })? }; @@ -422,18 +428,20 @@ impl ImageStore { let tmp_path = self.store_dir.join("index.json.tmp"); tokio::fs::write(&tmp_path, data).await.map_err(|e| { BoxError::OciImageError(format!( - "Failed to write image store index {}: {}", + "Failed to write image store index {}: {}. {}", tmp_path.display(), - e + e, + state_dir_hint() )) })?; tokio::fs::rename(&tmp_path, &index_path) .await .map_err(|e| { BoxError::OciImageError(format!( - "Failed to commit image store index {}: {}", + "Failed to commit image store index {}: {}. {}", index_path.display(), - e + e, + state_dir_hint() )) })?; diff --git a/src/runtime/src/pool/client.rs b/src/runtime/src/pool/client.rs new file mode 100644 index 00000000..39b50586 --- /dev/null +++ b/src/runtime/src/pool/client.rs @@ -0,0 +1,498 @@ +//! Socket protocol and client helpers for the warm-pool daemon. + +use a3s_box_core::error::{BoxError, Result}; +use serde::{Deserialize, Serialize}; + +/// Wire protocol for the `pool` Unix socket. +/// +/// Client→daemon request: run a one-shot command, query status, stop the +/// daemon, or manage a short-lived leased VM session. +#[derive(Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum PoolRequest { + Run(PoolRunRequest), + Status, + Stop, + Lease(PoolLeaseRequest), + Exec(PoolLeaseExecRequest), + Release(PoolLeaseReleaseRequest), +} + +#[derive(Serialize, Deserialize)] +pub struct PoolRunRequest { + /// Image to run in; `None` means use the daemon's default image. + #[serde(default)] + pub image: Option, + /// User to run as (uid[:gid] or name); `None` runs as the image default. + #[serde(default)] + pub user: Option, + /// Working directory inside the sandbox. + #[serde(default)] + pub workdir: Option, + /// Optional guest-visible rootfs to chroot into before executing. + #[serde(default)] + pub rootfs: Option, + /// Extra KEY=VALUE environment entries. + #[serde(default)] + pub env: Vec, + /// Boot-time volume specs for this sandbox pool. + #[serde(default)] + pub volumes: Vec, + /// Boot-time vCPU count for lazily-created pools. + #[serde(default)] + pub vcpus: Option, + /// Boot-time memory size for lazily-created pools. + #[serde(default)] + pub memory_mb: Option, + /// Force exec mode for this request. + #[serde(default)] + pub exec: bool, + /// Guest-side execution timeout in nanoseconds. + #[serde(default)] + pub timeout_ns: Option, + pub cmd: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct PoolRunResponse { + pub stdout: Vec, + pub stderr: Vec, + pub exit_code: i32, + pub error: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct PoolLeaseRequest { + /// Image for the helper VM; `None` means use the daemon's default image. + #[serde(default)] + pub image: Option, + /// Boot-time volume specs for this leased VM. + #[serde(default)] + pub volumes: Vec, + /// Boot-time vCPU count. + #[serde(default)] + pub vcpus: Option, + /// Boot-time memory size. + #[serde(default)] + pub memory_mb: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct PoolLeaseResponse { + pub lease_id: Option, + pub error: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct PoolLeaseExecRequest { + pub lease_id: String, + pub cmd: Vec, + #[serde(default)] + pub timeout_ns: Option, + #[serde(default)] + pub env: Vec, + #[serde(default)] + pub working_dir: Option, + #[serde(default)] + pub rootfs: Option, + #[serde(default)] + pub stdin: Option>, + #[serde(default)] + pub user: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct PoolLeaseReleaseRequest { + pub lease_id: String, +} + +#[derive(Serialize, Deserialize)] +pub struct PoolLeaseReleaseResponse { + pub error: Option, +} + +/// Live stats for one image's warm pool. +#[derive(Serialize, Deserialize)] +pub struct PoolImageStat { + pub image: String, + pub pool: String, + /// Maximum concurrent sandboxes for this pool key. + #[serde(default)] + pub max: usize, + pub idle: usize, + /// Sandboxes currently checked out by one-shot runs or leases. + #[serde(default)] + pub active: usize, + /// Active sandboxes held by lease clients. + #[serde(default)] + pub leased: usize, + pub total_created: u64, + pub total_acquired: u64, + pub total_evicted: u64, +} + +#[derive(Serialize, Deserialize)] +pub struct PoolStatusResponse { + pub images: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct PoolStopResponse { + pub error: Option, +} + +pub struct PoolClientRun { + pub socket: String, + pub image: Option, + pub user: Option, + pub workdir: Option, + pub rootfs: Option, + pub env: Vec, + pub volumes: Vec, + pub vcpus: u32, + pub memory_mb: u32, + pub exec: bool, + pub timeout_ns: Option, + pub cmd: Vec, +} + +pub struct PoolClientOutput { + pub stdout: Vec, + pub stderr: Vec, + pub exit_code: i32, +} + +pub struct PoolLeaseClient { + socket: String, + lease_id: String, + released: bool, +} + +impl PoolLeaseClient { + pub fn lease_id(&self) -> &str { + &self.lease_id + } + + pub async fn acquire(req: PoolClientLease) -> Result { + let response = lease_client(&req).await?; + let lease_id = response.lease_id.ok_or_else(|| { + BoxError::PoolError("pool lease response did not include a lease id".to_string()) + })?; + Ok(Self { + socket: req.socket, + lease_id, + released: false, + }) + } + + pub async fn exec(&self, req: PoolLeaseExec) -> Result { + lease_exec_client( + &self.socket, + PoolLeaseExecRequest { + lease_id: self.lease_id.clone(), + cmd: req.cmd, + timeout_ns: req.timeout_ns, + env: req.env, + working_dir: req.working_dir, + rootfs: req.rootfs, + stdin: req.stdin, + user: req.user, + }, + ) + .await + } + + pub async fn release(mut self) -> Result<()> { + let result = release_client(&self.socket, &self.lease_id).await; + if result.is_ok() { + self.released = true; + } + result + } +} + +impl Drop for PoolLeaseClient { + fn drop(&mut self) { + if self.released { + return; + } + #[cfg(not(windows))] + release_client_blocking_best_effort(&self.socket, &self.lease_id); + } +} + +pub struct PoolClientLease { + pub socket: String, + pub image: Option, + pub volumes: Vec, + pub vcpus: u32, + pub memory_mb: u32, +} + +pub struct PoolLeaseExec { + pub cmd: Vec, + pub timeout_ns: Option, + pub env: Vec, + pub working_dir: Option, + pub rootfs: Option, + pub stdin: Option>, + pub user: Option, +} + +#[cfg(not(windows))] +pub async fn run_client(req: PoolClientRun) -> Result { + use tokio::net::UnixStream; + + let mut stream = UnixStream::connect(&req.socket).await.map_err(|e| { + BoxError::PoolError(format!( + "Failed to connect to pool daemon at {} ({}). Is `a3s-box pool start` running?", + req.socket, e + )) + })?; + + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRequest::Run(PoolRunRequest { + image: req.image, + user: req.user, + workdir: req.workdir, + rootfs: req.rootfs, + env: req.env, + volumes: req.volumes, + vcpus: Some(req.vcpus), + memory_mb: Some(req.memory_mb), + exec: req.exec, + timeout_ns: req.timeout_ns, + cmd: req.cmd, + }))?, + ) + .await?; + let resp: PoolRunResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?; + + if let Some(err) = resp.error { + return Err(BoxError::PoolError(err)); + } + + Ok(PoolClientOutput { + stdout: resp.stdout, + stderr: resp.stderr, + exit_code: resp.exit_code, + }) +} + +#[cfg(windows)] +pub async fn run_client(_req: PoolClientRun) -> Result { + Err(BoxError::PoolError( + "`pool run` is not supported on Windows".to_string(), + )) +} + +#[cfg(not(windows))] +pub async fn status_client(socket: &str) -> Result { + use tokio::net::UnixStream; + + let mut stream = UnixStream::connect(socket).await.map_err(|e| { + BoxError::PoolError(format!("Failed to connect to pool daemon at {socket}: {e}")) + })?; + write_frame(&mut stream, &serde_json::to_vec(&PoolRequest::Status)?).await?; + Ok(serde_json::from_slice(&read_frame(&mut stream).await?)?) +} + +#[cfg(not(windows))] +pub async fn stop_client(socket: &str) -> Result<()> { + use tokio::net::UnixStream; + + let mut stream = UnixStream::connect(socket).await.map_err(|e| { + BoxError::PoolError(format!("Failed to connect to pool daemon at {socket}: {e}")) + })?; + write_frame(&mut stream, &serde_json::to_vec(&PoolRequest::Stop)?).await?; + let resp: PoolStopResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?; + if let Some(error) = resp.error { + return Err(BoxError::PoolError(error)); + } + Ok(()) +} + +#[cfg(windows)] +pub async fn stop_client(_socket: &str) -> Result<()> { + Err(BoxError::PoolError( + "`pool stop` is not supported on Windows".to_string(), + )) +} + +#[cfg(not(windows))] +async fn lease_client(req: &PoolClientLease) -> Result { + use tokio::net::UnixStream; + + let mut stream = UnixStream::connect(&req.socket).await.map_err(|e| { + BoxError::PoolError(format!( + "Failed to connect to pool daemon at {} ({}). Is `a3s-box pool start` running?", + req.socket, e + )) + })?; + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRequest::Lease(PoolLeaseRequest { + image: req.image.clone(), + volumes: req.volumes.clone(), + vcpus: Some(req.vcpus), + memory_mb: Some(req.memory_mb), + }))?, + ) + .await?; + let resp: PoolLeaseResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?; + if let Some(error) = resp.error.as_ref() { + return Err(BoxError::PoolError(error.clone())); + } + Ok(resp) +} + +#[cfg(windows)] +async fn lease_client(_req: &PoolClientLease) -> Result { + Err(BoxError::PoolError( + "warm-pool leases are not supported on Windows".to_string(), + )) +} + +#[cfg(not(windows))] +async fn lease_exec_client(socket: &str, req: PoolLeaseExecRequest) -> Result { + use tokio::net::UnixStream; + + let mut stream = UnixStream::connect(socket).await.map_err(|e| { + BoxError::PoolError(format!("Failed to connect to pool daemon at {socket}: {e}")) + })?; + write_frame(&mut stream, &serde_json::to_vec(&PoolRequest::Exec(req))?).await?; + let resp: PoolRunResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?; + if let Some(error) = resp.error { + return Err(BoxError::PoolError(error)); + } + Ok(PoolClientOutput { + stdout: resp.stdout, + stderr: resp.stderr, + exit_code: resp.exit_code, + }) +} + +#[cfg(windows)] +async fn lease_exec_client(_socket: &str, _req: PoolLeaseExecRequest) -> Result { + Err(BoxError::PoolError( + "warm-pool leases are not supported on Windows".to_string(), + )) +} + +#[cfg(not(windows))] +async fn release_client(socket: &str, lease_id: &str) -> Result<()> { + use tokio::net::UnixStream; + + let mut stream = UnixStream::connect(socket).await.map_err(|e| { + BoxError::PoolError(format!("Failed to connect to pool daemon at {socket}: {e}")) + })?; + write_frame( + &mut stream, + &serde_json::to_vec(&PoolRequest::Release(PoolLeaseReleaseRequest { + lease_id: lease_id.to_string(), + }))?, + ) + .await?; + let resp: PoolLeaseReleaseResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?; + if let Some(error) = resp.error { + return Err(BoxError::PoolError(error)); + } + Ok(()) +} + +#[cfg(windows)] +async fn release_client(_socket: &str, _lease_id: &str) -> Result<()> { + Err(BoxError::PoolError( + "warm-pool leases are not supported on Windows".to_string(), + )) +} + +#[cfg(not(windows))] +fn release_client_blocking_best_effort(socket: &str, lease_id: &str) { + use std::io::Write; + use std::os::unix::net::UnixStream; + use std::time::Duration; + + let Ok(mut stream) = UnixStream::connect(socket) else { + return; + }; + let timeout = Some(Duration::from_millis(500)); + let _ = stream.set_read_timeout(timeout); + let _ = stream.set_write_timeout(timeout); + + let Ok(payload) = serde_json::to_vec(&PoolRequest::Release(PoolLeaseReleaseRequest { + lease_id: lease_id.to_string(), + })) else { + return; + }; + let _ = stream + .write_all(&(payload.len() as u32).to_le_bytes()) + .and_then(|_| stream.write_all(&payload)) + .and_then(|_| stream.flush()); +} + +/// Length-prefixed (u32 LE) framing for the pool Unix-socket protocol. +#[cfg(not(windows))] +pub async fn write_frame(w: &mut W, data: &[u8]) -> std::io::Result<()> +where + W: tokio::io::AsyncWrite + Unpin, +{ + use tokio::io::AsyncWriteExt; + + w.write_all(&(data.len() as u32).to_le_bytes()).await?; + w.write_all(data).await?; + w.flush().await +} + +#[cfg(not(windows))] +pub async fn read_frame(r: &mut R) -> std::io::Result> +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + + let mut len = [0u8; 4]; + r.read_exact(&mut len).await?; + let mut buf = vec![0u8; u32::from_le_bytes(len) as usize]; + r.read_exact(&mut buf).await?; + Ok(buf) +} + +#[cfg(test)] +mod tests { + #[cfg(not(windows))] + #[test] + fn lease_drop_releases_synchronously() { + use super::*; + use std::io::Read; + use std::os::unix::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let socket = tmp.path().join("pool.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let socket_arg = socket.to_string_lossy().to_string(); + + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut len = [0_u8; 4]; + stream.read_exact(&mut len).unwrap(); + let mut request = vec![0_u8; u32::from_le_bytes(len) as usize]; + stream.read_exact(&mut request).unwrap(); + let request: PoolRequest = serde_json::from_slice(&request).unwrap(); + match request { + PoolRequest::Release(req) => assert_eq!(req.lease_id, "lease-drop"), + _ => panic!("drop should send release request"), + } + }); + + let lease = PoolLeaseClient { + socket: socket_arg, + lease_id: "lease-drop".to_string(), + released: false, + }; + drop(lease); + + server.join().unwrap(); + } +} diff --git a/src/runtime/src/pool/mod.rs b/src/runtime/src/pool/mod.rs index f1b81ec0..dadb59e1 100644 --- a/src/runtime/src/pool/mod.rs +++ b/src/runtime/src/pool/mod.rs @@ -3,8 +3,15 @@ //! Pre-boots MicroVMs so that `acquire()` returns an already-ready VM //! instead of waiting for the full boot sequence. +pub mod client; pub mod scaler; pub mod warm_pool; +pub use client::{ + PoolClientLease, PoolClientOutput, PoolClientRun, PoolImageStat, PoolLeaseClient, + PoolLeaseExec, PoolLeaseExecRequest, PoolLeaseReleaseRequest, PoolLeaseReleaseResponse, + PoolLeaseRequest, PoolLeaseResponse, PoolRequest, PoolRunRequest, PoolRunResponse, + PoolStatusResponse, PoolStopResponse, +}; pub use scaler::{PoolScaler, ScaleDecision}; pub use warm_pool::{PoolStats, WarmPool}; diff --git a/src/runtime/src/pool/warm_pool.rs b/src/runtime/src/pool/warm_pool.rs index 665ec963..dac43486 100644 --- a/src/runtime/src/pool/warm_pool.rs +++ b/src/runtime/src/pool/warm_pool.rs @@ -484,6 +484,8 @@ impl WarmPool { cfg.snapshot_sock = None; let mut vm = VmManager::new(cfg, event_emitter.clone()); vm.boot().await?; + vm.wait_for_exec_available(std::time::Duration::from_secs(120)) + .await?; return Ok(vm); } Err(error) => { @@ -493,6 +495,8 @@ impl WarmPool { } let mut vm = VmManager::new(box_config.clone(), event_emitter.clone()); vm.boot().await?; + vm.wait_for_exec_available(std::time::Duration::from_secs(120)) + .await?; Ok(vm) } diff --git a/src/runtime/src/process.rs b/src/runtime/src/process.rs new file mode 100644 index 00000000..736aad37 --- /dev/null +++ b/src/runtime/src/process.rs @@ -0,0 +1,170 @@ +//! Host process identity helpers shared by runtime consumers. + +/// Check whether a host process exists. +/// +/// On Unix, `EPERM` still means the process exists even though the caller is +/// not allowed to signal it. +#[cfg(unix)] +pub fn is_process_alive(pid: u32) -> bool { + let Ok(pid) = i32::try_from(pid) else { + return false; + }; + let result = unsafe { libc::kill(pid, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(windows)] +pub fn is_process_alive(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_INFORMATION, + }; + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid); + if handle == 0 { + return false; + } + let mut exit_code = 0u32; + let ok = GetExitCodeProcess(handle, &mut exit_code); + CloseHandle(handle); + ok != 0 && exit_code == STILL_ACTIVE as u32 + } +} + +#[cfg(not(any(unix, windows)))] +pub fn is_process_alive(_pid: u32) -> bool { + false +} + +/// Read a process's Linux start time as a stable PID identity token. +/// +/// The value is field 22 of `/proc//stat`, measured in clock ticks since +/// boot. It distinguishes a recorded process from a later process that reused +/// the same PID. Other platforms return `None` until they provide an equivalent +/// stable token. +#[cfg(target_os = "linux")] +pub fn pid_start_time(pid: u32) -> Option { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + linux_process_identity_from_stat(&stat).map(|(_, start_time)| start_time) +} + +#[cfg(not(target_os = "linux"))] +pub fn pid_start_time(_pid: u32) -> Option { + None +} + +/// Check process liveness and, when recorded, its stable identity token. +/// +/// Records created before PID identity tokens were introduced contain no +/// expected start time and retain their legacy liveness behavior. +pub fn is_process_alive_with_identity(pid: u32, expected_start_time: Option) -> bool { + if !is_process_alive(pid) { + return false; + } + + match expected_start_time { + Some(expected) => pid_start_time(pid) == Some(expected), + None => true, + } +} + +/// Check whether a process identity is actively running rather than a zombie. +/// +/// A completed child remains addressable by `kill(pid, 0)` until its parent +/// reaps it. Lifecycle ownership still uses [`is_process_alive_with_identity`] +/// when that distinction matters; completion waiters use this helper so a +/// fully drained worker zombie is treated as finished immediately. +#[cfg(target_os = "linux")] +pub fn is_process_running_with_identity(pid: u32, expected_start_time: Option) -> bool { + let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { + return false; + }; + linux_process_identity_from_stat(&stat).is_some_and(|(state, start_time)| { + state != 'Z' + && expected_start_time + .map(|expected| expected == start_time) + .unwrap_or(true) + }) +} + +#[cfg(not(target_os = "linux"))] +pub fn is_process_running_with_identity(pid: u32, expected_start_time: Option) -> bool { + is_process_alive_with_identity(pid, expected_start_time) +} + +#[cfg(target_os = "linux")] +fn linux_process_identity_from_stat(stat: &str) -> Option<(char, u64)> { + // `comm` may contain spaces and parentheses, so fields begin after the + // final `)`. Field 3 is then token zero and field 22 is token 19. + let fields: Vec<&str> = stat + .get(stat.rfind(')')? + 1..)? + .split_whitespace() + .collect(); + let state = fields.first()?.chars().next()?; + let start_time = fields.get(19)?.parse().ok()?; + Some((state, start_time)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_process_is_alive() { + assert!(is_process_alive(std::process::id())); + } + + #[test] + fn missing_process_is_not_alive() { + assert!(!is_process_alive(0x7fff_fffe)); + } + + #[cfg(target_os = "linux")] + #[test] + fn parses_start_time_after_complex_command_name() { + let stat = + "123 (command (with) spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 4242"; + assert_eq!(linux_process_identity_from_stat(stat), Some(('S', 4242))); + assert_eq!(linux_process_identity_from_stat("malformed"), None); + assert_eq!(linux_process_identity_from_stat("123 (short) S 1"), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn identity_rejects_a_reused_pid() { + let pid = std::process::id(); + let start_time = pid_start_time(pid); + assert!(start_time.is_some()); + assert!(is_process_alive_with_identity(pid, start_time)); + assert!(!is_process_alive_with_identity(pid, Some(u64::MAX))); + assert!(is_process_alive_with_identity(pid, None)); + assert!(!is_process_alive_with_identity(0x7fff_fffe, None)); + assert!(is_process_running_with_identity(pid, start_time)); + } + + #[cfg(target_os = "linux")] + #[test] + fn parses_zombie_state_for_completion_waiters() { + let stat = "123 (completed worker) Z 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 4242"; + assert_eq!(linux_process_identity_from_stat(stat), Some(('Z', 4242))); + } + + #[cfg(target_os = "linux")] + #[test] + fn running_identity_treats_an_unreaped_child_as_finished() { + let mut child = std::process::Command::new("true").spawn().unwrap(); + let pid = child.id(); + let start_time = pid_start_time(pid).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while is_process_running_with_identity(pid, Some(start_time)) + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + assert!(is_process_alive_with_identity(pid, Some(start_time))); + assert!(!is_process_running_with_identity(pid, Some(start_time))); + child.wait().unwrap(); + } +} diff --git a/src/runtime/src/resolved_image.rs b/src/runtime/src/resolved_image.rs new file mode 100644 index 00000000..e76f7145 --- /dev/null +++ b/src/runtime/src/resolved_image.rs @@ -0,0 +1,291 @@ +//! Durable resolved OCI image defaults used by filesystem snapshots. + +use std::io::Write; +use std::path::Path; + +use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::{SnapshotImageConfig, SnapshotImageHealthCheck, SnapshotMetadata}; +use serde::de::DeserializeOwned; + +use crate::oci::{OciHealthCheck, OciImageConfig}; + +/// Box-local artifact containing the resolved defaults from the source image. +pub const RESOLVED_IMAGE_CONFIG_FILE: &str = ".oci-image-config.json"; + +const MAX_IMAGE_CONFIG_BYTES: u64 = 1024 * 1024; + +/// Load the resolved image defaults persisted for a box. +/// +/// The artifact is independent of the control-plane process so a filesystem +/// snapshot created after a service restart retains the source image's OCI +/// entrypoint, command, environment, working directory, and user. +pub fn load_resolved_image_config(box_dir: &Path) -> Result> { + let path = box_dir.join(RESOLVED_IMAGE_CONFIG_FILE); + match std::fs::symlink_metadata(&path) { + Ok(_) => read_regular_json(&path, "resolved image configuration").map(Some), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(BoxError::ConfigError(format!( + "Failed to inspect resolved image configuration {}: {error}", + path.display() + ))), + } +} + +pub(crate) fn persist_resolved_image_config(box_dir: &Path, config: &OciImageConfig) -> Result<()> { + let config = SnapshotImageConfig::from(config); + let mut encoded = serde_json::to_vec_pretty(&config).map_err(|error| { + BoxError::SerializationError(format!( + "Failed to encode resolved image configuration: {error}" + )) + })?; + encoded.push(b'\n'); + + let destination = box_dir.join(RESOLVED_IMAGE_CONFIG_FILE); + let mut temporary = tempfile::NamedTempFile::new_in(box_dir).map_err(|error| { + BoxError::ConfigError(format!( + "Failed to create resolved image configuration beside {}: {error}", + destination.display() + )) + })?; + temporary.write_all(&encoded).map_err(|error| { + BoxError::ConfigError(format!( + "Failed to write resolved image configuration {}: {error}", + destination.display() + )) + })?; + temporary.as_file().sync_all().map_err(|error| { + BoxError::ConfigError(format!( + "Failed to sync resolved image configuration {}: {error}", + destination.display() + )) + })?; + temporary.persist(&destination).map_err(|error| { + BoxError::ConfigError(format!( + "Failed to publish resolved image configuration {}: {}", + destination.display(), + error.error + )) + })?; + if let Ok(directory) = std::fs::File::open(box_dir) { + let _ = directory.sync_all(); + } + Ok(()) +} + +pub(crate) fn load_snapshot_oci_config( + rootfs: &Path, + expected_image: &str, +) -> Result { + if rootfs.file_name().is_none_or(|name| name != "rootfs") { + return Err(BoxError::ConfigError(format!( + "Snapshot lower must end in rootfs: {}", + rootfs.display() + ))); + } + let snapshot_dir = rootfs.parent().ok_or_else(|| { + BoxError::ConfigError(format!( + "Snapshot lower has no snapshot directory: {}", + rootfs.display() + )) + })?; + let metadata_path = snapshot_dir.join("metadata.json"); + let metadata: SnapshotMetadata = + read_regular_json(&metadata_path, "filesystem snapshot metadata")?; + let directory_id = snapshot_dir.file_name().and_then(|value| value.to_str()); + if directory_id != Some(metadata.id.as_str()) { + return Err(BoxError::ConfigError(format!( + "Snapshot metadata identity does not match {}", + snapshot_dir.display() + ))); + } + if metadata.image != expected_image { + return Err(BoxError::ConfigError(format!( + "Snapshot image {} does not match requested image {expected_image}", + metadata.image + ))); + } + Ok(OciImageConfig::from( + metadata.require_image_config()?.clone(), + )) +} + +fn read_regular_json(path: &Path, description: &str) -> Result { + let file = std::fs::symlink_metadata(path).map_err(|error| { + BoxError::ConfigError(format!( + "Failed to inspect {description} {}: {error}", + path.display() + )) + })?; + if !file.file_type().is_file() || file.file_type().is_symlink() { + return Err(BoxError::ConfigError(format!( + "{description} is not a regular file: {}", + path.display() + ))); + } + if file.len() > MAX_IMAGE_CONFIG_BYTES { + return Err(BoxError::ConfigError(format!( + "{description} exceeds {MAX_IMAGE_CONFIG_BYTES} bytes: {}", + path.display() + ))); + } + let encoded = std::fs::read(path).map_err(|error| { + BoxError::ConfigError(format!( + "Failed to read {description} {}: {error}", + path.display() + )) + })?; + serde_json::from_slice(&encoded).map_err(|error| { + BoxError::SerializationError(format!( + "Failed to parse {description} {}: {error}", + path.display() + )) + }) +} + +impl From<&OciImageConfig> for SnapshotImageConfig { + fn from(config: &OciImageConfig) -> Self { + Self { + entrypoint: config.entrypoint.clone(), + cmd: config.cmd.clone(), + env: config.env.clone(), + working_dir: config.working_dir.clone(), + user: config.user.clone(), + exposed_ports: config.exposed_ports.clone(), + labels: config.labels.clone(), + volumes: config.volumes.clone(), + stop_signal: config.stop_signal.clone(), + health_check: config + .health_check + .as_ref() + .map(SnapshotImageHealthCheck::from), + onbuild: config.onbuild.clone(), + } + } +} + +impl From<&OciHealthCheck> for SnapshotImageHealthCheck { + fn from(health_check: &OciHealthCheck) -> Self { + Self { + test: health_check.test.clone(), + interval: health_check.interval, + timeout: health_check.timeout, + retries: health_check.retries, + start_period: health_check.start_period, + } + } +} + +impl From for OciImageConfig { + fn from(config: SnapshotImageConfig) -> Self { + Self { + entrypoint: config.entrypoint, + cmd: config.cmd, + env: config.env, + working_dir: config.working_dir, + user: config.user, + exposed_ports: config.exposed_ports, + labels: config.labels, + volumes: config.volumes, + stop_signal: config.stop_signal, + health_check: config.health_check.map(OciHealthCheck::from), + onbuild: config.onbuild, + } + } +} + +impl From for OciHealthCheck { + fn from(health_check: SnapshotImageHealthCheck) -> Self { + Self { + test: health_check.test, + interval: health_check.interval, + timeout: health_check.timeout, + retries: health_check.retries, + start_period: health_check.start_period, + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn image_config() -> OciImageConfig { + OciImageConfig { + entrypoint: Some(vec!["/usr/local/bin/envd".to_string()]), + cmd: Some(vec!["--port".to_string(), "49983".to_string()]), + env: vec![("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string())], + working_dir: Some("/home/user".to_string()), + user: Some("1000:1000".to_string()), + exposed_ports: vec!["49983/tcp".to_string()], + labels: HashMap::from([("runtime".to_string(), "envd".to_string())]), + volumes: vec!["/home/user".to_string()], + stop_signal: Some("SIGTERM".to_string()), + health_check: Some(OciHealthCheck { + test: vec!["CMD".to_string(), "envd-health".to_string()], + interval: Some(10), + timeout: Some(2), + retries: Some(3), + start_period: Some(5), + }), + onbuild: vec!["RUN prepare-runtime".to_string()], + } + } + + #[test] + fn resolved_image_config_round_trips_through_the_snapshot_schema() { + let original = image_config(); + let restored = OciImageConfig::from(SnapshotImageConfig::from(&original)); + + assert_eq!(restored.entrypoint, original.entrypoint); + assert_eq!(restored.cmd, original.cmd); + assert_eq!(restored.env, original.env); + assert_eq!(restored.working_dir, original.working_dir); + assert_eq!(restored.user, original.user); + assert_eq!(restored.exposed_ports, original.exposed_ports); + assert_eq!(restored.labels, original.labels); + assert_eq!(restored.volumes, original.volumes); + assert_eq!(restored.stop_signal, original.stop_signal); + assert_eq!(restored.health_check, original.health_check); + assert_eq!(restored.onbuild, original.onbuild); + } + + #[test] + fn box_image_config_artifact_survives_process_local_state() { + let directory = tempfile::tempdir().unwrap(); + let original = image_config(); + + persist_resolved_image_config(directory.path(), &original).unwrap(); + let loaded = load_resolved_image_config(directory.path()) + .unwrap() + .unwrap(); + + assert_eq!(loaded, SnapshotImageConfig::from(&original)); + } + + #[test] + fn legacy_snapshot_without_image_config_fails_closed() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("state.txt"), "captured").unwrap(); + let store = crate::SnapshotStore::new(&directory.path().join("snapshots")).unwrap(); + store + .save( + SnapshotMetadata::new( + "legacy-snapshot".to_string(), + "legacy-snapshot".to_string(), + "source-execution".to_string(), + "alpine:3.20".to_string(), + ), + &source, + ) + .unwrap(); + + let error = load_snapshot_oci_config(&store.rootfs_path("legacy-snapshot"), "alpine:3.20") + .unwrap_err(); + + assert!(format!("{error}").contains("resolved OCI image configuration")); + } +} diff --git a/src/runtime/src/rootfs/mod.rs b/src/runtime/src/rootfs/mod.rs index 70024bb2..625f4b56 100644 --- a/src/runtime/src/rootfs/mod.rs +++ b/src/runtime/src/rootfs/mod.rs @@ -18,6 +18,81 @@ pub use provider::{default_provider, CopyProvider, OverlayProvider, RootfsProvid use std::path::Path; +/// Read the exit code persisted by guest-init from the active writable rootfs. +/// +/// Rootfs providers expose `/.a3s_exit_code` at different host paths: the +/// overlay upper directory on Linux, the copied rootfs fallback, or the private +/// data directory inside the case-sensitive APFS mount on macOS. +pub fn read_persisted_exit_code(box_dir: &Path) -> Option { + let candidates = [ + box_dir.join("upper").join(".a3s_exit_code"), + box_dir + .join("rootfs") + .join(".a3s-rootfs") + .join(".a3s_exit_code"), + box_dir.join("rootfs").join(".a3s_exit_code"), + ]; + + candidates.into_iter().find_map(|path| { + std::fs::read_to_string(path) + .ok() + .and_then(|contents| contents.trim().parse::().ok()) + }) +} + +/// A temporarily attached persistent rootfs. +/// +/// Dropping this guard detaches only mounts created by +/// [`attach_persistent_rootfs`]. An already mounted rootfs is left untouched. +pub struct AttachedRootfs { + path: std::path::PathBuf, + detach_on_drop: bool, +} + +impl AttachedRootfs { + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for AttachedRootfs { + fn drop(&mut self) { + if self.detach_on_drop { + unmount_box_rootfs(&self.path); + } + } +} + +/// Attach an existing platform-backed persistent rootfs for offline access. +/// +/// Returns `None` when the box has no platform-specific backing image. This +/// never creates a new image, so callers cannot accidentally commit an empty +/// filesystem when a backing image is missing. +pub fn attach_persistent_rootfs( + box_dir: &Path, +) -> a3s_box_core::error::Result> { + #[cfg(target_os = "macos")] + { + let image = box_dir.join("rootfs-apfs-v2.sparseimage"); + if !image.is_file() { + return Ok(None); + } + let rootfs = box_dir.join("rootfs"); + let was_mounted = is_mountpoint(&rootfs); + let path = provider::CaseSensitiveApfsProvider.prepare_empty(box_dir)?; + Ok(Some(AttachedRootfs { + path, + detach_on_drop: !was_mounted, + })) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = box_dir; + Ok(None) + } +} + /// Unmount a box's overlayfs `merged` view — best-effort and idempotent. /// /// Box teardown must release this mount BEFORE removing the box dir, or @@ -37,7 +112,7 @@ pub fn unmount_box_overlay(merged: &Path) { } /// True if `path` is a mountpoint (its device id differs from its parent's). -#[cfg(target_os = "linux")] +#[cfg(unix)] pub(crate) fn is_mountpoint(path: &Path) -> bool { use std::os::unix::fs::MetadataExt; match (std::fs::metadata(path), std::fs::metadata(path.join(".."))) { @@ -46,15 +121,81 @@ pub(crate) fn is_mountpoint(path: &Path) -> bool { } } -#[cfg(not(target_os = "linux"))] +#[cfg(not(unix))] pub(crate) fn is_mountpoint(_path: &Path) -> bool { false } +/// Unmount a platform-specific writable rootfs mount. +pub fn unmount_box_rootfs(rootfs: &Path) { + #[cfg(target_os = "macos")] + { + // The case-sensitive provider returns `/.a3s-rootfs`, keeping + // APFS-created volume metadata outside the Linux tree. Accept either + // that data path or the mountpoint itself at cleanup call sites. + let mountpoint = if rootfs.file_name().is_some_and(|name| name == ".a3s-rootfs") { + rootfs.parent().unwrap_or(rootfs) + } else { + rootfs + }; + if !is_mountpoint(mountpoint) { + return; + } + match std::process::Command::new("hdiutil") + .arg("detach") + .arg("-quiet") + .arg(mountpoint) + .status() + { + Ok(status) if status.success() => {} + Ok(status) => tracing::warn!( + path = %mountpoint.display(), + ?status, + "Failed to detach case-sensitive rootfs image" + ), + Err(error) => tracing::warn!( + path = %mountpoint.display(), + %error, + "Failed to run hdiutil detach" + ), + } + } + + #[cfg(not(target_os = "macos"))] + let _ = rootfs; +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn persisted_exit_code_supports_each_rootfs_provider_layout() { + for (relative, expected) in [ + ("upper/.a3s_exit_code", 17), + ("rootfs/.a3s_exit_code", 23), + ("rootfs/.a3s-rootfs/.a3s_exit_code", 29), + ] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, format!("{expected}\n")).unwrap(); + + assert_eq!(read_persisted_exit_code(temp.path()), Some(expected)); + } + } + + #[test] + fn persisted_exit_code_ignores_missing_or_invalid_files() { + let temp = tempfile::tempdir().unwrap(); + assert_eq!(read_persisted_exit_code(temp.path()), None); + + let path = temp.path().join("rootfs/.a3s_exit_code"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "not-an-exit-code").unwrap(); + assert_eq!(read_persisted_exit_code(temp.path()), None); + } + #[test] fn missing_path_is_not_mountpoint() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/runtime/src/rootfs/overlay.rs b/src/runtime/src/rootfs/overlay.rs index 47f55b20..e08471cd 100644 --- a/src/runtime/src/rootfs/overlay.rs +++ b/src/runtime/src/rootfs/overlay.rs @@ -27,83 +27,116 @@ pub fn overlay_mount(lower: &Path, upper: &Path, work: &Path, merged: &Path) -> } } - let opts = format!( - "lowerdir={},upperdir={},workdir={}", - lower.display(), - upper.display(), - work.display() - ); + let base_options = overlay_options(lower, upper, work, false); // Try mount(2) syscall first #[cfg(target_os = "linux")] { use std::ffi::CString; + // Metadata-only copy-up avoids copying every executable's contents + // when Sandbox ownership is shifted for its user namespace. Restrict + // it to the initial root namespace: rootless overlay uses user.* + // private xattrs that an untrusted workload could forge. OCI ingestion + // separately rejects both trusted.overlay.* and user.overlay.* xattrs. + let metadata_options = overlay_options(lower, upper, work, true); + let options = if unsafe { libc::geteuid() } == 0 { + vec![(&metadata_options, true), (&base_options, false)] + } else { + vec![(&base_options, false)] + }; let source = CString::new("overlay").unwrap(); let target = CString::new(merged.to_string_lossy().as_ref()) .map_err(|e| BoxError::BuildError(format!("Invalid merged path for mount: {}", e)))?; let fstype = CString::new("overlay").unwrap(); - let data = CString::new(opts.as_str()) - .map_err(|e| BoxError::BuildError(format!("Invalid overlay mount options: {}", e)))?; - - let ret = unsafe { - libc::mount( - source.as_ptr(), - target.as_ptr(), - fstype.as_ptr(), - 0, - data.as_ptr() as *const libc::c_void, - ) - }; - - if ret == 0 { - tracing::debug!( - lower = %lower.display(), - merged = %merged.display(), - "Overlay mounted via mount(2)" - ); - return Ok(()); + let mut failures = Vec::new(); + + for &(options, metadata_copy) in &options { + let data = CString::new(options.as_str()).map_err(|error| { + BoxError::BuildError(format!("Invalid overlay mount options: {error}")) + })?; + let ret = unsafe { + libc::mount( + source.as_ptr(), + target.as_ptr(), + fstype.as_ptr(), + 0, + data.as_ptr() as *const libc::c_void, + ) + }; + if ret == 0 { + tracing::debug!( + lower = %lower.display(), + merged = %merged.display(), + metadata_copy, + "Overlay mounted via mount(2)" + ); + return Ok(()); + } + failures.push(format!( + "mount(2), metacopy={metadata_copy}: {}", + std::io::Error::last_os_error() + )); } - let errno = std::io::Error::last_os_error(); tracing::debug!( - error = %errno, + errors = ?failures, "mount(2) failed, trying mount command" ); - // Fallback: try `mount` command - let status = std::process::Command::new("mount") - .args(["-t", "overlay", "overlay", "-o", &opts]) - .arg(merged) - .status() - .map_err(|e| BoxError::BuildError(format!("Failed to run mount command: {}", e)))?; - - if status.success() { - tracing::debug!( - lower = %lower.display(), - merged = %merged.display(), - "Overlay mounted via mount command" - ); - return Ok(()); + for &(options, metadata_copy) in &options { + match std::process::Command::new("mount") + .args(["-t", "overlay", "overlay", "-o", options]) + .arg(merged) + .status() + { + Ok(status) if status.success() => { + tracing::debug!( + lower = %lower.display(), + merged = %merged.display(), + metadata_copy, + "Overlay mounted via mount command" + ); + return Ok(()); + } + Ok(status) => { + failures.push(format!("mount command, metacopy={metadata_copy}: {status}")) + } + Err(error) => { + failures.push(format!("mount command, metacopy={metadata_copy}: {error}")) + } + } } Err(BoxError::BuildError(format!( - "Failed to mount overlayfs at {}: mount(2) returned {} and mount command exited with {}", + "Failed to mount overlayfs at {}: {}", merged.display(), - errno, - status + failures.join("; ") ))) } #[cfg(not(target_os = "linux"))] { - let _ = (lower, upper, work, merged, opts); + let _ = (lower, upper, work, merged, base_options); Err(BoxError::BuildError( "Overlayfs is only supported on Linux".to_string(), )) } } +fn overlay_options(lower: &Path, upper: &Path, work: &Path, metadata_copy: bool) -> String { + let mut options = format!( + "lowerdir={},upperdir={},workdir={}", + lower.display(), + upper.display(), + work.display() + ); + if metadata_copy { + options.push_str(",metacopy=on"); + } + options +} + /// Unmount an overlayfs at `merged`. pub fn overlay_unmount(merged: &Path) -> Result<()> { #[cfg(target_os = "linux")] @@ -192,6 +225,7 @@ pub(crate) fn is_overlay_supported() -> bool { /// /// Always returns `false` on non-Linux platforms. #[cfg(not(target_os = "linux"))] +#[allow(dead_code)] pub(crate) fn is_overlay_supported() -> bool { false } @@ -239,6 +273,22 @@ mod tests { assert!(err.to_string().contains("lower,with-comma")); } + #[test] + fn metadata_copy_option_is_explicit() { + let lower = Path::new("/cache/lower"); + let upper = Path::new("/box/upper"); + let work = Path::new("/box/work"); + + assert_eq!( + overlay_options(lower, upper, work, false), + "lowerdir=/cache/lower,upperdir=/box/upper,workdir=/box/work" + ); + assert_eq!( + overlay_options(lower, upper, work, true), + "lowerdir=/cache/lower,upperdir=/box/upper,workdir=/box/work,metacopy=on" + ); + } + #[cfg(not(target_os = "linux"))] #[test] fn test_overlay_unmount_noop_on_non_linux() { diff --git a/src/runtime/src/rootfs/provider.rs b/src/runtime/src/rootfs/provider.rs index 1e3f37d9..4ec90cbb 100644 --- a/src/runtime/src/rootfs/provider.rs +++ b/src/runtime/src/rootfs/provider.rs @@ -14,6 +14,18 @@ pub trait RootfsProvider: Send + Sync { /// Returns the path to use as `InstanceSpec.rootfs_path`. fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result; + /// Prepare an empty writable rootfs for an OCI cache miss. + fn prepare_empty(&self, box_dir: &Path) -> Result { + let rootfs = box_dir.join("rootfs"); + std::fs::create_dir_all(&rootfs).map_err(|error| { + BoxError::BuildError(format!( + "Failed to create rootfs {}: {error}", + rootfs.display() + )) + })?; + Ok(rootfs) + } + /// Cleanup after box stops. /// /// When `persistent` is true, the writable layer (overlay upper dir or copy @@ -66,6 +78,185 @@ impl RootfsProvider for CopyProvider { } } +/// A copy provider backed by a case-sensitive APFS sparse image. +/// +/// macOS commonly stores `~/.a3s` on case-insensitive APFS. Passing a normal +/// host directory to libkrun as the guest root would then make Linux paths such +/// as `/bin` and `/BIN` aliases. Each box therefore owns a sparse, dynamically +/// allocated case-sensitive APFS image and exposes its mountpoint via virtiofs. +#[cfg(target_os = "macos")] +pub struct CaseSensitiveApfsProvider; + +#[cfg(target_os = "macos")] +impl CaseSensitiveApfsProvider { + // v2 stores the Linux tree below a private directory inside the volume. + // APFS creates volume-management entries such as `.fseventsd` at the + // volume root; exposing that root to the guest both leaks host artifacts + // and can make recursive rootfs walks fail with EACCES. + const IMAGE_STEM: &'static str = "rootfs-apfs-v2"; + const IMAGE_NAME: &'static str = "rootfs-apfs-v2.sparseimage"; + const DATA_DIR: &'static str = ".a3s-rootfs"; + + fn clone_image(source: &Path, destination: &Path) -> Result<()> { + let output = std::process::Command::new("cp") + .arg("-c") + .arg(source) + .arg(destination) + .output() + .map_err(|error| { + BoxError::BuildError(format!("Failed to start APFS clone: {error}")) + })?; + if !output.status.success() { + return Err(BoxError::BuildError(format!( + "Failed to clone cached APFS rootfs {}: {}", + source.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(()) + } + + fn mount(&self, box_dir: &Path) -> Result { + use std::process::Command; + + std::fs::create_dir_all(box_dir).map_err(|error| { + BoxError::BuildError(format!( + "Failed to create box directory {}: {error}", + box_dir.display() + )) + })?; + let rootfs = box_dir.join("rootfs"); + std::fs::create_dir_all(&rootfs).map_err(|error| { + BoxError::BuildError(format!( + "Failed to create APFS mountpoint {}: {error}", + rootfs.display() + )) + })?; + if super::is_mountpoint(&rootfs) { + return Self::data_dir(&rootfs); + } + + let image = box_dir.join(Self::IMAGE_NAME); + if !image.exists() { + let stem = box_dir.join(Self::IMAGE_STEM); + let output = Command::new("hdiutil") + .args([ + "create", + "-quiet", + "-size", + "64g", + "-type", + "SPARSE", + "-fs", + "Case-sensitive APFS", + "-volname", + "A3SRootfs", + ]) + .arg(&stem) + .output() + .map_err(|error| { + BoxError::BuildError(format!("Failed to start hdiutil create: {error}")) + })?; + if !output.status.success() { + return Err(BoxError::BuildError(format!( + "Failed to create case-sensitive APFS rootfs image: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + } + + let output = Command::new("hdiutil") + .args([ + "attach", + "-quiet", + "-nobrowse", + "-owners", + "on", + "-mountpoint", + ]) + .arg(&rootfs) + .arg(&image) + .output() + .map_err(|error| { + BoxError::BuildError(format!("Failed to start hdiutil attach: {error}")) + })?; + if !output.status.success() { + return Err(BoxError::BuildError(format!( + "Failed to mount case-sensitive APFS rootfs image {}: {}", + image.display(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + if !super::is_mountpoint(&rootfs) { + return Err(BoxError::BuildError(format!( + "hdiutil did not mount the rootfs image at {}", + rootfs.display() + ))); + } + Self::data_dir(&rootfs) + } + + fn data_dir(mountpoint: &Path) -> Result { + let data = mountpoint.join(Self::DATA_DIR); + std::fs::create_dir_all(&data).map_err(|error| { + BoxError::BuildError(format!( + "Failed to create APFS rootfs data directory {}: {error}", + data.display() + )) + })?; + Ok(data) + } +} + +#[cfg(target_os = "macos")] +impl RootfsProvider for CaseSensitiveApfsProvider { + fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result { + let image = box_dir.join(Self::IMAGE_NAME); + if cache_dir.is_file() && !image.exists() { + std::fs::create_dir_all(box_dir).map_err(BoxError::IoError)?; + Self::clone_image(cache_dir, &image)?; + } + let rootfs = self.mount(box_dir)?; + if cache_dir.is_file() { + return Ok(rootfs); + } + if std::fs::read_dir(&rootfs) + .map_err(|error| BoxError::BuildError(error.to_string()))? + .next() + .is_none() + { + crate::cache::layer_cache::copy_dir_recursive(cache_dir, &rootfs)?; + } else { + tracing::info!(path = %rootfs.display(), "Reusing persistent APFS rootfs"); + } + Ok(rootfs) + } + + fn prepare_empty(&self, box_dir: &Path) -> Result { + self.mount(box_dir) + } + + fn cleanup(&self, box_dir: &Path, persistent: bool) -> Result<()> { + super::unmount_box_rootfs(&box_dir.join("rootfs")); + if !persistent { + let image = box_dir.join(Self::IMAGE_NAME); + if image.exists() { + std::fs::remove_file(&image).map_err(|error| { + BoxError::BuildError(format!( + "Failed to remove rootfs image {}: {error}", + image.display() + )) + })?; + } + } + Ok(()) + } + + fn name(&self) -> &'static str { + "case-sensitive-apfs" + } +} + /// Overlayfs provider — near-instant CoW mounts (Linux only). /// /// Layout: @@ -77,8 +268,36 @@ impl RootfsProvider for CopyProvider { /// ``` pub struct OverlayProvider; +impl OverlayProvider { + fn lower_dir(box_dir: &Path, cache_dir: &Path) -> Result { + let rootfs = box_dir.join("rootfs"); + match std::fs::read_dir(&rootfs) { + Ok(mut entries) => { + if entries.next().is_some() { + // A cache miss builds the first generation directly in `rootfs`. + // Once that generation has run, the cache will usually be warm. + // Keep the original writable tree as the overlay lower instead + // of switching the next generation to the immutable image cache; + // otherwise persistent guest writes silently disappear on restart. + Ok(rootfs) + } else { + Ok(cache_dir.to_path_buf()) + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(cache_dir.to_path_buf()) + } + Err(error) => Err(BoxError::BuildError(format!( + "Failed to inspect existing rootfs {}: {error}", + rootfs.display() + ))), + } + } +} + impl RootfsProvider for OverlayProvider { fn prepare(&self, box_dir: &Path, cache_dir: &Path) -> Result { + let lower = Self::lower_dir(box_dir, cache_dir)?; let upper = box_dir.join("upper"); let work = box_dir.join("work"); let merged = box_dir.join("merged"); @@ -100,10 +319,10 @@ impl RootfsProvider for OverlayProvider { return Ok(merged); } - super::overlay::overlay_mount(cache_dir, &upper, &work, &merged)?; + super::overlay::overlay_mount(&lower, &upper, &work, &merged)?; tracing::info!( - lower = %cache_dir.display(), + lower = %lower.display(), merged = %merged.display(), "Overlay mount ready" ); @@ -120,8 +339,10 @@ impl RootfsProvider for OverlayProvider { super::unmount_box_overlay(&merged); if persistent { - // Keep upper (writes) and remove only merged/work (not needed at rest) - tracing::info!("Persistent box: keeping overlay upper layer on disk"); + // Keep both possible persistent generations: a cache-miss generation + // lives in `rootfs`, while later overlay writes live in `upper`. + // The next prepare mounts their union again. + tracing::info!("Persistent box: keeping rootfs and overlay upper on disk"); for dir_name in &["merged", "work"] { let dir = box_dir.join(dir_name); if dir.exists() { @@ -133,7 +354,7 @@ impl RootfsProvider for OverlayProvider { return Ok(()); } - for dir_name in &["upper", "work", "merged"] { + for dir_name in &["rootfs", "upper", "work", "merged"] { let dir = box_dir.join(dir_name); if dir.exists() { if let Err(e) = std::fs::remove_dir_all(&dir) { @@ -156,13 +377,22 @@ impl RootfsProvider for OverlayProvider { /// Auto-detect the best available rootfs provider for the current platform. pub fn default_provider() -> Box { - if super::overlay::is_overlay_supported() { - tracing::info!("Using overlayfs rootfs provider"); - return Box::new(OverlayProvider); + #[cfg(target_os = "macos")] + { + tracing::info!("Using case-sensitive APFS rootfs provider"); + Box::new(CaseSensitiveApfsProvider) } - tracing::info!("Overlayfs not available, using copy provider"); - Box::new(CopyProvider) + #[cfg(not(target_os = "macos"))] + { + if super::overlay::is_overlay_supported() { + tracing::info!("Using overlayfs rootfs provider"); + return Box::new(OverlayProvider); + } + + tracing::info!("Overlayfs not available, using copy provider"); + Box::new(CopyProvider) + } } #[cfg(test)] @@ -275,12 +505,43 @@ mod tests { } #[test] - fn test_overlay_provider_cleanup_persistent_keeps_upper_only() { + fn test_overlay_provider_uses_populated_rootfs_as_persistent_lower() { + let tmp = TempDir::new().unwrap(); + let cache_dir = tmp.path().join("cache"); + let box_dir = tmp.path().join("box"); + let rootfs = box_dir.join("rootfs"); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::write(rootfs.join("restart-proof"), "generation-one").unwrap(); + + assert_eq!( + OverlayProvider::lower_dir(&box_dir, &cache_dir).unwrap(), + rootfs + ); + } + + #[test] + fn test_overlay_provider_ignores_empty_rootfs_as_lower() { + let tmp = TempDir::new().unwrap(); + let cache_dir = tmp.path().join("cache"); + let box_dir = tmp.path().join("box"); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::create_dir_all(box_dir.join("rootfs")).unwrap(); + + assert_eq!( + OverlayProvider::lower_dir(&box_dir, &cache_dir).unwrap(), + cache_dir + ); + } + + #[test] + fn test_overlay_provider_cleanup_persistent_keeps_rootfs_and_upper() { let tmp = TempDir::new().unwrap(); let box_dir = tmp.path().join("box"); - for dir in ["upper", "work", "merged"] { + for dir in ["rootfs", "upper", "work", "merged"] { std::fs::create_dir_all(box_dir.join(dir)).unwrap(); } + std::fs::write(box_dir.join("rootfs/restart-proof"), "generation-one").unwrap(); std::fs::write(box_dir.join("upper/data.txt"), "state").unwrap(); std::fs::write(box_dir.join("work/scratch.txt"), "work").unwrap(); std::fs::write(box_dir.join("merged/view.txt"), "merged").unwrap(); @@ -291,6 +552,10 @@ mod tests { std::fs::read_to_string(box_dir.join("upper/data.txt")).unwrap(), "state" ); + assert_eq!( + std::fs::read_to_string(box_dir.join("rootfs/restart-proof")).unwrap(), + "generation-one" + ); assert!(!box_dir.join("work").exists()); assert!(!box_dir.join("merged").exists()); } @@ -299,13 +564,14 @@ mod tests { fn test_overlay_provider_cleanup_nonpersistent_removes_all_overlay_dirs() { let tmp = TempDir::new().unwrap(); let box_dir = tmp.path().join("box"); - for dir in ["upper", "work", "merged"] { + for dir in ["rootfs", "upper", "work", "merged"] { std::fs::create_dir_all(box_dir.join(dir)).unwrap(); std::fs::write(box_dir.join(dir).join("file.txt"), "data").unwrap(); } OverlayProvider.cleanup(&box_dir, false).unwrap(); + assert!(!box_dir.join("rootfs").exists()); assert!(!box_dir.join("upper").exists()); assert!(!box_dir.join("work").exists()); assert!(!box_dir.join("merged").exists()); @@ -318,6 +584,35 @@ mod tests { assert!(!provider.name().is_empty()); } + #[cfg(target_os = "macos")] + #[test] + fn case_sensitive_apfs_provider_preserves_distinct_names() { + use std::os::unix::fs::MetadataExt; + + let tmp = TempDir::new().unwrap(); + let box_dir = tmp.path().join("box"); + let provider = CaseSensitiveApfsProvider; + let rootfs = provider.prepare_empty(&box_dir).unwrap(); + std::fs::write(rootfs.join("Foo"), "upper").unwrap(); + std::fs::write(rootfs.join("foo"), "lower").unwrap(); + + assert_eq!( + std::fs::read_to_string(rootfs.join("Foo")).unwrap(), + "upper" + ); + assert_eq!( + std::fs::read_to_string(rootfs.join("foo")).unwrap(), + "lower" + ); + assert_ne!( + std::fs::metadata(rootfs.join("Foo")).unwrap().ino(), + std::fs::metadata(rootfs.join("foo")).unwrap().ino() + ); + + provider.cleanup(&box_dir, false).unwrap(); + assert!(!box_dir.join(CaseSensitiveApfsProvider::IMAGE_NAME).exists()); + } + #[cfg(target_os = "linux")] #[test] fn test_overlay_provider_prepare_and_cleanup() { diff --git a/src/runtime/src/sandbox/capability.rs b/src/runtime/src/sandbox/capability.rs new file mode 100644 index 00000000..17f93f8d --- /dev/null +++ b/src/runtime/src/sandbox/capability.rs @@ -0,0 +1,974 @@ +//! Linux capability evidence for the shared-kernel Sandbox backend. + +#[cfg(any(target_os = "linux", test))] +use std::collections::BTreeSet; +#[cfg(target_os = "linux")] +use std::fs::File; +#[cfg(target_os = "linux")] +use std::io::Read; +#[cfg(target_os = "linux")] +use std::path::Component; +use std::path::{Path, PathBuf}; +#[cfg(target_os = "linux")] +use std::process::Command; + +use a3s_box_core::error::{BoxError, Result}; +use serde::{Deserialize, Serialize}; +#[cfg(target_os = "linux")] +use sha2::{Digest, Sha256}; + +/// Capability snapshot schema persisted with a resolved execution plan. +pub const SANDBOX_CAPABILITY_SCHEMA: &str = "a3s.box.sandbox-capabilities.v1"; + +/// The only `crun` release accepted by the first Sandbox backend. +pub const CERTIFIED_CRUN_VERSION: &str = "1.28"; + +#[cfg(target_os = "linux")] +const CRUN_AMD64_SHA256: &str = "2aa6b7024a9c9f153895c0d11ae233d3758f54844011c3a039e3e89048d01d42"; +#[cfg(target_os = "linux")] +const CRUN_ARM64_SHA256: &str = "cc1e8ec89aef1422e0741be196f9ed099e2e09d2f48f30f27cd44a22ef1f0342"; +#[cfg(target_os = "linux")] +const REQUIRED_CGROUP_CONTROLLERS: &[&str] = &["cpu", "memory", "pids"]; + +/// Verified runtime artifact evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CertifiedCrun { + pub path: PathBuf, + pub version: String, + pub sha256: String, + pub features: Vec, +} + +/// One contiguous user-namespace ID mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct IdMapping { + pub container_id: u32, + pub host_id: u32, + pub size: u32, +} + +/// One subordinate ID range assigned to the service account. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubordinateIdRange { + pub start: u32, + pub size: u32, +} + +/// Host identity and subordinate-ID evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserNamespaceEvidence { + pub effective_uid: u32, + pub effective_gid: u32, + pub username: Option, + pub max_user_namespaces: Option, + pub subordinate_uids: Vec, + pub subordinate_gids: Vec, +} + +/// The exact mappings compiled into the OCI specification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxIdMappingPlan { + pub uid_mappings: Vec, + pub gid_mappings: Vec, + pub maximum_container_uid: u32, + pub maximum_container_gid: u32, +} + +/// cgroup v2 delegation evidence for the current service process. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CgroupV2Evidence { + pub mountpoint: Option, + pub current_path: Option, + pub controllers: Vec, + pub delegated: bool, +} + +/// Serializable pre-launch evidence for every mandatory Sandbox control. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxCapabilitySnapshot { + pub schema: String, + pub platform: String, + pub architecture: String, + pub runtime: Option, + pub namespaces: Vec, + pub user_namespace: Option, + pub seccomp_actions: Vec, + pub no_new_privileges_supported: bool, + pub capability_bounding_supported: bool, + pub cgroup_v2: CgroupV2Evidence, + pub failures: Vec, +} + +impl SandboxCapabilitySnapshot { + /// Whether all mandatory host controls were evidenced. + pub fn is_ready(&self) -> bool { + self.failures.is_empty() + } + + /// Fail closed before rootfs preparation when a mandatory control is absent. + pub fn require_ready(&self) -> Result<()> { + if self.is_ready() { + return Ok(()); + } + + Err(BoxError::BoxBootError { + message: format!( + "Sandbox host capability check failed: {}", + self.failures.join("; ") + ), + hint: Some( + "Use a certified A3S OS Sandbox host with crun 1.28, user namespaces, and delegated cgroup v2" + .to_string(), + ), + }) + } +} + +/// Probe the current host without changing namespaces, cgroups, or mounts. +/// +/// An explicit runtime path is still subject to the pinned version and digest +/// checks. When omitted, only packaged A3S locations are searched; `PATH` is +/// deliberately ignored. +pub fn probe_sandbox_capabilities(runtime_path: Option<&Path>) -> SandboxCapabilitySnapshot { + let mut snapshot = SandboxCapabilitySnapshot { + schema: SANDBOX_CAPABILITY_SCHEMA.to_string(), + platform: std::env::consts::OS.to_string(), + architecture: std::env::consts::ARCH.to_string(), + runtime: None, + namespaces: Vec::new(), + user_namespace: None, + seccomp_actions: Vec::new(), + no_new_privileges_supported: false, + capability_bounding_supported: false, + cgroup_v2: CgroupV2Evidence { + mountpoint: None, + current_path: None, + controllers: Vec::new(), + delegated: false, + }, + failures: Vec::new(), + }; + + #[cfg(not(target_os = "linux"))] + { + let _ = runtime_path; + snapshot + .failures + .push("Sandbox isolation is supported only on Linux".to_string()); + snapshot + } + + #[cfg(target_os = "linux")] + { + let runtime_path = runtime_path + .map(Path::to_path_buf) + .map(Ok) + .unwrap_or_else(resolve_certified_crun_path); + match runtime_path.and_then(|path| verify_certified_crun(&path)) { + Ok(runtime) => snapshot.runtime = Some(runtime), + Err(error) => snapshot.failures.push(error.to_string()), + } + + probe_namespaces(&mut snapshot); + probe_seccomp_and_privileges(&mut snapshot); + snapshot.cgroup_v2 = probe_cgroup_v2(); + if !snapshot.cgroup_v2.delegated { + snapshot.failures.push(format!( + "cgroup v2 delegation is unavailable or lacks controllers: {}", + REQUIRED_CGROUP_CONTROLLERS.join(", ") + )); + } + + snapshot + } +} + +/// Produce complete UID and GID mappings for the IDs present in one rootfs. +/// +/// A non-root service account maps container root to itself and consumes +/// subordinate IDs from container ID 1 onward. A root service account must use +/// subordinate IDs even for container root, so host UID/GID 0 are never mapped. +pub fn plan_id_mappings( + evidence: &UserNamespaceEvidence, + maximum_container_uid: u32, + maximum_container_gid: u32, +) -> Result { + let uid_mappings = allocate_id_mappings( + evidence.effective_uid, + &evidence.subordinate_uids, + maximum_container_uid, + "UID", + )?; + let gid_mappings = allocate_id_mappings( + evidence.effective_gid, + &evidence.subordinate_gids, + maximum_container_gid, + "GID", + )?; + + if uid_mappings + .iter() + .any(|mapping| mapping.container_id == 0 && mapping.host_id == 0) + || gid_mappings + .iter() + .any(|mapping| mapping.container_id == 0 && mapping.host_id == 0) + { + return Err(BoxError::ConfigError( + "Sandbox container root must not map to host root".to_string(), + )); + } + + Ok(SandboxIdMappingPlan { + uid_mappings, + gid_mappings, + maximum_container_uid, + maximum_container_gid, + }) +} + +/// Translate one container UID through the same mapping allocation used by +/// Sandbox OCI specifications. +pub fn map_container_uid(evidence: &UserNamespaceEvidence, uid: u32) -> Result { + map_container_identity( + evidence.effective_uid, + &evidence.subordinate_uids, + uid, + "UID", + ) +} + +/// Translate one container GID through the same mapping allocation used by +/// Sandbox OCI specifications. +pub fn map_container_gid(evidence: &UserNamespaceEvidence, gid: u32) -> Result { + map_container_identity( + evidence.effective_gid, + &evidence.subordinate_gids, + gid, + "GID", + ) +} + +/// Recover the container UID represented by a mapped host UID. +pub fn unmap_host_uid(evidence: &UserNamespaceEvidence, uid: u32) -> Result { + unmap_host_identity( + evidence.effective_uid, + &evidence.subordinate_uids, + uid, + "UID", + ) +} + +/// Recover the container GID represented by a mapped host GID. +pub fn unmap_host_gid(evidence: &UserNamespaceEvidence, gid: u32) -> Result { + unmap_host_identity( + evidence.effective_gid, + &evidence.subordinate_gids, + gid, + "GID", + ) +} + +fn map_container_identity( + effective_id: u32, + subordinate_ranges: &[SubordinateIdRange], + container_id: u32, + kind: &str, +) -> Result { + let mappings = allocate_id_mappings(effective_id, subordinate_ranges, container_id, kind)?; + translate_container_id(&mappings, container_id, kind) +} + +fn unmap_host_identity( + effective_id: u32, + subordinate_ranges: &[SubordinateIdRange], + host_id: u32, + kind: &str, +) -> Result { + if effective_id != 0 && host_id == effective_id { + return Ok(0); + } + + let mut next_container_id = u32::from(effective_id != 0); + for range in subordinate_ranges { + if range.size == 0 || range.start == 0 { + continue; + } + let Some(host_end) = range.start.checked_add(range.size) else { + continue; + }; + if effective_id != 0 && range.start <= effective_id && effective_id < host_end { + continue; + } + if range.start <= host_id && host_id < host_end { + return next_container_id + .checked_add(host_id - range.start) + .ok_or_else(|| { + BoxError::ConfigError(format!("Sandbox {kind} reverse mapping overflows u32")) + }); + } + next_container_id = next_container_id.checked_add(range.size).ok_or_else(|| { + BoxError::ConfigError(format!("Sandbox {kind} mapping range overflows u32")) + })?; + } + + Err(BoxError::ConfigError(format!( + "Sandbox host {kind} {host_id} is outside the configured mappings" + ))) +} + +fn translate_container_id(mappings: &[IdMapping], container_id: u32, kind: &str) -> Result { + for mapping in mappings { + let Some(end) = mapping.container_id.checked_add(mapping.size) else { + continue; + }; + if mapping.container_id <= container_id && container_id < end { + return mapping + .host_id + .checked_add(container_id - mapping.container_id) + .ok_or_else(|| { + BoxError::ConfigError(format!("Sandbox {kind} mapping overflows u32")) + }); + } + } + Err(BoxError::ConfigError(format!( + "Sandbox mappings do not cover container {kind} {container_id}" + ))) +} + +fn allocate_id_mappings( + effective_id: u32, + subordinate_ranges: &[SubordinateIdRange], + maximum_container_id: u32, + kind: &str, +) -> Result> { + let mut mappings = Vec::new(); + let mut next_container_id = 0u32; + + if effective_id != 0 { + mappings.push(IdMapping { + container_id: 0, + host_id: effective_id, + size: 1, + }); + next_container_id = 1; + } + + let required_end = maximum_container_id + .checked_add(1) + .ok_or_else(|| BoxError::ConfigError(format!("Sandbox {kind} range overflows u32")))?; + + for range in subordinate_ranges { + if next_container_id >= required_end { + break; + } + if range.size == 0 || range.start == 0 { + continue; + } + let host_end = match range.start.checked_add(range.size) { + Some(end) => end, + None => continue, + }; + if effective_id != 0 && range.start <= effective_id && effective_id < host_end { + continue; + } + + let remaining = required_end - next_container_id; + let size = remaining.min(range.size); + mappings.push(IdMapping { + container_id: next_container_id, + host_id: range.start, + size, + }); + next_container_id += size; + } + + if next_container_id < required_end { + return Err(BoxError::ConfigError(format!( + "Sandbox needs mappings through container {kind} {maximum_container_id}, but the service account has only {} mapped IDs", + next_container_id + ))); + } + + Ok(mappings) +} + +#[cfg(target_os = "linux")] +fn resolve_certified_crun_path() -> Result { + if let Some(path) = std::env::var_os("A3S_BOX_CRUN_PATH") { + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join("crun")); + } + } + candidates.push(a3s_box_core::dirs_home().join("bin/crun")); + + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .ok_or_else(|| BoxError::BoxBootError { + message: "Certified crun runtime not found in packaged A3S locations".to_string(), + hint: Some( + "Install the A3S Box Sandbox runtime package or set A3S_BOX_CRUN_PATH to its verified crun binary" + .to_string(), + ), + }) +} + +#[cfg(target_os = "linux")] +fn verify_certified_crun(path: &Path) -> Result { + let canonical = path + .canonicalize() + .map_err(|error| BoxError::BoxBootError { + message: format!("Failed to resolve crun path {}: {error}", path.display()), + hint: None, + })?; + let metadata = canonical + .metadata() + .map_err(|error| BoxError::BoxBootError { + message: format!( + "Failed to inspect crun artifact {}: {error}", + canonical.display() + ), + hint: None, + })?; + if !metadata.is_file() { + return Err(BoxError::BoxBootError { + message: format!( + "crun artifact is not a regular file: {}", + canonical.display() + ), + hint: None, + }); + } + + let expected_digest = expected_crun_digest()?; + let actual_digest = sha256_file(&canonical)?; + if actual_digest != expected_digest { + return Err(BoxError::BoxBootError { + message: format!( + "crun artifact digest mismatch for {}: expected {}, got {}", + canonical.display(), + expected_digest, + actual_digest + ), + hint: Some("Reinstall the certified A3S Box Sandbox runtime artifact".to_string()), + }); + } + + let output = Command::new(&canonical) + .arg("--version") + .env("LC_ALL", "C") + .output() + .map_err(|error| BoxError::BoxBootError { + message: format!( + "Failed to execute {} --version: {error}", + canonical.display() + ), + hint: None, + })?; + if !output.status.success() { + return Err(BoxError::BoxBootError { + message: format!( + "{} --version exited with {}", + canonical.display(), + output.status + ), + hint: None, + }); + } + let stdout = String::from_utf8(output.stdout).map_err(|error| BoxError::BoxBootError { + message: format!("crun --version returned non-UTF-8 output: {error}"), + hint: None, + })?; + let version = parse_crun_version(&stdout).ok_or_else(|| BoxError::BoxBootError { + message: "Unable to parse crun version output".to_string(), + hint: None, + })?; + if version != CERTIFIED_CRUN_VERSION { + return Err(BoxError::BoxBootError { + message: format!( + "Unsupported crun version {version}; expected {CERTIFIED_CRUN_VERSION}" + ), + hint: Some("Install the certified A3S Box Sandbox runtime artifact".to_string()), + }); + } + + let features = parse_crun_features(&stdout); + for required in ["+CAP", "+SECCOMP"] { + if !features.iter().any(|feature| feature == required) { + return Err(BoxError::BoxBootError { + message: format!("Certified crun build does not advertise {required}"), + hint: None, + }); + } + } + + Ok(CertifiedCrun { + path: canonical, + version, + sha256: actual_digest, + features, + }) +} + +#[cfg(target_os = "linux")] +fn expected_crun_digest() -> Result<&'static str> { + match std::env::consts::ARCH { + "x86_64" => Ok(CRUN_AMD64_SHA256), + "aarch64" => Ok(CRUN_ARM64_SHA256), + architecture => Err(BoxError::BoxBootError { + message: format!("No certified crun 1.28 artifact for Linux {architecture}"), + hint: None, + }), + } +} + +#[cfg(target_os = "linux")] +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + +#[cfg(any(target_os = "linux", test))] +fn parse_crun_version(output: &str) -> Option { + output.lines().find_map(|line| { + line.trim() + .strip_prefix("crun version ") + .and_then(|rest| rest.split_whitespace().next()) + .map(ToString::to_string) + }) +} + +#[cfg(any(target_os = "linux", test))] +fn parse_crun_features(output: &str) -> Vec { + let mut features = BTreeSet::new(); + for token in output.split_whitespace() { + if (token.starts_with('+') || token.starts_with('-')) && token.len() > 1 { + features.insert(token.trim_matches(',').to_string()); + } + } + features.into_iter().collect() +} + +#[cfg(target_os = "linux")] +fn probe_namespaces(snapshot: &mut SandboxCapabilitySnapshot) { + const REQUIRED: &[(&str, &str)] = &[ + ("user", "user namespace"), + ("mnt", "mount namespace"), + ("pid", "PID namespace"), + ("ipc", "IPC namespace"), + ("uts", "UTS namespace"), + ("net", "network namespace"), + ("cgroup", "cgroup namespace"), + ]; + + for (name, label) in REQUIRED { + if Path::new("/proc/self/ns").join(name).exists() { + snapshot.namespaces.push((*name).to_string()); + } else { + snapshot + .failures + .push(format!("Kernel does not expose the required {label}")); + } + } + + let effective_uid = unsafe { libc::geteuid() }; + let effective_gid = unsafe { libc::getegid() }; + let username = username_for_uid(effective_uid); + let max_user_namespaces = read_trimmed("/proc/sys/user/max_user_namespaces") + .and_then(|value| value.parse::().ok()); + if max_user_namespaces == Some(0) || max_user_namespaces.is_none() { + snapshot + .failures + .push("User namespaces are disabled by the host".to_string()); + } + + let subordinate_uids = + read_subordinate_ranges("/etc/subuid", effective_uid, username.as_deref()); + let subordinate_gids = + read_subordinate_ranges("/etc/subgid", effective_uid, username.as_deref()); + if effective_uid == 0 && subordinate_uids.is_empty() { + snapshot.failures.push( + "A root-run Sandbox service requires a non-root subordinate UID range".to_string(), + ); + } + if effective_gid == 0 && subordinate_gids.is_empty() { + snapshot.failures.push( + "A root-run Sandbox service requires a non-root subordinate GID range".to_string(), + ); + } + + snapshot.user_namespace = Some(UserNamespaceEvidence { + effective_uid, + effective_gid, + username, + max_user_namespaces, + subordinate_uids, + subordinate_gids, + }); +} + +#[cfg(target_os = "linux")] +fn probe_seccomp_and_privileges(snapshot: &mut SandboxCapabilitySnapshot) { + snapshot.seccomp_actions = read_trimmed("/proc/sys/kernel/seccomp/actions_avail") + .map(|line| line.split_whitespace().map(ToString::to_string).collect()) + .unwrap_or_default(); + if !snapshot + .seccomp_actions + .iter() + .any(|action| action == "allow") + || !snapshot + .seccomp_actions + .iter() + .any(|action| action == "errno") + { + snapshot + .failures + .push("Kernel seccomp ERRNO/ALLOW actions are unavailable".to_string()); + } + + let status = read_trimmed("/proc/self/status").unwrap_or_default(); + snapshot.no_new_privileges_supported = + status.lines().any(|line| line.starts_with("NoNewPrivs:")); + snapshot.capability_bounding_supported = status.lines().any(|line| line.starts_with("CapBnd:")); + if !snapshot.no_new_privileges_supported { + snapshot + .failures + .push("Kernel does not expose no_new_privs state".to_string()); + } + if !snapshot.capability_bounding_supported { + snapshot + .failures + .push("Kernel does not expose a capability bounding set".to_string()); + } +} + +#[cfg(target_os = "linux")] +fn probe_cgroup_v2() -> CgroupV2Evidence { + let mountpoint = read_trimmed("/proc/self/mountinfo") + .and_then(|contents| parse_cgroup2_mountpoint(&contents)); + let relative = read_trimmed("/proc/self/cgroup") + .and_then(|contents| parse_current_cgroup_path(&contents).map(ToString::to_string)); + let current_path = match (&mountpoint, &relative) { + (Some(mountpoint), Some(relative)) => safe_join_cgroup(mountpoint, relative), + _ => None, + }; + let controllers: Vec = current_path + .as_ref() + .and_then(|path| read_trimmed(path.join("cgroup.controllers"))) + .map(|line| line.split_whitespace().map(ToString::to_string).collect()) + .unwrap_or_default(); + let has_controllers = REQUIRED_CGROUP_CONTROLLERS + .iter() + .all(|required| controllers.iter().any(|value| value == required)); + let delegated = current_path.as_ref().is_some_and(|path| { + has_controllers + && path.join("cgroup.procs").exists() + && path.join("cgroup.subtree_control").exists() + && path_is_writable(path) + && path_is_writable(&path.join("cgroup.procs")) + && path_is_writable(&path.join("cgroup.subtree_control")) + }); + + CgroupV2Evidence { + mountpoint, + current_path, + controllers, + delegated, + } +} + +#[cfg(target_os = "linux")] +fn parse_cgroup2_mountpoint(mountinfo: &str) -> Option { + mountinfo.lines().find_map(|line| { + let (left, right) = line.split_once(" - ")?; + if right.split_whitespace().next()? != "cgroup2" { + return None; + } + let mountpoint = left.split_whitespace().nth(4)?; + Some(PathBuf::from(unescape_mountinfo(mountpoint))) + }) +} + +#[cfg(target_os = "linux")] +fn parse_current_cgroup_path(contents: &str) -> Option<&str> { + contents.lines().find_map(|line| { + let mut fields = line.splitn(3, ':'); + let hierarchy = fields.next()?; + let controllers = fields.next()?; + let path = fields.next()?; + (hierarchy == "0" && controllers.is_empty()).then_some(path) + }) +} + +#[cfg(target_os = "linux")] +fn safe_join_cgroup(mountpoint: &Path, relative: &str) -> Option { + let relative = Path::new(relative.trim_start_matches('/')); + if relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return None; + } + Some(mountpoint.join(relative)) +} + +#[cfg(target_os = "linux")] +fn unescape_mountinfo(value: &str) -> String { + value + .replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\") +} + +#[cfg(target_os = "linux")] +fn path_is_writable(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + + let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + unsafe { libc::access(path.as_ptr(), libc::W_OK) == 0 } +} + +#[cfg(target_os = "linux")] +fn read_trimmed(path: impl AsRef) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|value| value.trim().to_string()) +} + +#[cfg(target_os = "linux")] +fn username_for_uid(uid: u32) -> Option { + let passwd = std::fs::read_to_string("/etc/passwd").ok()?; + passwd.lines().find_map(|line| { + if line.trim_start().starts_with('#') { + return None; + } + let mut fields = line.split(':'); + let name = fields.next()?; + let _password = fields.next()?; + let entry_uid = fields.next()?.parse::().ok()?; + (entry_uid == uid).then(|| name.to_string()) + }) +} + +#[cfg(target_os = "linux")] +fn read_subordinate_ranges( + path: &str, + uid: u32, + username: Option<&str>, +) -> Vec { + let Some(contents) = read_trimmed(path) else { + return Vec::new(); + }; + parse_subordinate_ranges(&contents, uid, username) +} + +#[cfg(any(target_os = "linux", test))] +fn parse_subordinate_ranges( + contents: &str, + uid: u32, + username: Option<&str>, +) -> Vec { + let uid = uid.to_string(); + let mut ranges: Vec<_> = contents + .lines() + .filter_map(|line| { + let line = line.split('#').next()?.trim(); + let mut fields = line.split(':'); + let owner = fields.next()?; + if owner != uid && username != Some(owner) { + return None; + } + let start = fields.next()?.parse::().ok()?; + let size = fields.next()?.parse::().ok()?; + if fields.next().is_some() || start == 0 || size == 0 { + return None; + } + start.checked_add(size)?; + Some(SubordinateIdRange { start, size }) + }) + .collect(); + ranges.sort_by_key(|range| range.start); + ranges +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_crun_version_and_features() { + let output = + "crun version 1.28\ncommit: abc\nspec: 1.0.0\n+SYSTEMD +CAP +SECCOMP -SELINUX\n"; + assert_eq!(parse_crun_version(output).as_deref(), Some("1.28")); + assert_eq!( + parse_crun_features(output), + vec!["+CAP", "+SECCOMP", "+SYSTEMD", "-SELINUX"] + ); + } + + #[test] + fn subordinate_ranges_match_name_or_numeric_uid() { + let contents = "alice:100000:65536\n1001:200000:42\nbob:300000:5\n"; + assert_eq!( + parse_subordinate_ranges(contents, 1001, Some("alice")), + vec![ + SubordinateIdRange { + start: 100000, + size: 65536, + }, + SubordinateIdRange { + start: 200000, + size: 42, + }, + ] + ); + } + + #[test] + fn non_root_mapping_uses_effective_id_for_container_root() { + let evidence = UserNamespaceEvidence { + effective_uid: 1000, + effective_gid: 1000, + username: Some("box".to_string()), + max_user_namespaces: Some(1024), + subordinate_uids: vec![SubordinateIdRange { + start: 100000, + size: 65536, + }], + subordinate_gids: vec![SubordinateIdRange { + start: 200000, + size: 65536, + }], + }; + let plan = plan_id_mappings(&evidence, 65535, 65535).unwrap(); + assert_eq!( + plan.uid_mappings, + vec![ + IdMapping { + container_id: 0, + host_id: 1000, + size: 1, + }, + IdMapping { + container_id: 1, + host_id: 100000, + size: 65535, + }, + ] + ); + assert_eq!(plan.gid_mappings[0].host_id, 1000); + assert_eq!(plan.gid_mappings[1].host_id, 200000); + } + + #[test] + fn root_service_maps_container_root_to_subordinate_id() { + let evidence = UserNamespaceEvidence { + effective_uid: 0, + effective_gid: 0, + username: Some("root".to_string()), + max_user_namespaces: Some(1024), + subordinate_uids: vec![SubordinateIdRange { + start: 100000, + size: 65536, + }], + subordinate_gids: vec![SubordinateIdRange { + start: 200000, + size: 65536, + }], + }; + let plan = plan_id_mappings(&evidence, 65535, 65535).unwrap(); + assert_eq!(plan.uid_mappings[0].container_id, 0); + assert_eq!(plan.uid_mappings[0].host_id, 100000); + assert_eq!(plan.gid_mappings[0].host_id, 200000); + assert_eq!(map_container_uid(&evidence, 0).unwrap(), 100000); + assert_eq!(map_container_uid(&evidence, 1000).unwrap(), 101000); + assert_eq!(unmap_host_uid(&evidence, 100000).unwrap(), 0); + assert_eq!(unmap_host_uid(&evidence, 101000).unwrap(), 1000); + } + + #[test] + fn identity_translation_matches_multi_range_allocation() { + let evidence = UserNamespaceEvidence { + effective_uid: 1000, + effective_gid: 2000, + username: None, + max_user_namespaces: Some(1024), + subordinate_uids: vec![ + SubordinateIdRange { + start: 100000, + size: 2, + }, + SubordinateIdRange { + start: 200000, + size: 3, + }, + ], + subordinate_gids: vec![SubordinateIdRange { + start: 300000, + size: 8, + }], + }; + + assert_eq!(map_container_uid(&evidence, 0).unwrap(), 1000); + assert_eq!(map_container_uid(&evidence, 1).unwrap(), 100000); + assert_eq!(map_container_uid(&evidence, 2).unwrap(), 100001); + assert_eq!(map_container_uid(&evidence, 3).unwrap(), 200000); + assert_eq!(unmap_host_uid(&evidence, 1000).unwrap(), 0); + assert_eq!(unmap_host_uid(&evidence, 200002).unwrap(), 5); + assert!(map_container_uid(&evidence, 6).is_err()); + assert!(unmap_host_uid(&evidence, 42).is_err()); + assert_eq!(map_container_gid(&evidence, 1).unwrap(), 300000); + assert_eq!(unmap_host_gid(&evidence, 300000).unwrap(), 1); + } + + #[test] + fn one_id_rootless_mapping_is_allowed_only_when_sufficient() { + let evidence = UserNamespaceEvidence { + effective_uid: 1000, + effective_gid: 1000, + username: None, + max_user_namespaces: Some(1024), + subordinate_uids: Vec::new(), + subordinate_gids: Vec::new(), + }; + assert!(plan_id_mappings(&evidence, 0, 0).is_ok()); + assert!(plan_id_mappings(&evidence, 1, 0).is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn parses_cgroup_v2_paths_without_traversal() { + let mountinfo = + "29 23 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n"; + assert_eq!( + parse_cgroup2_mountpoint(mountinfo).as_deref(), + Some(Path::new("/sys/fs/cgroup")) + ); + assert_eq!( + parse_current_cgroup_path("0::/user.slice/a3s.service\n"), + Some("/user.slice/a3s.service") + ); + assert!(safe_join_cgroup(Path::new("/sys/fs/cgroup"), "/../../etc").is_none()); + } +} diff --git a/src/runtime/src/sandbox/controller.rs b/src/runtime/src/sandbox/controller.rs new file mode 100644 index 00000000..403c75f6 --- /dev/null +++ b/src/runtime/src/sandbox/controller.rs @@ -0,0 +1,656 @@ +//! Durable bundle creation and `crun` process startup. + +#[cfg(target_os = "linux")] +use std::fs::File; +use std::fs::OpenOptions; +#[cfg(target_os = "linux")] +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::process::Command; +#[cfg(target_os = "linux")] +use std::process::Stdio; +#[cfg(target_os = "linux")] +use std::time::{Duration, Instant}; + +use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::execution::ResolvedExecutionPlan; +use a3s_box_core::log::LogConfig; +#[cfg(target_os = "linux")] +use a3s_box_core::log::{SandboxLogWorkerSpec, SANDBOX_LOG_WORKER_SCHEMA}; +use oci_spec::runtime::Spec; +use serde::Serialize; + +use super::capability::{CertifiedCrun, SandboxCapabilitySnapshot}; +use super::handler::CrunHandler; +#[cfg(target_os = "linux")] +use super::handler::{CrunHandlerSpec, CrunState}; + +#[cfg(target_os = "linux")] +const EXEC_LISTENER_FD: i32 = 3; +#[cfg(target_os = "linux")] +const PTY_LISTENER_FD: i32 = 4; +#[cfg(target_os = "linux")] +const INIT_LOG_FD: i32 = 5; +#[cfg(target_os = "linux")] +const PRESERVED_FD_COUNT: usize = 3; +#[cfg(target_os = "linux")] +const START_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(target_os = "linux")] +const START_FAILURE_LOG_LIMIT_BYTES: u64 = 4 * 1024; + +/// Files and sockets required to launch a generated OCI bundle. +pub struct SandboxLaunchSpec { + pub container_id: String, + pub bundle_dir: PathBuf, + pub runtime_root: PathBuf, + pub runtime_record: PathBuf, + pub exec_socket_path: PathBuf, + pub pty_socket_path: PathBuf, + pub stdout_path: PathBuf, + pub stderr_path: PathBuf, + pub init_log_path: PathBuf, + pub log_config: LogConfig, + pub log_worker_path: PathBuf, + pub log_worker_log_path: PathBuf, + pub log_worker_ready_path: PathBuf, +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Serialize)] +struct SandboxRuntimeRecord<'a> { + schema: &'static str, + container_id: &'a str, + runtime_path: &'a Path, + runtime_root: &'a Path, + bundle_dir: &'a Path, + init_pid: u32, + log_worker_pid: u32, + log_worker_pid_start_time: u64, +} + +/// Controller pinned to one already-verified `crun` artifact. +pub struct CrunController { + runtime: CertifiedCrun, +} + +impl CrunController { + pub fn new(runtime: CertifiedCrun) -> Self { + Self { runtime } + } + + /// Refuse to overwrite a live runtime generation with the same ID. + pub fn require_absent(&self, runtime_root: &Path, container_id: &str) -> Result<()> { + match std::fs::symlink_metadata(runtime_root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(BoxError::BoxBootError { + message: format!( + "Failed to inspect Sandbox runtime root {}: {error}", + runtime_root.display() + ), + hint: None, + }) + } + Ok(metadata) if !metadata.file_type().is_dir() => { + return Err(BoxError::BoxBootError { + message: format!( + "Sandbox runtime root is not a directory: {}", + runtime_root.display() + ), + hint: None, + }) + } + Ok(_) => {} + } + + // `crun state --root ` materializes the root even when the + // container is absent. The metadata gate above keeps this safety probe + // side-effect free before image pulls and bundle preparation begin. + match CrunHandler::query_state_at(&self.runtime.path, runtime_root, container_id)? { + Some(state) if state.status == "stopped" => { + let output = Command::new(&self.runtime.path) + .arg("--root") + .arg(runtime_root) + .arg("delete") + .arg("--force") + .arg(container_id) + .env("LC_ALL", "C") + .output() + .map_err(|error| BoxError::BoxBootError { + message: format!("Failed to delete stopped Sandbox generation: {error}"), + hint: None, + })?; + if !output.status.success() { + return Err(BoxError::BoxBootError { + message: format!( + "Failed to delete stopped Sandbox generation: {}", + String::from_utf8_lossy(&output.stderr).trim() + ), + hint: None, + }); + } + Ok(()) + } + Some(state) => Err(BoxError::BoxBootError { + message: format!( + "Sandbox runtime ID {container_id} already exists in state {}", + state.status + ), + hint: Some( + "Reconcile or stop the existing Sandbox before restarting it".to_string(), + ), + }), + None => Ok(()), + } + } + + #[cfg(target_os = "linux")] + pub async fn start(&self, launch: SandboxLaunchSpec) -> Result { + use std::os::fd::AsRawFd; + use std::os::unix::process::CommandExt; + + self.require_absent(&launch.runtime_root, &launch.container_id)?; + create_private_dir(&launch.runtime_root)?; + let exec_listener = bind_control_listener(&launch.exec_socket_path)?; + let pty_listener = bind_control_listener(&launch.pty_socket_path)?; + let stdout = open_log(&launch.stdout_path)?; + let stderr = open_log(&launch.stderr_path)?; + let init_log = open_log(&launch.init_log_path)?; + + let inherited_exec = duplicate_for_inheritance(exec_listener.as_raw_fd())?; + let inherited_pty = duplicate_for_inheritance(pty_listener.as_raw_fd())?; + let inherited_log = duplicate_for_inheritance(init_log.as_raw_fd())?; + let exec_fd = inherited_exec.as_raw_fd(); + let pty_fd = inherited_pty.as_raw_fd(); + let log_fd = inherited_log.as_raw_fd(); + + let mut command = Command::new(&self.runtime.path); + command + .arg("--root") + .arg(&launch.runtime_root) + .arg("run") + .arg("--bundle") + .arg(&launch.bundle_dir) + .arg("--preserve-fds") + .arg(PRESERVED_FD_COUNT.to_string()) + .arg(&launch.container_id) + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + + // The duplicated source descriptors are all >= 10, so the three dup2 + // operations cannot clobber one another. dup2 clears CLOEXEC on 3/4/5. + unsafe { + command.pre_exec(move || { + for (source, destination) in [ + (exec_fd, EXEC_LISTENER_FD), + (pty_fd, PTY_LISTENER_FD), + (log_fd, INIT_LOG_FD), + ] { + if libc::dup2(source, destination) < 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) + }); + } + + let mut child = command.spawn().map_err(|error| BoxError::BoxBootError { + message: format!("Failed to start certified crun runtime: {error}"), + hint: None, + })?; + drop((inherited_exec, inherited_pty, inherited_log)); + // `crun` and the container own duplicated descriptors now. The parent + // listener copies must close so socket EOF/lifetime is not extended. + drop((exec_listener, pty_listener, init_log)); + + let deadline = Instant::now() + START_TIMEOUT; + let init_pid = loop { + let child_status = match child.try_wait() { + Ok(status) => status, + Err(error) => { + cleanup_failed_start(&self.runtime.path, &launch); + let _ = child.kill(); + let _ = child.wait(); + return Err(BoxError::IoError(error)); + } + }; + if let Some(status) = child_status { + let diagnostics = start_failure_diagnostics(&launch); + cleanup_failed_start(&self.runtime.path, &launch); + return Err(BoxError::BoxBootError { + message: format!( + "crun run exited before the Sandbox was running: {status}{diagnostics}" + ), + hint: None, + }); + } + let runtime_state = match CrunHandler::query_state_at( + &self.runtime.path, + &launch.runtime_root, + &launch.container_id, + ) { + Ok(state) => state, + Err(error) => { + cleanup_failed_start(&self.runtime.path, &launch); + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + if let Some(CrunState { status, pid }) = runtime_state { + if status == "running" && pid > 0 { + break pid; + } + if status == "stopped" { + let diagnostics = start_failure_diagnostics(&launch); + cleanup_failed_start(&self.runtime.path, &launch); + return Err(BoxError::BoxBootError { + message: format!("Sandbox stopped during OCI startup{diagnostics}"), + hint: None, + }); + } + } + if Instant::now() >= deadline { + let diagnostics = start_failure_diagnostics(&launch); + cleanup_failed_start(&self.runtime.path, &launch); + let _ = child.kill(); + let _ = child.wait(); + return Err(BoxError::BoxBootError { + message: format!( + "Timed out waiting for the Sandbox OCI state to become running{diagnostics}" + ), + hint: None, + }); + } + tokio::time::sleep(Duration::from_millis(25)).await; + }; + + let watched_pid = child.id(); + let watched_pid_start_time = match crate::process::pid_start_time(watched_pid) { + Some(start_time) => start_time, + None => { + cleanup_failed_start(&self.runtime.path, &launch); + let _ = child.kill(); + let _ = child.wait(); + return Err(BoxError::BoxBootError { + message: "Failed to capture crun wrapper process identity for Sandbox logs" + .to_string(), + hint: None, + }); + } + }; + let mut log_worker = match start_log_worker(&launch, watched_pid, watched_pid_start_time) { + Ok(worker) => worker, + Err(error) => { + cleanup_failed_start(&self.runtime.path, &launch); + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + let log_worker_pid = log_worker.id(); + let log_worker_pid_start_time = match crate::process::pid_start_time(log_worker_pid) { + Some(start_time) => start_time, + None => { + cleanup_failed_start(&self.runtime.path, &launch); + reap_failed_log_worker(&mut log_worker); + let _ = child.kill(); + let _ = child.wait(); + return Err(BoxError::BoxBootError { + message: "Failed to capture Sandbox log worker process identity".to_string(), + hint: None, + }); + } + }; + + let record = SandboxRuntimeRecord { + schema: "a3s.box.sandbox-runtime.v1", + container_id: &launch.container_id, + runtime_path: &self.runtime.path, + runtime_root: &launch.runtime_root, + bundle_dir: &launch.bundle_dir, + init_pid, + log_worker_pid, + log_worker_pid_start_time, + }; + if let Err(error) = write_json_atomic(&launch.runtime_record, &record) { + cleanup_failed_start(&self.runtime.path, &launch); + reap_failed_log_worker(&mut log_worker); + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + + Ok(CrunHandler::from_child( + CrunHandlerSpec::new( + self.runtime.path.clone(), + launch.runtime_root, + launch.container_id, + init_pid, + launch.bundle_dir, + launch.runtime_record, + ), + child, + log_worker, + log_worker_pid_start_time, + )) + } + + #[cfg(not(target_os = "linux"))] + pub async fn start(&self, _launch: SandboxLaunchSpec) -> Result { + Err(BoxError::BoxBootError { + message: "Sandbox execution requires Linux".to_string(), + hint: Some("Run this workload on an A3S OS Sandbox host".to_string()), + }) + } +} + +/// Persist generated artifacts without accepting user-supplied OCI JSON. +pub fn write_bundle( + bundle_dir: &Path, + spec: &Spec, + execution_plan: &ResolvedExecutionPlan, + capabilities: &SandboxCapabilitySnapshot, +) -> Result<()> { + create_private_dir(bundle_dir)?; + write_json_atomic(&bundle_dir.join("config.json"), spec)?; + write_json_atomic(&bundle_dir.join("execution-plan.json"), execution_plan)?; + write_json_atomic(&bundle_dir.join("capabilities.json"), capabilities)?; + Ok(()) +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { + use std::io::Write; + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt; + + let parent = path.parent().ok_or_else(|| { + BoxError::ConfigError(format!( + "Sandbox artifact has no parent: {}", + path.display() + )) + })?; + create_private_dir(parent)?; + let temporary = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4())); + let bytes = serde_json::to_vec_pretty(value).map_err(|error| { + BoxError::SerializationError(format!("Failed to encode Sandbox artifact: {error}")) + })?; + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options.open(&temporary).map_err(BoxError::IoError)?; + file.write_all(&bytes).map_err(BoxError::IoError)?; + file.write_all(b"\n").map_err(BoxError::IoError)?; + file.sync_all().map_err(BoxError::IoError)?; + std::fs::rename(&temporary, path).map_err(BoxError::IoError)?; + Ok(()) +} + +fn create_private_dir(path: &Path) -> Result<()> { + std::fs::create_dir_all(path).map_err(BoxError::IoError)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(BoxError::IoError)?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn open_log(path: &Path) -> Result { + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt; + + let parent = path.parent().ok_or_else(|| { + BoxError::ConfigError(format!("Sandbox log has no parent: {}", path.display())) + })?; + create_private_dir(parent)?; + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + options.mode(0o600); + options.open(path).map_err(BoxError::IoError) +} + +#[cfg(target_os = "linux")] +fn start_log_worker( + launch: &SandboxLaunchSpec, + watched_pid: u32, + watched_pid_start_time: u64, +) -> Result { + let _ = std::fs::remove_file(&launch.log_worker_ready_path); + let worker_spec = SandboxLogWorkerSpec { + schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(), + box_id: launch.container_id.clone(), + console_log: launch.stdout_path.clone(), + log_config: launch.log_config.clone(), + watched_pid, + watched_pid_start_time, + ready_file: launch.log_worker_ready_path.clone(), + }; + let config = serde_json::to_string(&worker_spec).map_err(|error| { + BoxError::SerializationError(format!( + "Failed to encode Sandbox log worker configuration: {error}" + )) + })?; + let stdout = open_log(&launch.log_worker_log_path)?; + let stderr = stdout.try_clone().map_err(BoxError::IoError)?; + let mut worker = Command::new(&launch.log_worker_path) + .arg("--sandbox-log-worker-config") + .arg(config) + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .map_err(|error| BoxError::BoxBootError { + message: format!("Failed to start Sandbox log worker: {error}"), + hint: None, + })?; + + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if launch.log_worker_ready_path.is_file() { + return Ok(worker); + } + match worker.try_wait() { + Ok(Some(status)) => { + let diagnostics = + read_log_tail(&launch.log_worker_log_path, START_FAILURE_LOG_LIMIT_BYTES) + .map(|excerpt| format!(": {excerpt}")) + .unwrap_or_default(); + return Err(BoxError::BoxBootError { + message: format!( + "Sandbox log worker exited before readiness with {status}{diagnostics}" + ), + hint: None, + }); + } + Ok(None) => {} + Err(error) => return Err(BoxError::IoError(error)), + } + if Instant::now() >= deadline { + reap_failed_log_worker(&mut worker); + return Err(BoxError::BoxBootError { + message: "Timed out waiting for Sandbox log worker readiness".to_string(), + hint: None, + }); + } + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[cfg(target_os = "linux")] +fn reap_failed_log_worker(worker: &mut std::process::Child) { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + match worker.try_wait() { + Ok(Some(_)) => return, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + _ => break, + } + } + let _ = worker.kill(); + let _ = worker.wait(); +} + +#[cfg(target_os = "linux")] +fn bind_control_listener(path: &Path) -> Result { + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; + + let parent = path.parent().ok_or_else(|| { + BoxError::ConfigError(format!("Sandbox socket has no parent: {}", path.display())) + })?; + create_private_dir(parent)?; + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_socket() => { + std::fs::remove_file(path).map_err(BoxError::IoError)?; + } + Ok(_) => { + return Err(BoxError::BoxBootError { + message: format!( + "Refusing to replace non-socket Sandbox control path {}", + path.display() + ), + hint: None, + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(BoxError::IoError(error)), + } + let listener = std::os::unix::net::UnixListener::bind(path).map_err(BoxError::IoError)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(BoxError::IoError)?; + Ok(listener) +} + +#[cfg(target_os = "linux")] +fn duplicate_for_inheritance(fd: i32) -> Result { + use std::os::fd::{FromRawFd, OwnedFd}; + let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) }; + if duplicate < 0 { + return Err(BoxError::IoError(std::io::Error::last_os_error())); + } + // SAFETY: F_DUPFD_CLOEXEC returned a new descriptor owned by this process. + Ok(unsafe { OwnedFd::from_raw_fd(duplicate) }) +} + +#[cfg(target_os = "linux")] +fn start_failure_diagnostics(launch: &SandboxLaunchSpec) -> String { + let diagnostics = [ + ("crun stderr", &launch.stderr_path), + ("guest-init log", &launch.init_log_path), + ("Sandbox log worker", &launch.log_worker_log_path), + ] + .into_iter() + .filter_map(|(label, path)| { + read_log_tail(path, START_FAILURE_LOG_LIMIT_BYTES) + .map(|excerpt| format!("{label}: {excerpt}")) + }) + .collect::>(); + + if diagnostics.is_empty() { + String::new() + } else { + format!(" ({})", diagnostics.join("; ")) + } +} + +#[cfg(target_os = "linux")] +fn read_log_tail(path: &Path, limit: u64) -> Option { + let mut file = File::open(path).ok()?; + let length = file.metadata().ok()?.len(); + let offset = length.saturating_sub(limit); + file.seek(SeekFrom::Start(offset)).ok()?; + + let mut bytes = Vec::with_capacity((length - offset) as usize); + file.take(limit).read_to_end(&mut bytes).ok()?; + let excerpt = String::from_utf8_lossy(&bytes).trim().to_string(); + if excerpt.is_empty() { + None + } else if offset > 0 { + Some(format!("...{excerpt}")) + } else { + Some(excerpt) + } +} + +#[cfg(target_os = "linux")] +fn cleanup_failed_start(runtime_path: &Path, launch: &SandboxLaunchSpec) { + let _ = Command::new(runtime_path) + .arg("--root") + .arg(&launch.runtime_root) + .arg("delete") + .arg("--force") + .arg(&launch.container_id) + .env("LC_ALL", "C") + .output(); + let _ = std::fs::remove_file(&launch.runtime_record); + let _ = std::fs::remove_dir_all(&launch.runtime_root); +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + fn controller_with_runtime(path: PathBuf) -> CrunController { + CrunController::new(CertifiedCrun { + path, + version: "1.28".to_string(), + sha256: "test-digest".to_string(), + features: vec!["+CAP".to_string(), "+SECCOMP".to_string()], + }) + } + + #[test] + fn absent_runtime_root_is_not_materialized_by_state_probe() { + let temporary = tempfile::tempdir().unwrap(); + let runtime_root = temporary.path().join("missing-runtime-root"); + let controller = controller_with_runtime(temporary.path().join("must-not-run")); + + controller + .require_absent(&runtime_root, "internal-execution-id") + .unwrap(); + + assert!(!runtime_root.exists()); + } + + #[test] + fn runtime_root_symlink_is_rejected_before_executing_crun() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().unwrap(); + let target = temporary.path().join("target"); + let runtime_root = temporary.path().join("runtime-root"); + std::fs::create_dir(&target).unwrap(); + symlink(&target, &runtime_root).unwrap(); + let controller = controller_with_runtime(temporary.path().join("must-not-run")); + + let error = controller + .require_absent(&runtime_root, "internal-execution-id") + .unwrap_err(); + + assert!(error.to_string().contains("not a directory")); + assert!(target.read_dir().unwrap().next().is_none()); + } + + #[test] + fn startup_log_excerpt_is_bounded_and_keeps_the_tail() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("crun.stderr.log"); + let mut contents = "x".repeat(START_FAILURE_LOG_LIMIT_BYTES as usize + 512); + contents.push_str("\nseccomp unknown architecture `NATIVE`\n"); + std::fs::write(&path, contents).unwrap(); + + let excerpt = read_log_tail(&path, START_FAILURE_LOG_LIMIT_BYTES).unwrap(); + assert!(excerpt.starts_with("...")); + assert!(excerpt.contains("seccomp unknown architecture `NATIVE`")); + assert!(excerpt.len() <= START_FAILURE_LOG_LIMIT_BYTES as usize + 3); + } +} diff --git a/src/runtime/src/sandbox/handler.rs b/src/runtime/src/sandbox/handler.rs new file mode 100644 index 00000000..b01b45d1 --- /dev/null +++ b/src/runtime/src/sandbox/handler.rs @@ -0,0 +1,723 @@ +//! Runtime handler for a live `crun` Sandbox container. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::vmm::{VmHandler, VmMetrics}; +use serde::Deserialize; +use sysinfo::{Pid, System}; + +// `crun kill` accepts Linux signal numbers even though this module must also +// type-check on hosts where libc does not expose POSIX signal constants. +const SIGKILL_NUMBER: i32 = 9; +#[cfg(target_os = "linux")] +const LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(target_os = "linux")] +const LIFECYCLE_POLL_INTERVAL: Duration = Duration::from_millis(25); + +#[derive(Debug, Deserialize)] +pub(crate) struct CrunState { + pub status: String, + #[serde(default)] + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub pid: u32, +} + +/// Owns both the foreground `crun run` process and the OCI runtime state. +/// Lifecycle operations always target the container ID through `crun`; merely +/// signalling the wrapper process is never treated as cleanup. +/// Dropping the in-process handle deliberately detaches without destroying the +/// workload so short-lived CLI commands can launch persistent boxes. Explicit +/// lifecycle operations and crash reconciliation own runtime cleanup. +pub struct CrunHandler { + runtime_path: PathBuf, + runtime_root: PathBuf, + container_id: String, + init_pid: u32, + process: Option, + log_worker: Option, + log_worker_pid: Option, + log_worker_pid_start_time: Option, + metrics_sys: Mutex, + exit_code: Option, + bundle_dir: PathBuf, + runtime_record: PathBuf, + cleaned: bool, +} + +#[cfg(target_os = "linux")] +pub(crate) struct CrunHandlerSpec { + runtime_path: PathBuf, + runtime_root: PathBuf, + container_id: String, + init_pid: u32, + bundle_dir: PathBuf, + runtime_record: PathBuf, +} + +#[cfg(target_os = "linux")] +impl CrunHandlerSpec { + pub(crate) fn new( + runtime_path: PathBuf, + runtime_root: PathBuf, + container_id: String, + init_pid: u32, + bundle_dir: PathBuf, + runtime_record: PathBuf, + ) -> Self { + Self { + runtime_path, + runtime_root, + container_id, + init_pid, + bundle_dir, + runtime_record, + } + } +} + +impl CrunHandler { + #[cfg(target_os = "linux")] + pub(crate) fn from_child( + spec: CrunHandlerSpec, + process: Child, + log_worker: Child, + log_worker_pid_start_time: u64, + ) -> Self { + let log_worker_pid = log_worker.id(); + Self { + runtime_path: spec.runtime_path, + runtime_root: spec.runtime_root, + container_id: spec.container_id, + init_pid: spec.init_pid, + process: Some(process), + log_worker: Some(log_worker), + log_worker_pid: Some(log_worker_pid), + log_worker_pid_start_time: Some(log_worker_pid_start_time), + metrics_sys: Mutex::new(System::new()), + exit_code: None, + bundle_dir: spec.bundle_dir, + runtime_record: spec.runtime_record, + cleaned: false, + } + } + + #[cfg(all(target_os = "linux", feature = "vm"))] + pub(crate) fn from_recorded_runtime( + spec: CrunHandlerSpec, + log_worker_pid: Option, + log_worker_pid_start_time: Option, + ) -> Self { + Self { + runtime_path: spec.runtime_path, + runtime_root: spec.runtime_root, + container_id: spec.container_id, + init_pid: spec.init_pid, + process: None, + log_worker: None, + log_worker_pid, + log_worker_pid_start_time, + metrics_sys: Mutex::new(System::new()), + exit_code: None, + bundle_dir: spec.bundle_dir, + runtime_record: spec.runtime_record, + cleaned: false, + } + } + + pub(crate) fn query_state_at( + runtime_path: &Path, + runtime_root: &Path, + container_id: &str, + ) -> Result> { + let output = Command::new(runtime_path) + .arg("--root") + .arg(runtime_root) + .arg("state") + .arg(container_id) + .env("LC_ALL", "C") + .output() + .map_err(|error| BoxError::BoxBootError { + message: format!("Failed to query Sandbox runtime state: {error}"), + hint: None, + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let normalized = stderr.to_ascii_lowercase(); + if normalized.contains("does not exist") + || normalized.contains("not found") + || normalized.contains("no such file or directory") + { + return Ok(None); + } + return Err(BoxError::BoxBootError { + message: format!("crun state failed for {container_id}: {}", stderr.trim()), + hint: None, + }); + } + let state = + serde_json::from_slice(&output.stdout).map_err(|error| BoxError::BoxBootError { + message: format!("Invalid crun state response: {error}"), + hint: None, + })?; + Ok(Some(state)) + } + + #[cfg(target_os = "linux")] + pub(crate) fn pause_at( + runtime_path: &Path, + runtime_root: &Path, + container_id: &str, + ) -> Result<()> { + Self::transition_state_at( + runtime_path, + runtime_root, + container_id, + "pause", + &["created", "running"], + "paused", + ) + } + + #[cfg(target_os = "linux")] + pub(crate) fn resume_at( + runtime_path: &Path, + runtime_root: &Path, + container_id: &str, + ) -> Result<()> { + Self::transition_state_at( + runtime_path, + runtime_root, + container_id, + "resume", + &["paused"], + "running", + ) + } + + #[cfg(target_os = "linux")] + fn transition_state_at( + runtime_path: &Path, + runtime_root: &Path, + container_id: &str, + operation: &str, + source_states: &[&str], + target_state: &str, + ) -> Result<()> { + let state = + Self::query_state_at(runtime_path, runtime_root, container_id)?.ok_or_else(|| { + BoxError::StateError(format!( + "Sandbox runtime {container_id} does not exist for {operation}" + )) + })?; + if state.status == target_state { + return Ok(()); + } + if !source_states.contains(&state.status.as_str()) { + return Err(BoxError::StateError(format!( + "Cannot {operation} Sandbox runtime {container_id} in state {}", + state.status + ))); + } + + let output = Command::new(runtime_path) + .arg("--root") + .arg(runtime_root) + .arg(operation) + .arg(container_id) + .env("LC_ALL", "C") + .output() + .map_err(|error| { + BoxError::ExecError(format!("Failed to run crun {operation}: {error}")) + })?; + if !output.status.success() { + if Self::query_state_at(runtime_path, runtime_root, container_id)? + .is_some_and(|state| state.status == target_state) + { + return Ok(()); + } + return Err(runtime_failure(&format!("crun {operation}"), &output)); + } + + let deadline = Instant::now() + LIFECYCLE_TIMEOUT; + loop { + match Self::query_state_at(runtime_path, runtime_root, container_id)? { + Some(state) if state.status == target_state => return Ok(()), + Some(state) if state.status == "stopped" => { + return Err(BoxError::StateError(format!( + "Sandbox runtime {container_id} stopped while waiting for {operation}" + ))) + } + None => { + return Err(BoxError::StateError(format!( + "Sandbox runtime {container_id} disappeared while waiting for {operation}" + ))) + } + Some(_) if Instant::now() < deadline => { + std::thread::sleep(LIFECYCLE_POLL_INTERVAL); + } + Some(state) => { + return Err(BoxError::StateError(format!( + "Timed out waiting for Sandbox runtime {container_id} to enter {target_state}; current state is {}", + state.status + ))) + } + } + } + } + + fn runtime_command(&self, operation: &str) -> Command { + let mut command = Command::new(&self.runtime_path); + command + .arg("--root") + .arg(&self.runtime_root) + .arg(operation) + .env("LC_ALL", "C"); + command + } + + fn signal_container(&self, signal: i32) -> Result<()> { + let output = self + .runtime_command("kill") + .arg(&self.container_id) + .arg(signal.to_string()) + .output() + .map_err(|error| BoxError::ExecError(format!("Failed to run crun kill: {error}")))?; + if output.status.success() { + return Ok(()); + } + match self.query_state()? { + None => return Ok(()), + Some(state) if state.status == "stopped" => return Ok(()), + Some(_) => {} + } + Err(runtime_failure("crun kill", &output)) + } + + fn query_state(&self) -> Result> { + Self::query_state_at(&self.runtime_path, &self.runtime_root, &self.container_id) + } + + fn wait_for_exit(&mut self, timeout_ms: u64) -> Result { + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + if self.poll_child()?.is_some() || self.query_state()?.is_none() { + return Ok(true); + } + if Instant::now() >= deadline { + return Ok(false); + } + std::thread::sleep(Duration::from_millis(25)); + } + } + + fn poll_child(&mut self) -> Result> { + if self.exit_code.is_some() { + return Ok(self.exit_code); + } + let Some(process) = self.process.as_mut() else { + return Ok(None); + }; + match process.try_wait() { + Ok(Some(status)) => { + self.exit_code = status.code().or(Some(128)); + Ok(self.exit_code) + } + Ok(None) => Ok(None), + Err(error) => Err(BoxError::ExecError(format!( + "Failed to poll crun process for {}: {error}", + self.container_id + ))), + } + } + + fn reap_child(&mut self) { + let Some(mut process) = self.process.take() else { + return; + }; + match process.try_wait() { + Ok(Some(status)) => { + self.exit_code = status.code().or(self.exit_code).or(Some(128)); + return; + } + Ok(None) => { + // OCI cleanup already ran before this helper. Killing a stuck + // wrapper here cannot replace container cleanup; it only + // guarantees that handler teardown never blocks indefinitely. + let _ = process.kill(); + } + Err(error) => { + tracing::warn!( + container_id = %self.container_id, + %error, + "Failed to poll crun run process before reaping" + ); + let _ = process.kill(); + } + } + match process.wait() { + Ok(status) => { + self.exit_code = status.code().or(self.exit_code).or(Some(128)); + } + Err(error) => { + tracing::warn!( + container_id = %self.container_id, + %error, + "Failed to reap crun run process" + ); + } + } + } + + fn reap_log_worker(&mut self) { + const LOG_WORKER_EXIT_TIMEOUT: Duration = Duration::from_secs(2); + const LOG_WORKER_EXIT_POLL: Duration = Duration::from_millis(10); + + if let Some(mut worker) = self.log_worker.take() { + let deadline = Instant::now() + LOG_WORKER_EXIT_TIMEOUT; + loop { + match worker.try_wait() { + Ok(Some(_)) => return, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(LOG_WORKER_EXIT_POLL); + } + Ok(None) => break, + Err(error) => { + tracing::warn!( + container_id = %self.container_id, + %error, + "Failed to poll Sandbox log worker before reaping" + ); + break; + } + } + } + tracing::warn!( + container_id = %self.container_id, + "Sandbox log worker did not exit after crun; terminating it" + ); + let _ = worker.kill(); + let _ = worker.wait(); + return; + } + + let (Some(pid), Some(start_time)) = (self.log_worker_pid, self.log_worker_pid_start_time) + else { + return; + }; + let deadline = Instant::now() + LOG_WORKER_EXIT_TIMEOUT; + while crate::process::is_process_running_with_identity(pid, Some(start_time)) + && Instant::now() < deadline + { + std::thread::sleep(LOG_WORKER_EXIT_POLL); + } + if crate::process::is_process_running_with_identity(pid, Some(start_time)) { + tracing::warn!( + container_id = %self.container_id, + log_worker_pid = pid, + "Recovered Sandbox log worker did not exit after crun; terminating it" + ); + // The start-time token was revalidated immediately before the + // signal, so a reused PID cannot be targeted. + #[cfg(target_os = "linux")] + if let Ok(pid) = i32::try_from(pid) { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + } + } + + fn delete_runtime_state(&mut self) -> Result<()> { + if self.cleaned { + return Ok(()); + } + let output = self + .runtime_command("delete") + .arg("--force") + .arg(&self.container_id) + .output() + .map_err(|error| BoxError::ExecError(format!("Failed to run crun delete: {error}")))?; + if !output.status.success() && self.query_state()?.is_some() { + return Err(runtime_failure("crun delete --force", &output)); + } + // Reap the wrapper first: its inherited stdout/stderr descriptors must + // close before the worker treats EOF as final. Then wait for the worker + // to drain both streams before removing durable generation artifacts. + self.reap_child(); + self.reap_log_worker(); + self.cleaned = true; + remove_file_if_exists(&self.runtime_record); + remove_dir_if_exists(&self.bundle_dir); + remove_dir_if_exists(&self.runtime_root); + Ok(()) + } +} + +impl VmHandler for CrunHandler { + fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()> { + let mut first_error = None; + if self.query_state()?.is_some() { + if let Err(error) = self.signal_container(signal) { + first_error = Some(error); + } + match self.wait_for_exit(timeout_ms) { + Ok(true) => {} + Ok(false) => { + tracing::warn!( + container_id = %self.container_id, + timeout_ms, + "Sandbox did not stop gracefully; sending SIGKILL" + ); + if let Err(error) = self.signal_container(SIGKILL_NUMBER) { + first_error.get_or_insert(error); + } + let _ = self.wait_for_exit(2_000); + } + Err(error) => { + first_error.get_or_insert(error); + let _ = self.signal_container(SIGKILL_NUMBER); + } + } + } + + if let Err(error) = self.delete_runtime_state() { + first_error.get_or_insert(error); + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + fn metrics(&self) -> VmMetrics { + let pid = Pid::from_u32(self.init_pid); + let mut system = match self.metrics_sys.lock() { + Ok(system) => system, + Err(error) => { + tracing::warn!(%error, "Sandbox metrics lock is poisoned"); + return VmMetrics::default(); + } + }; + system.refresh_process(pid); + system + .process(pid) + .map(|process| VmMetrics { + cpu_percent: Some(process.cpu_usage()), + memory_bytes: Some(process.memory()), + }) + .unwrap_or_default() + } + + fn is_running(&self) -> bool { + self.query_state() + .ok() + .flatten() + .is_some_and(|state| matches!(state.status.as_str(), "created" | "running" | "paused")) + } + + fn has_exited(&self) -> bool { + !self.is_running() + } + + fn pid(&self) -> u32 { + self.init_pid + } + + fn exit_code(&self) -> Option { + self.exit_code + } + + fn try_wait_exit(&mut self) -> Result> { + let exit = self.poll_child()?; + if exit.is_some() { + self.delete_runtime_state()?; + } + Ok(exit) + } +} + +fn runtime_failure(operation: &str, output: &Output) -> BoxError { + let stderr = String::from_utf8_lossy(&output.stderr); + BoxError::ExecError(format!( + "{operation} exited with {}: {}", + output.status, + stderr.trim() + )) +} + +fn remove_file_if_exists(path: &Path) { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!(path = %path.display(), %error, "Failed to remove Sandbox runtime record") + } + } +} + +fn remove_dir_if_exists(path: &Path) { + match std::fs::remove_dir_all(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!(path = %path.display(), %error, "Failed to remove Sandbox runtime directory") + } + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + fn lifecycle_runtime(temporary: &tempfile::TempDir) -> (PathBuf, PathBuf) { + use std::os::unix::fs::PermissionsExt; + + let runtime_root = temporary.path().join("runtime"); + std::fs::create_dir(&runtime_root).unwrap(); + std::fs::write(runtime_root.join("state"), "running\n").unwrap(); + let runtime = temporary.path().join("crun-fixture"); + std::fs::write( + &runtime, + r#"#!/bin/sh +root="$2" +operation="$3" +case "$operation" in + state) + status="$(cat "$root/state")" + printf '{"status":"%s","pid":42}\n' "$status" + ;; + pause) + printf 'paused\n' > "$root/state" + ;; + resume) + printf 'running\n' > "$root/state" + ;; + *) + exit 64 + ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700)).unwrap(); + (runtime, runtime_root) + } + + #[test] + fn crun_pause_and_resume_are_state_checked_and_idempotent() { + let temporary = tempfile::tempdir().unwrap(); + let (runtime, runtime_root) = lifecycle_runtime(&temporary); + + CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap(); + CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap(); + assert_eq!( + CrunHandler::query_state_at(&runtime, &runtime_root, "sandbox-1") + .unwrap() + .unwrap() + .status, + "paused" + ); + + CrunHandler::resume_at(&runtime, &runtime_root, "sandbox-1").unwrap(); + CrunHandler::resume_at(&runtime, &runtime_root, "sandbox-1").unwrap(); + assert_eq!( + CrunHandler::query_state_at(&runtime, &runtime_root, "sandbox-1") + .unwrap() + .unwrap() + .status, + "running" + ); + } + + #[test] + fn crun_pause_rejects_a_terminal_runtime() { + let temporary = tempfile::tempdir().unwrap(); + let (runtime, runtime_root) = lifecycle_runtime(&temporary); + std::fs::write(runtime_root.join("state"), "stopped\n").unwrap(); + + let error = CrunHandler::pause_at(&runtime, &runtime_root, "sandbox-1").unwrap_err(); + + assert!(error.to_string().contains("state stopped")); + } + + #[cfg(feature = "vm")] + #[test] + fn recorded_runtime_handler_attaches_without_owning_a_wrapper_process() { + let temporary = tempfile::tempdir().unwrap(); + let runtime_path = PathBuf::from("/bin/true"); + let runtime_root = temporary.path().join("runtime"); + let bundle_dir = temporary.path().join("bundle"); + let runtime_record = temporary.path().join("runtime.json"); + + let handler = CrunHandler::from_recorded_runtime( + CrunHandlerSpec::new( + runtime_path.clone(), + runtime_root.clone(), + "recorded-test".to_string(), + 42, + bundle_dir.clone(), + runtime_record.clone(), + ), + None, + None, + ); + + assert_eq!(handler.runtime_path, runtime_path); + assert_eq!(handler.runtime_root, runtime_root); + assert_eq!(handler.container_id, "recorded-test"); + assert_eq!(handler.pid(), 42); + assert!(handler.process.is_none()); + assert!(handler.log_worker.is_none()); + assert!(handler.log_worker_pid.is_none()); + assert_eq!(handler.bundle_dir, bundle_dir); + assert_eq!(handler.runtime_record, runtime_record); + assert!(!handler.cleaned); + } + + #[test] + fn dropping_handler_detaches_from_live_runtime_process() { + let temporary = tempfile::tempdir().unwrap(); + let child = Command::new("sleep").arg("30").spawn().unwrap(); + let pid = child.id(); + let log_worker = Command::new("sleep").arg("30").spawn().unwrap(); + let log_worker_pid = log_worker.id(); + let log_worker_pid_start_time = crate::process::pid_start_time(log_worker_pid).unwrap(); + let handler = CrunHandler::from_child( + CrunHandlerSpec::new( + PathBuf::from("/bin/true"), + temporary.path().join("runtime"), + "detached-test".to_string(), + pid, + temporary.path().join("bundle"), + temporary.path().join("runtime.json"), + ), + child, + log_worker, + log_worker_pid_start_time, + ); + + drop(handler); + let remained_alive = unsafe { libc::kill(pid as i32, 0) == 0 }; + let log_worker_remained_alive = unsafe { libc::kill(log_worker_pid as i32, 0) == 0 }; + + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + let mut status = 0; + libc::waitpid(pid as i32, &mut status, 0); + libc::kill(log_worker_pid as i32, libc::SIGKILL); + libc::waitpid(log_worker_pid as i32, &mut status, 0); + } + assert!( + remained_alive, + "dropping a runtime handle must not destroy a detached Sandbox" + ); + assert!( + log_worker_remained_alive, + "dropping a runtime handle must not destroy its detached log worker" + ); + } +} diff --git a/src/runtime/src/sandbox/mod.rs b/src/runtime/src/sandbox/mod.rs new file mode 100644 index 00000000..45de40c2 --- /dev/null +++ b/src/runtime/src/sandbox/mod.rs @@ -0,0 +1,29 @@ +//! Shared-kernel Sandbox backend support. +//! +//! The public isolation selector stays backend-neutral. This module owns the +//! Linux host evidence and OCI artifacts required by the certified `crun` +//! backend; VM-specific code must not depend on these types. + +pub mod capability; +pub mod controller; +pub mod handler; +pub mod oci; +pub mod path_access; +pub mod rootfs; + +pub use capability::{ + map_container_gid, map_container_uid, plan_id_mappings, probe_sandbox_capabilities, + unmap_host_gid, unmap_host_uid, CertifiedCrun, IdMapping, SandboxCapabilitySnapshot, + SandboxIdMappingPlan, UserNamespaceEvidence, CERTIFIED_CRUN_VERSION, +}; +pub use controller::{write_bundle, CrunController, SandboxLaunchSpec}; +pub use handler::CrunHandler; +pub use oci::{ + compile_oci_spec, SandboxBundleSpec, SandboxMount, SandboxResources, SandboxTmpfs, + DEFAULT_SANDBOX_PIDS_LIMIT, +}; +pub use path_access::prepare_crun_path_access; +pub use rootfs::{ + inspect_rootfs_identity_requirements, mapped_root_ids, prepare_managed_mount_source, + prepare_rootfs_ownership, validate_external_mount_access, RootfsIdentityRequirements, +}; diff --git a/src/runtime/src/sandbox/oci.rs b/src/runtime/src/sandbox/oci.rs new file mode 100644 index 00000000..e456e14b --- /dev/null +++ b/src/runtime/src/sandbox/oci.rs @@ -0,0 +1,1384 @@ +//! Generated OCI specification for the certified Sandbox backend. + +use std::collections::{HashMap, HashSet}; +use std::path::{Component, Path, PathBuf}; + +use a3s_box_core::config::BoxConfig; +use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::rootfs_metadata::RUNTIME_ENV_PATH; +use oci_spec::runtime::{ + Arch, Capabilities, Capability, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxCpuBuilder, + LinuxDeviceBuilder, LinuxDeviceCgroupBuilder, LinuxDeviceType, LinuxIdMappingBuilder, + LinuxMemoryBuilder, LinuxNamespaceBuilder, LinuxNamespaceType, LinuxPidsBuilder, + LinuxResourcesBuilder, LinuxSeccompAction, LinuxSeccompArgBuilder, LinuxSeccompBuilder, + LinuxSeccompOperator, LinuxSyscallBuilder, Mount, MountBuilder, ProcessBuilder, RootBuilder, + Spec, SpecBuilder, UserBuilder, +}; + +use super::capability::{IdMapping, SandboxIdMappingPlan}; + +/// OCI annotation schema for generated A3S Sandbox bundles. +pub const SANDBOX_BUNDLE_SCHEMA: &str = "a3s.box.sandbox-bundle.v1"; +/// Baseline process count enforced even when the caller omits `--pids-limit`. +pub const DEFAULT_SANDBOX_PIDS_LIMIT: i64 = 4096; +const DEFAULT_CPU_PERIOD_US: u64 = 100_000; +const DEFAULT_TMPFS_SIZE: &str = "67108864"; +const LINUX_EPERM: u32 = 1; +const LINUX_ENOSYS: u32 = 38; +const LINUX_CLONE_NAMESPACE_MASK: u64 = + 0x0002_0000 | 0x0200_0000 | 0x0400_0000 | 0x0800_0000 | 0x1000_0000 | 0x2000_0000 | 0x4000_0000; + +/// A host path deliberately exposed inside the Sandbox. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxMount { + pub source: PathBuf, + pub destination: PathBuf, + pub read_only: bool, +} + +/// A generated tmpfs mount with a bounded byte size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxTmpfs { + pub destination: PathBuf, + pub size_bytes: u64, +} + +/// Cgroup values compiled from `BoxConfig`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxResources { + pub memory_limit: i64, + pub memory_reservation: Option, + pub memory_swap: Option, + pub cpu_shares: Option, + pub cpu_quota: i64, + pub cpu_period: u64, + pub cpuset_cpus: Option, + pub pids_limit: i64, +} + +impl SandboxResources { + /// Convert public resource intent into strict OCI cgroup controls. + pub fn from_box_config(config: &BoxConfig) -> Result { + if config.resources.memory_mb == 0 { + return Err(BoxError::ConfigError( + "Sandbox memory limit must be greater than zero".to_string(), + )); + } + if config.resources.vcpus == 0 { + return Err(BoxError::ConfigError( + "Sandbox CPU limit must be greater than zero".to_string(), + )); + } + + let memory_limit = i64::from(config.resources.memory_mb) + .checked_mul(1024 * 1024) + .ok_or_else(|| { + BoxError::ConfigError("Sandbox memory limit overflows i64".to_string()) + })?; + let memory_reservation = config + .resource_limits + .memory_reservation + .map(|value| { + i64::try_from(value).map_err(|_| { + BoxError::ConfigError("Sandbox memory reservation overflows i64".to_string()) + }) + }) + .transpose()?; + if memory_reservation.is_some_and(|reservation| reservation > memory_limit) { + return Err(BoxError::ConfigError( + "Sandbox memory reservation cannot exceed the hard memory limit".to_string(), + )); + } + let memory_swap = config.resource_limits.memory_swap; + if memory_swap.is_some_and(|swap| swap != -1 && swap < memory_limit) { + return Err(BoxError::ConfigError( + "Sandbox memory+swap limit cannot be below the hard memory limit".to_string(), + )); + } + + let cpu_period = config + .resource_limits + .cpu_period + .unwrap_or(DEFAULT_CPU_PERIOD_US); + if cpu_period == 0 { + return Err(BoxError::ConfigError( + "Sandbox CPU period must be greater than zero".to_string(), + )); + } + let cpu_quota = match config.resource_limits.cpu_quota { + Some(quota) if quota > 0 => quota, + Some(_) => { + return Err(BoxError::ConfigError( + "Sandbox CPU quota must be greater than zero".to_string(), + )) + } + None => i64::from(config.resources.vcpus) + .checked_mul(i64::try_from(cpu_period).map_err(|_| { + BoxError::ConfigError("Sandbox CPU period overflows i64".to_string()) + })?) + .ok_or_else(|| { + BoxError::ConfigError("Sandbox CPU quota overflows i64".to_string()) + })?, + }; + + if config + .resource_limits + .cpu_shares + .is_some_and(|shares| !(2..=262_144).contains(&shares)) + { + return Err(BoxError::ConfigError( + "Sandbox CPU shares must be between 2 and 262144".to_string(), + )); + } + if let Some(cpuset) = config.resource_limits.cpuset_cpus.as_deref() { + validate_cpuset(cpuset)?; + } + + let pids_limit_u64 = config + .resource_limits + .pids_limit + .unwrap_or(DEFAULT_SANDBOX_PIDS_LIMIT as u64); + let pids_limit = i64::try_from(pids_limit_u64) + .map_err(|_| BoxError::ConfigError("Sandbox PID limit overflows i64".to_string()))?; + if pids_limit <= 0 { + return Err(BoxError::ConfigError( + "Sandbox PID limit must be greater than zero".to_string(), + )); + } + + Ok(Self { + memory_limit, + memory_reservation, + memory_swap, + cpu_shares: config.resource_limits.cpu_shares, + cpu_quota, + cpu_period, + cpuset_cpus: config.resource_limits.cpuset_cpus.clone(), + pids_limit, + }) + } +} + +/// Backend-neutral inputs already validated and resolved by the runtime. +#[derive(Debug, Clone)] +pub struct SandboxBundleSpec { + pub box_id: String, + pub rootfs_path: PathBuf, + pub rootfs_read_only: bool, + pub hostname: String, + pub init_environment: Vec<(String, String)>, + pub mounts: Vec, + pub tmpfs: Vec, + pub id_mappings: SandboxIdMappingPlan, + pub resources: SandboxResources, + pub requested_capabilities: Vec, + pub execution_plan_digest: String, + pub runtime_digest: String, +} + +/// Compile a complete OCI config. Arbitrary caller-provided OCI JSON is never +/// accepted by this backend. +pub fn compile_oci_spec(input: &SandboxBundleSpec) -> Result { + validate_box_id(&input.box_id)?; + validate_rootfs_path(&input.rootfs_path)?; + validate_hostname(&input.hostname)?; + validate_digest("execution plan", &input.execution_plan_digest)?; + validate_digest("runtime", &input.runtime_digest)?; + validate_id_mapping_plan(&input.id_mappings)?; + + let process = ProcessBuilder::default() + .terminal(false) + .user( + UserBuilder::default() + .uid(0u32) + .gid(0u32) + .build() + .map_err(oci_error)?, + ) + .args(vec!["/sbin/init".to_string()]) + .env(compile_environment(&input.init_environment)?) + .cwd(PathBuf::from("/")) + .capabilities(compile_capabilities(&input.requested_capabilities)?) + .no_new_privileges(true) + .build() + .map_err(oci_error)?; + + let linux = LinuxBuilder::default() + .uid_mappings(compile_id_mappings(&input.id_mappings.uid_mappings)?) + .gid_mappings(compile_id_mappings(&input.id_mappings.gid_mappings)?) + .namespaces(compile_namespaces()?) + .resources(compile_resources(&input.resources)?) + .cgroups_path(PathBuf::from(format!("a3s-box/{}", input.box_id))) + .devices(compile_devices()?) + .seccomp(compile_seccomp()?) + .rootfs_propagation("private".to_string()) + .masked_paths(masked_paths()) + .readonly_paths(readonly_paths()) + .build() + .map_err(oci_error)?; + + let mut annotations = HashMap::new(); + annotations.insert( + "com.a3s.box.sandbox.schema".to_string(), + SANDBOX_BUNDLE_SCHEMA.to_string(), + ); + annotations.insert( + "com.a3s.box.execution-plan.digest".to_string(), + input.execution_plan_digest.clone(), + ); + annotations.insert( + "com.a3s.box.runtime.digest".to_string(), + input.runtime_digest.clone(), + ); + annotations.insert( + "com.a3s.box.isolation-class".to_string(), + "shared-kernel".to_string(), + ); + + SpecBuilder::default() + .version("1.1.0".to_string()) + .root( + RootBuilder::default() + .path(input.rootfs_path.clone()) + .readonly(input.rootfs_read_only) + .build() + .map_err(oci_error)?, + ) + .mounts(compile_mounts(&input.mounts, &input.tmpfs)?) + .process(process) + .hostname(input.hostname.clone()) + .annotations(annotations) + .linux(linux) + .build() + .map_err(oci_error) +} + +fn compile_environment(environment: &[(String, String)]) -> Result> { + let mut values = std::collections::BTreeMap::new(); + for (key, value) in environment { + if key.is_empty() || key.contains(['=', '\0']) || value.contains('\0') { + return Err(BoxError::ConfigError(format!( + "Invalid Sandbox process environment key {key:?}" + ))); + } + values.insert(key.clone(), value.clone()); + } + values.entry("PATH".to_string()).or_insert_with(|| { + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string() + }); + values.insert("A3S_BOOTSTRAP_MODE".to_string(), "host-sandbox".to_string()); + values.insert("A3S_EXEC_LISTENER_FD".to_string(), "3".to_string()); + values.insert("A3S_PTY_LISTENER_FD".to_string(), "4".to_string()); + values.insert("A3S_INIT_LOG_FD".to_string(), "5".to_string()); + + Ok(values + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect()) +} + +fn compile_capabilities(requested: &[String]) -> Result { + // PID 1 needs these only for metadata replay, loopback setup, and dropping + // to the configured workload user. guest-init narrows the child process to + // the user-requested set before exec. + let mut bounding: Capabilities = [ + Capability::Chown, + Capability::DacOverride, + Capability::Fowner, + Capability::Fsetid, + Capability::Kill, + Capability::NetAdmin, + Capability::NetBindService, + Capability::Setgid, + Capability::Setpcap, + Capability::Setuid, + Capability::SysChroot, + ] + .into_iter() + .collect(); + + for capability in requested { + bounding.insert(parse_allowed_capability(capability)?); + } + + LinuxCapabilitiesBuilder::default() + .bounding(bounding.clone()) + .effective(bounding.clone()) + .permitted(bounding) + .inheritable(HashSet::::new()) + .ambient(HashSet::::new()) + .build() + .map_err(oci_error) +} + +fn parse_allowed_capability(value: &str) -> Result { + let normalized = value + .trim() + .to_ascii_uppercase() + .strip_prefix("CAP_") + .map(ToString::to_string) + .unwrap_or_else(|| value.trim().to_ascii_uppercase()); + let capability = match normalized.as_str() { + "AUDIT_WRITE" => Capability::AuditWrite, + "CHOWN" => Capability::Chown, + "DAC_OVERRIDE" => Capability::DacOverride, + "FOWNER" => Capability::Fowner, + "FSETID" => Capability::Fsetid, + "KILL" => Capability::Kill, + "MKNOD" => Capability::Mknod, + "NET_BIND_SERVICE" => Capability::NetBindService, + "SETFCAP" => Capability::Setfcap, + "SETGID" => Capability::Setgid, + "SETPCAP" => Capability::Setpcap, + "SETUID" => Capability::Setuid, + "SYS_CHROOT" => Capability::SysChroot, + _ => { + return Err(BoxError::ConfigError(format!( + "Sandbox capability {value:?} is outside the allowlist" + ))) + } + }; + Ok(capability) +} + +fn compile_id_mappings(mappings: &[IdMapping]) -> Result> { + mappings + .iter() + .map(|mapping| { + LinuxIdMappingBuilder::default() + .container_id(mapping.container_id) + .host_id(mapping.host_id) + .size(mapping.size) + .build() + .map_err(oci_error) + }) + .collect() +} + +fn compile_namespaces() -> Result> { + [ + LinuxNamespaceType::User, + LinuxNamespaceType::Mount, + LinuxNamespaceType::Pid, + LinuxNamespaceType::Ipc, + LinuxNamespaceType::Uts, + LinuxNamespaceType::Network, + LinuxNamespaceType::Cgroup, + ] + .into_iter() + .map(|typ| { + LinuxNamespaceBuilder::default() + .typ(typ) + .build() + .map_err(oci_error) + }) + .collect() +} + +fn compile_resources(resources: &SandboxResources) -> Result { + let mut memory = LinuxMemoryBuilder::default().limit(resources.memory_limit); + if let Some(reservation) = resources.memory_reservation { + memory = memory.reservation(reservation); + } + if let Some(swap) = resources.memory_swap { + memory = memory.swap(swap); + } + + let mut cpu = LinuxCpuBuilder::default() + .quota(resources.cpu_quota) + .period(resources.cpu_period); + if let Some(shares) = resources.cpu_shares { + cpu = cpu.shares(shares); + } + if let Some(cpuset) = resources.cpuset_cpus.as_ref() { + cpu = cpu.cpus(cpuset.clone()); + } + + let mut device_rules = vec![LinuxDeviceCgroupBuilder::default() + .allow(false) + .access("rwm".to_string()) + .build() + .map_err(oci_error)?]; + for device in minimal_device_numbers() { + device_rules.push( + LinuxDeviceCgroupBuilder::default() + .allow(true) + .typ(LinuxDeviceType::C) + .major(device.1) + .minor(device.2) + .access("rwm".to_string()) + .build() + .map_err(oci_error)?, + ); + } + + LinuxResourcesBuilder::default() + .devices(device_rules) + .memory(memory.build().map_err(oci_error)?) + .cpu(cpu.build().map_err(oci_error)?) + .pids( + LinuxPidsBuilder::default() + .limit(resources.pids_limit) + .build() + .map_err(oci_error)?, + ) + .build() + .map_err(oci_error) +} + +fn compile_devices() -> Result> { + minimal_device_numbers() + .iter() + .map(|(path, major, minor)| { + LinuxDeviceBuilder::default() + .path(PathBuf::from(path)) + .typ(LinuxDeviceType::C) + .major(*major) + .minor(*minor) + .file_mode(0o666u32) + .uid(0u32) + .gid(0u32) + .build() + .map_err(oci_error) + }) + .collect() +} + +fn minimal_device_numbers() -> &'static [(&'static str, i64, i64)] { + &[ + ("/dev/null", 1, 3), + ("/dev/zero", 1, 5), + ("/dev/full", 1, 7), + ("/dev/random", 1, 8), + ("/dev/urandom", 1, 9), + ("/dev/tty", 5, 0), + ] +} + +fn compile_mounts(user_mounts: &[SandboxMount], user_tmpfs: &[SandboxTmpfs]) -> Result> { + let mut mounts = vec![ + mount("/proc", "proc", "proc", &["nosuid", "noexec", "nodev"])?, + mount( + "/dev", + "tmpfs", + "tmpfs", + &[ + "nosuid", + "strictatime", + "mode=755", + &format!("size={DEFAULT_TMPFS_SIZE}"), + ], + )?, + mount( + "/dev/pts", + "devpts", + "devpts", + &[ + "nosuid", + "noexec", + "newinstance", + "ptmxmode=0666", + "mode=0620", + "gid=5", + ], + )?, + mount( + "/dev/shm", + "tmpfs", + "shm", + &[ + "nosuid", + "noexec", + "nodev", + "mode=1777", + &format!("size={DEFAULT_TMPFS_SIZE}"), + ], + )?, + mount( + "/dev/mqueue", + "mqueue", + "mqueue", + &["nosuid", "noexec", "nodev"], + )?, + mount( + "/sys", + "sysfs", + "sysfs", + &["nosuid", "noexec", "nodev", "ro"], + )?, + mount( + "/sys/fs/cgroup", + "cgroup", + "cgroup", + &["nosuid", "noexec", "nodev", "relatime", "ro"], + )?, + mount( + "/tmp", + "tmpfs", + "tmpfs", + &[ + "nosuid", + "nodev", + "mode=1777", + &format!("size={DEFAULT_TMPFS_SIZE}"), + ], + )?, + mount( + "/run", + "tmpfs", + "tmpfs", + &[ + "nosuid", + "nodev", + "mode=755", + &format!("size={DEFAULT_TMPFS_SIZE}"), + ], + )?, + ]; + + let mut destinations: HashSet = mounts + .iter() + .map(|entry| entry.destination().clone()) + .collect(); + for user_mount in user_mounts { + validate_user_mount(user_mount)?; + if !destinations.insert(user_mount.destination.clone()) { + return Err(BoxError::ConfigError(format!( + "Duplicate Sandbox mount destination {}", + user_mount.destination.display() + ))); + } + let mut options = vec![ + "rbind".to_string(), + "rprivate".to_string(), + "nosuid".to_string(), + "nodev".to_string(), + ]; + options.push(if user_mount.read_only { "ro" } else { "rw" }.to_string()); + mounts.push( + MountBuilder::default() + .destination(user_mount.destination.clone()) + .typ("bind".to_string()) + .source(user_mount.source.clone()) + .options(options) + .build() + .map_err(oci_error)?, + ); + } + + for tmpfs in user_tmpfs { + validate_absolute_normalized(&tmpfs.destination, "tmpfs destination")?; + if tmpfs.size_bytes == 0 { + return Err(BoxError::ConfigError(format!( + "Sandbox tmpfs {} must have a non-zero size", + tmpfs.destination.display() + ))); + } + let is_shared_memory = tmpfs.destination == Path::new("/dev/shm"); + if path_is_or_below(&tmpfs.destination, Path::new("/proc")) + || path_is_or_below(&tmpfs.destination, Path::new("/sys")) + || (path_is_or_below(&tmpfs.destination, Path::new("/dev")) && !is_shared_memory) + || path_is_or_below(&tmpfs.destination, Path::new("/run/a3s-box")) + || tmpfs.destination == Path::new("/") + { + return Err(BoxError::ConfigError(format!( + "Sandbox tmpfs destination {} is protected", + tmpfs.destination.display() + ))); + } + // A user size for /tmp or /run replaces the generated default rather + // than creating two mounts at the same destination. + if let Some(index) = mounts + .iter() + .position(|mount| mount.destination() == &tmpfs.destination) + { + if !matches!( + tmpfs.destination.to_str(), + Some("/tmp" | "/run" | "/dev/shm") + ) { + return Err(BoxError::ConfigError(format!( + "Duplicate Sandbox mount destination {}", + tmpfs.destination.display() + ))); + } + mounts.remove(index); + destinations.remove(&tmpfs.destination); + } + if !destinations.insert(tmpfs.destination.clone()) { + return Err(BoxError::ConfigError(format!( + "Duplicate Sandbox mount destination {}", + tmpfs.destination.display() + ))); + } + let mut options = vec![ + "nosuid".to_string(), + "nodev".to_string(), + "mode=1777".to_string(), + format!("size={}", tmpfs.size_bytes), + ]; + if is_shared_memory { + options.push("noexec".to_string()); + } + mounts.push( + MountBuilder::default() + .destination(tmpfs.destination.clone()) + .typ("tmpfs".to_string()) + .source(PathBuf::from("tmpfs")) + .options(options) + .build() + .map_err(oci_error)?, + ); + } + + Ok(mounts) +} + +fn mount(destination: &str, typ: &str, source: &str, options: &[&str]) -> Result { + MountBuilder::default() + .destination(PathBuf::from(destination)) + .typ(typ.to_string()) + .source(PathBuf::from(source)) + .options( + options + .iter() + .map(|value| (*value).to_string()) + .collect::>(), + ) + .build() + .map_err(oci_error) +} + +fn compile_seccomp() -> Result { + let allowed = LinuxSyscallBuilder::default() + .names( + ALLOWED_SYSCALLS + .iter() + .map(|name| (*name).to_string()) + .collect::>(), + ) + .action(LinuxSeccompAction::ScmpActAllow) + .build() + .map_err(oci_error)?; + + // clone is needed for threads/processes, but namespace creation stays + // denied. clone3 deliberately returns ENOSYS so libc falls back to clone. + let clone = LinuxSyscallBuilder::default() + .names(vec!["clone".to_string()]) + .action(LinuxSeccompAction::ScmpActAllow) + .args(vec![LinuxSeccompArgBuilder::default() + .index(0usize) + .value(0u64) + .value_two(LINUX_CLONE_NAMESPACE_MASK) + .op(LinuxSeccompOperator::ScmpCmpMaskedEq) + .build() + .map_err(oci_error)?]) + .build() + .map_err(oci_error)?; + let clone3 = LinuxSyscallBuilder::default() + .names(vec!["clone3".to_string()]) + .action(LinuxSeccompAction::ScmpActErrno) + .errno_ret(LINUX_ENOSYS) + .build() + .map_err(oci_error)?; + + LinuxSeccompBuilder::default() + .default_action(LinuxSeccompAction::ScmpActErrno) + .default_errno_ret(LINUX_EPERM) + .architectures(vec![certified_seccomp_architecture()?]) + .syscalls(vec![allowed, clone, clone3]) + .build() + .map_err(oci_error) +} + +fn certified_seccomp_architecture() -> Result { + match std::env::consts::ARCH { + "x86_64" => Ok(Arch::ScmpArchX86_64), + "aarch64" => Ok(Arch::ScmpArchAarch64), + architecture => Err(BoxError::ConfigError(format!( + "Sandbox seccomp is not certified for architecture {architecture}" + ))), + } +} + +fn validate_id_mapping_plan(plan: &SandboxIdMappingPlan) -> Result<()> { + validate_mapping_set(&plan.uid_mappings, plan.maximum_container_uid, "UID")?; + validate_mapping_set(&plan.gid_mappings, plan.maximum_container_gid, "GID")?; + if plan + .uid_mappings + .iter() + .any(|mapping| mapping.container_id == 0 && mapping.host_id == 0) + || plan + .gid_mappings + .iter() + .any(|mapping| mapping.container_id == 0 && mapping.host_id == 0) + { + return Err(BoxError::ConfigError( + "Sandbox container root must not map to host root".to_string(), + )); + } + Ok(()) +} + +fn validate_mapping_set(mappings: &[IdMapping], maximum: u32, kind: &str) -> Result<()> { + if mappings.is_empty() || mappings[0].container_id != 0 { + return Err(BoxError::ConfigError(format!( + "Sandbox {kind} mappings must start at container ID 0" + ))); + } + let mut next = 0u32; + let mut host_ranges = Vec::new(); + for mapping in mappings { + if mapping.size == 0 || mapping.container_id != next { + return Err(BoxError::ConfigError(format!( + "Sandbox {kind} mappings must be contiguous and non-empty" + ))); + } + next = next.checked_add(mapping.size).ok_or_else(|| { + BoxError::ConfigError(format!("Sandbox {kind} container mapping overflows")) + })?; + let host_end = mapping.host_id.checked_add(mapping.size).ok_or_else(|| { + BoxError::ConfigError(format!("Sandbox {kind} host mapping overflows")) + })?; + if host_ranges + .iter() + .any(|(start, end)| mapping.host_id < *end && *start < host_end) + { + return Err(BoxError::ConfigError(format!( + "Sandbox {kind} host mappings overlap" + ))); + } + host_ranges.push((mapping.host_id, host_end)); + } + if next <= maximum { + return Err(BoxError::ConfigError(format!( + "Sandbox {kind} mappings do not cover container ID {maximum}" + ))); + } + Ok(()) +} + +fn validate_user_mount(mount: &SandboxMount) -> Result<()> { + validate_absolute_normalized(&mount.source, "mount source")?; + validate_absolute_normalized(&mount.destination, "mount destination")?; + + const PROTECTED_SOURCES: &[&str] = &[ + "/", "/boot", "/dev", "/etc", "/proc", "/run", "/sys", "/var/run", + ]; + if PROTECTED_SOURCES + .iter() + .any(|protected| path_is_or_below(&mount.source, Path::new(protected))) + { + return Err(BoxError::ConfigError(format!( + "Sandbox mount source {} is protected", + mount.source.display() + ))); + } + + const PROTECTED_DESTINATIONS: &[&str] = &[ + "/dev", + "/proc", + "/run/a3s-box", + "/sbin/init", + "/sys", + RUNTIME_ENV_PATH, + "/.a3s_image_metadata_v1.json", + "/.a3s_rootfs_metadata_v1.json", + ]; + if mount.destination == Path::new("/") + || PROTECTED_DESTINATIONS.iter().any(|protected| { + let protected = Path::new(protected); + path_is_or_below(&mount.destination, protected) + || (mount.destination != Path::new("/") + && protected.starts_with(&mount.destination)) + }) + { + return Err(BoxError::ConfigError(format!( + "Sandbox mount destination {} is protected", + mount.destination.display() + ))); + } + Ok(()) +} + +fn validate_rootfs_path(path: &Path) -> Result<()> { + validate_absolute_normalized(path, "rootfs path")?; + if path == Path::new("/") { + return Err(BoxError::ConfigError( + "Host root cannot be used as a Sandbox rootfs".to_string(), + )); + } + Ok(()) +} + +fn validate_absolute_normalized(path: &Path, label: &str) -> Result<()> { + if !path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::CurDir | Component::ParentDir | Component::Prefix(_) + ) + }) + { + return Err(BoxError::ConfigError(format!( + "Sandbox {label} must be an absolute normalized path: {}", + path.display() + ))); + } + Ok(()) +} + +fn path_is_or_below(path: &Path, protected: &Path) -> bool { + path == protected || (protected != Path::new("/") && path.starts_with(protected)) +} + +fn validate_box_id(box_id: &str) -> Result<()> { + if box_id.is_empty() + || box_id.len() > 128 + || !box_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(BoxError::ConfigError(format!( + "Invalid Sandbox box ID {box_id:?}" + ))); + } + Ok(()) +} + +fn validate_hostname(hostname: &str) -> Result<()> { + if hostname.is_empty() + || hostname.len() > 253 + || hostname.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(BoxError::ConfigError(format!( + "Invalid Sandbox hostname {hostname:?}" + ))); + } + Ok(()) +} + +fn validate_digest(label: &str, digest: &str) -> Result<()> { + let Some(hex) = digest.strip_prefix("sha256:") else { + return Err(BoxError::ConfigError(format!( + "Sandbox {label} digest must use sha256" + ))); + }; + if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(BoxError::ConfigError(format!( + "Invalid Sandbox {label} digest" + ))); + } + Ok(()) +} + +fn validate_cpuset(value: &str) -> Result<()> { + let first = value.as_bytes().first().copied(); + let last = value.as_bytes().last().copied(); + if value.is_empty() + || matches!(first, Some(b',' | b'-')) + || matches!(last, Some(b',' | b'-')) + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b',' | b'-')) + { + return Err(BoxError::ConfigError(format!( + "Invalid Sandbox cpuset {value:?}" + ))); + } + Ok(()) +} + +fn masked_paths() -> Vec { + [ + "/proc/acpi", + "/proc/asound", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/sched_debug", + "/proc/scsi", + "/proc/timer_list", + "/proc/timer_stats", + "/sys/devices/virtual/powercap", + "/sys/firmware", + ] + .into_iter() + .map(ToString::to_string) + .collect() +} + +fn readonly_paths() -> Vec { + [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger", + ] + .into_iter() + .map(ToString::to_string) + .collect() +} + +fn oci_error(error: impl std::fmt::Display) -> BoxError { + BoxError::ConfigError(format!("Failed to compile Sandbox OCI spec: {error}")) +} + +// Default-deny profile for guest-init and general code execution. Namespace, +// mount, kernel-module, BPF, keyring, perf, io_uring, userfaultfd, reboot, and +// host-control syscalls are intentionally absent. +const ALLOWED_SYSCALLS: &[&str] = &[ + "accept", + "accept4", + "access", + "arch_prctl", + "bind", + "brk", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "clock_getres", + "clock_gettime", + "clock_nanosleep", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fallocate", + "fchdir", + "fchmod", + "fchmodat", + "fchown", + "fchownat", + "fcntl", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstatfs", + "fsync", + "ftruncate", + "futex", + "futex_waitv", + "getcwd", + "getdents", + "getdents64", + "getegid", + "geteuid", + "getgid", + "getgroups", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresuid", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "gettid", + "gettimeofday", + "getuid", + "getxattr", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "ioctl", + "ioprio_get", + "ioprio_set", + "kill", + "lchown", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "madvise", + "membarrier", + "memfd_create", + "mincore", + "mkdir", + "mkdirat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mprotect", + "mremap", + "msync", + "munlock", + "munlockall", + "munmap", + "nanosleep", + "newfstatat", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "poll", + "ppoll", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "pselect6", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recvfrom", + "recvmmsg", + "recvmsg", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_getscheduler", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "sendfile", + "sendmmsg", + "sendmsg", + "sendto", + "set_robust_list", + "set_tid_address", + "setfsgid", + "setfsuid", + "setgid", + "setgroups", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setresgid", + "setresuid", + "setreuid", + "setrlimit", + "setsid", + "setsockopt", + "setuid", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "socket", + "socketpair", + "splice", + "stat", + "statfs", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_settime", + "timerfd_create", + "timerfd_gettime", + "timerfd_settime", + "times", + "tkill", + "truncate", + "umask", + "uname", + "unlink", + "unlinkat", + "utime", + "utimensat", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "write", + "writev", +]; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn sample_input() -> SandboxBundleSpec { + SandboxBundleSpec { + box_id: "box-123".to_string(), + rootfs_path: PathBuf::from("/var/lib/a3s/boxes/box-123/rootfs"), + rootfs_read_only: false, + hostname: "box-123".to_string(), + init_environment: vec![ + ("PATH".to_string(), "/bin".to_string()), + ( + "A3S_BOOTSTRAP_MODE".to_string(), + "attacker-value".to_string(), + ), + ], + mounts: vec![SandboxMount { + source: PathBuf::from("/srv/a3s/workspaces/box-123"), + destination: PathBuf::from("/workspace"), + read_only: false, + }], + tmpfs: Vec::new(), + id_mappings: SandboxIdMappingPlan { + uid_mappings: vec![IdMapping { + container_id: 0, + host_id: 100000, + size: 65536, + }], + gid_mappings: vec![IdMapping { + container_id: 0, + host_id: 200000, + size: 65536, + }], + maximum_container_uid: 65535, + maximum_container_gid: 65535, + }, + resources: SandboxResources { + memory_limit: 512 * 1024 * 1024, + memory_reservation: Some(256 * 1024 * 1024), + memory_swap: Some(1024 * 1024 * 1024), + cpu_shares: Some(1024), + cpu_quota: 200000, + cpu_period: 100000, + cpuset_cpus: Some("0-1".to_string()), + pids_limit: 512, + }, + requested_capabilities: Vec::new(), + execution_plan_digest: format!("sha256:{}", "a".repeat(64)), + runtime_digest: format!("sha256:{}", "b".repeat(64)), + } + } + + fn as_json(spec: &Spec) -> Value { + serde_json::to_value(spec).unwrap() + } + + #[test] + fn compiler_emits_every_mandatory_isolation_control() { + let value = as_json(&compile_oci_spec(&sample_input()).unwrap()); + let namespaces: HashSet<_> = value["linux"]["namespaces"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["type"].as_str().unwrap()) + .collect(); + for required in ["user", "mount", "pid", "ipc", "uts", "network", "cgroup"] { + assert!(namespaces.contains(required), "missing {required}"); + } + assert_eq!(value["process"]["args"], serde_json::json!(["/sbin/init"])); + assert_eq!(value["process"]["noNewPrivileges"], true); + assert_eq!(value["linux"]["seccomp"]["defaultAction"], "SCMP_ACT_ERRNO"); + let expected_seccomp_architecture = match std::env::consts::ARCH { + "x86_64" => "SCMP_ARCH_X86_64", + "aarch64" => "SCMP_ARCH_AARCH64", + architecture => panic!("unexpected test architecture {architecture}"), + }; + assert_eq!( + value["linux"]["seccomp"]["architectures"], + serde_json::json!([expected_seccomp_architecture]) + ); + assert_eq!( + value["linux"]["resources"]["memory"]["limit"], + 512 * 1024 * 1024i64 + ); + assert_eq!(value["linux"]["resources"]["pids"]["limit"], 512); + assert_eq!(value["linux"]["resources"]["cpu"]["cpus"], "0-1"); + } + + #[test] + fn compiler_seals_bootstrap_environment_and_capabilities() { + let value = as_json(&compile_oci_spec(&sample_input()).unwrap()); + let env = value["process"]["env"].as_array().unwrap(); + assert!(env + .iter() + .any(|value| value == "A3S_BOOTSTRAP_MODE=host-sandbox")); + assert!(env.iter().any(|value| value == "A3S_EXEC_LISTENER_FD=3")); + assert!(env.iter().any(|value| value == "A3S_PTY_LISTENER_FD=4")); + assert!(env.iter().any(|value| value == "A3S_INIT_LOG_FD=5")); + assert!(!env + .iter() + .any(|value| value == "A3S_BOOTSTRAP_MODE=attacker-value")); + + let bounding = value["process"]["capabilities"]["bounding"] + .as_array() + .unwrap(); + assert!(!bounding.iter().any(|value| value == "CAP_SYS_ADMIN")); + assert!(!bounding.iter().any(|value| value == "CAP_NET_RAW")); + } + + #[test] + fn seccomp_masks_clone_namespace_flags_and_returns_enosys_for_clone3() { + let value = as_json(&compile_oci_spec(&sample_input()).unwrap()); + let rules = value["linux"]["seccomp"]["syscalls"].as_array().unwrap(); + let clone = rules + .iter() + .find(|rule| { + rule["names"] + .as_array() + .unwrap() + .iter() + .any(|name| name == "clone") + }) + .unwrap(); + assert_eq!(clone["args"][0]["op"], "SCMP_CMP_MASKED_EQ"); + let clone3 = rules + .iter() + .find(|rule| { + rule["names"] + .as_array() + .unwrap() + .iter() + .any(|name| name == "clone3") + }) + .unwrap(); + assert_eq!(clone3["errnoRet"], LINUX_ENOSYS); + let allowed_names = rules[0]["names"].as_array().unwrap(); + for forbidden in [ + "unshare", + "setns", + "mount", + "pivot_root", + "bpf", + "keyctl", + "perf_event_open", + "io_uring_setup", + "userfaultfd", + "reboot", + ] { + assert!(!allowed_names.iter().any(|name| name == forbidden)); + } + } + + #[test] + fn compiler_rejects_protected_or_duplicate_mounts() { + let mut input = sample_input(); + input.mounts[0].source = PathBuf::from("/run/containerd/containerd.sock"); + assert!(compile_oci_spec(&input).is_err()); + + let mut input = sample_input(); + input.mounts.push(input.mounts[0].clone()); + assert!(compile_oci_spec(&input).is_err()); + } + + #[test] + fn compiler_allows_only_the_exact_shared_memory_tmpfs_override() { + let mut input = sample_input(); + input.tmpfs.push(SandboxTmpfs { + destination: PathBuf::from("/dev/shm"), + size_bytes: 128 * 1024 * 1024, + }); + + let value = as_json(&compile_oci_spec(&input).unwrap()); + let shared_memory = value["mounts"] + .as_array() + .unwrap() + .iter() + .filter(|mount| mount["destination"] == "/dev/shm") + .collect::>(); + assert_eq!(shared_memory.len(), 1); + let options = shared_memory[0]["options"].as_array().unwrap(); + assert!(options.iter().any(|option| option == "size=134217728")); + assert!(options.iter().any(|option| option == "noexec")); + + input.tmpfs[0].destination = PathBuf::from("/dev/shm/nested"); + assert!(compile_oci_spec(&input).is_err()); + } + + #[test] + fn resource_conversion_enforces_hard_limits_and_baseline_pids() { + let config = BoxConfig::default(); + let resources = SandboxResources::from_box_config(&config).unwrap(); + assert_eq!(resources.memory_limit, 1024 * 1024 * 1024); + assert_eq!(resources.cpu_quota, 200000); + assert_eq!(resources.cpu_period, 100000); + assert_eq!(resources.pids_limit, DEFAULT_SANDBOX_PIDS_LIMIT); + } +} diff --git a/src/runtime/src/sandbox/path_access.rs b/src/runtime/src/sandbox/path_access.rs new file mode 100644 index 00000000..72e3137f --- /dev/null +++ b/src/runtime/src/sandbox/path_access.rs @@ -0,0 +1,308 @@ +//! Host path access required while `crun` enters a user namespace. +//! +//! A root-run service deliberately maps container root to a subordinate host +//! identity. `crun` changes into the OCI bundle before entering that user +//! namespace, then resolves `/proc/self/cwd` while setting up the container. +//! Every parent of the bundle and rootfs must therefore be searchable by the +//! mapped identity even when the service uses a restrictive umask. + +use std::path::Path; + +use a3s_box_core::error::{BoxError, Result}; + +#[cfg(target_os = "linux")] +use super::mapped_root_ids; +use super::SandboxIdMappingPlan; + +/// Make only A3S-owned bundle/rootfs parents searchable by mapped container root. +/// +/// The bundle artifacts and credentials keep their private modes. Paths above +/// `A3S_HOME` are never modified; an inaccessible deployment parent is rejected +/// with an actionable error instead. +#[cfg(target_os = "linux")] +pub fn prepare_crun_path_access( + home_dir: &Path, + box_id: &str, + bundle_dir: &Path, + rootfs_path: &Path, + id_mappings: &SandboxIdMappingPlan, +) -> Result<()> { + let boxes_dir = home_dir.join("boxes"); + let box_dir = boxes_dir.join(box_id); + let sandbox_dir = box_dir.join("sandbox"); + let expected_bundle = sandbox_dir.join("bundle"); + + if bundle_dir != expected_bundle { + return Err(invalid_runtime_path("bundle", bundle_dir, &expected_bundle)); + } + if rootfs_path == box_dir || !rootfs_path.starts_with(&box_dir) { + return Err(BoxError::BoxBootError { + message: format!( + "Sandbox rootfs {} is outside its managed box directory {}", + rootfs_path.display(), + box_dir.display() + ), + hint: Some("Rebuild the Sandbox rootfs inside its A3S box directory".to_string()), + }); + } + + let (mapped_uid, mapped_gid) = mapped_root_ids(id_mappings)?; + // `home_dir`, `boxes`, the per-box directory, and `sandbox` are all owned + // by A3S. Add only the single search bit selected by normal DAC rules. + // Do not touch siblings such as `auth` or any bundle artifact mode. + for path in [home_dir, &boxes_dir, &box_dir, &sandbox_dir] { + make_managed_directory_searchable(path, mapped_uid, mapped_gid)?; + } + + require_directory(bundle_dir, "Sandbox bundle")?; + require_directory(rootfs_path, "Sandbox rootfs")?; + require_searchable_parents(bundle_dir, mapped_uid, mapped_gid)?; + require_searchable_path(rootfs_path, mapped_uid, mapped_gid)?; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn prepare_crun_path_access( + _home_dir: &Path, + _box_id: &str, + _bundle_dir: &Path, + _rootfs_path: &Path, + _id_mappings: &SandboxIdMappingPlan, +) -> Result<()> { + Err(BoxError::ConfigError( + "Sandbox runtime path preparation requires Linux".to_string(), + )) +} + +#[cfg(target_os = "linux")] +fn invalid_runtime_path(label: &str, actual: &Path, expected: &Path) -> BoxError { + BoxError::BoxBootError { + message: format!( + "Sandbox {label} path {} does not match the managed path {}", + actual.display(), + expected.display() + ), + hint: Some("Remove the invalid Sandbox state and retry creation".to_string()), + } +} + +#[cfg(target_os = "linux")] +fn make_managed_directory_searchable(path: &Path, uid: u32, gid: u32) -> Result<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let metadata = require_directory(path, "A3S-managed Sandbox directory")?; + let search_bit = identity_search_bit(&metadata, uid, gid); + let mode = metadata.mode() & 0o7777; + if mode & search_bit == 0 { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode | search_bit)) + .map_err(|error| BoxError::BoxBootError { + message: format!( + "Failed to make A3S-managed Sandbox directory {} searchable: {error}", + path.display() + ), + hint: None, + })?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn require_searchable_parents(path: &Path, uid: u32, gid: u32) -> Result<()> { + let parent = path.parent().ok_or_else(|| BoxError::BoxBootError { + message: format!("Sandbox runtime path has no parent: {}", path.display()), + hint: None, + })?; + require_searchable_chain(parent, uid, gid) +} + +#[cfg(target_os = "linux")] +fn require_searchable_path(path: &Path, uid: u32, gid: u32) -> Result<()> { + require_searchable_chain(path, uid, gid) +} + +#[cfg(target_os = "linux")] +fn require_searchable_chain(path: &Path, uid: u32, gid: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + for ancestor in path.ancestors() { + let metadata = std::fs::metadata(ancestor).map_err(|error| BoxError::BoxBootError { + message: format!( + "Failed to inspect Sandbox runtime path ancestor {}: {error}", + ancestor.display() + ), + hint: None, + })?; + if !metadata.is_dir() { + return Err(BoxError::BoxBootError { + message: format!( + "Sandbox runtime path ancestor is not a directory: {}", + ancestor.display() + ), + hint: None, + }); + } + let search_bit = identity_search_bit(&metadata, uid, gid); + if metadata.permissions().mode() & search_bit == 0 { + return Err(BoxError::BoxBootError { + message: format!( + "Sandbox runtime path ancestor {} is not searchable by mapped container root {uid}:{gid}", + ancestor.display() + ), + hint: Some( + "Grant execute-only traversal on the deployment parent or move A3S_HOME under a searchable service directory" + .to_string(), + ), + }); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn identity_search_bit(metadata: &std::fs::Metadata, uid: u32, gid: u32) -> u32 { + use std::os::unix::fs::MetadataExt; + + if metadata.uid() == uid { + 0o100 + } else if metadata.gid() == gid { + 0o010 + } else { + 0o001 + } +} + +#[cfg(target_os = "linux")] +fn require_directory(path: &Path, label: &str) -> Result { + let metadata = std::fs::symlink_metadata(path).map_err(|error| BoxError::BoxBootError { + message: format!("Failed to inspect {label} {}: {error}", path.display()), + hint: None, + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(BoxError::BoxBootError { + message: format!("{label} is not a real directory: {}", path.display()), + hint: Some("Remove the invalid Sandbox state and retry creation".to_string()), + }); + } + Ok(metadata) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + + use super::*; + use crate::sandbox::IdMapping; + + fn mappings(uid: u32, gid: u32) -> SandboxIdMappingPlan { + SandboxIdMappingPlan { + uid_mappings: vec![IdMapping { + container_id: 0, + host_id: uid, + size: 1, + }], + gid_mappings: vec![IdMapping { + container_id: 0, + host_id: gid, + size: 1, + }], + maximum_container_uid: 0, + maximum_container_gid: 0, + } + } + + fn set_mode(path: &Path, mode: u32) { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + + fn mode(path: &Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o7777 + } + + fn fixture(outer_mode: u32) -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { + let outer = tempfile::tempdir().unwrap(); + set_mode(outer.path(), outer_mode); + let home = outer.path().join("home"); + let box_dir = home.join("boxes/execution-1"); + let bundle = box_dir.join("sandbox/bundle"); + let rootfs = box_dir.join("merged"); + std::fs::create_dir_all(&bundle).unwrap(); + std::fs::create_dir_all(&rootfs).unwrap(); + std::fs::create_dir_all(home.join("auth")).unwrap(); + std::fs::write(home.join("auth/credentials.json"), b"private").unwrap(); + for path in [ + &home, + &home.join("boxes"), + &box_dir, + &box_dir.join("sandbox"), + &bundle, + &rootfs, + &home.join("auth"), + ] { + set_mode(path, 0o700); + } + set_mode(&home.join("auth/credentials.json"), 0o600); + (outer, home, bundle, rootfs) + } + + #[test] + fn restrictive_umask_paths_become_searchable_without_exposing_credentials() { + let (outer, home, bundle, rootfs) = fixture(0o701); + let uid = unsafe { libc::geteuid() }.saturating_add(100_000); + let gid = unsafe { libc::getegid() }.saturating_add(200_000); + // The real rootfs is owned by mapped root. An execute-only bit models + // that access without requiring this unit test to run as host root. + set_mode(&rootfs, 0o701); + + prepare_crun_path_access(&home, "execution-1", &bundle, &rootfs, &mappings(uid, gid)) + .unwrap(); + + for path in [ + &home, + &home.join("boxes"), + &home.join("boxes/execution-1"), + &home.join("boxes/execution-1/sandbox"), + ] { + assert_eq!(mode(path), 0o701); + } + assert_eq!(mode(&bundle), 0o700); + assert_eq!(mode(&home.join("auth")), 0o700); + assert_eq!(mode(&home.join("auth/credentials.json")), 0o600); + assert_eq!(mode(outer.path()), 0o701); + } + + #[test] + fn inaccessible_deployment_parent_is_rejected_without_chmod() { + let (outer, home, bundle, rootfs) = fixture(0o700); + let uid = unsafe { libc::geteuid() }.saturating_add(100_000); + let gid = unsafe { libc::getegid() }.saturating_add(200_000); + set_mode(&rootfs, 0o701); + + let error = + prepare_crun_path_access(&home, "execution-1", &bundle, &rootfs, &mappings(uid, gid)) + .unwrap_err(); + + assert!(error.to_string().contains("not searchable")); + assert_eq!(mode(outer.path()), 0o700); + } + + #[test] + fn mismatched_bundle_path_is_rejected_before_permissions_change() { + let (outer, home, _bundle, rootfs) = fixture(0o701); + let wrong = home.join("other/bundle"); + std::fs::create_dir_all(&wrong).unwrap(); + + let error = prepare_crun_path_access( + &home, + "execution-1", + &wrong, + &rootfs, + &mappings(100_000, 200_000), + ) + .unwrap_err(); + + assert!(error.to_string().contains("does not match")); + assert_eq!(mode(&home), 0o700); + assert_eq!(mode(outer.path()), 0o701); + } +} diff --git a/src/runtime/src/sandbox/rootfs.rs b/src/runtime/src/sandbox/rootfs.rs new file mode 100644 index 00000000..638b72bc --- /dev/null +++ b/src/runtime/src/sandbox/rootfs.rs @@ -0,0 +1,814 @@ +//! Host-side rootfs ownership preparation for user-namespace execution. + +#[cfg(target_os = "linux")] +use std::collections::HashSet; +#[cfg(any(target_os = "linux", test))] +use std::path::Component; +use std::path::{Path, PathBuf}; + +use a3s_box_core::error::{BoxError, Result}; +#[cfg(target_os = "linux")] +use a3s_box_core::rootfs_metadata::runtime_managed_rootfs_mode; +#[cfg(any(target_os = "linux", test))] +use a3s_box_core::rootfs_metadata::{RootfsEntryKind, RootfsMetadataEntry}; +use a3s_box_core::rootfs_metadata::{ + RootfsMetadataManifest, IMAGE_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH, +}; +#[cfg(any(target_os = "linux", test))] +use base64::Engine; + +use super::capability::{IdMapping, SandboxIdMappingPlan}; + +/// Container IDs discovered in the authoritative rootfs metadata manifest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RootfsIdentityRequirements { + pub maximum_uid: u32, + pub maximum_gid: u32, + pub manifest_path: PathBuf, +} + +/// Host IDs representing container root for one mapping plan. +pub fn mapped_root_ids(plan: &SandboxIdMappingPlan) -> Result<(u32, u32)> { + Ok(( + map_container_id(&plan.uid_mappings, 0, "UID")?, + map_container_id(&plan.gid_mappings, 0, "GID")?, + )) +} + +/// Make an A3S-owned workspace or anonymous volume accessible as container +/// root without ever changing an arbitrary caller-provided host tree. +#[cfg(target_os = "linux")] +pub fn prepare_managed_mount_source(path: &Path, plan: &SandboxIdMappingPlan) -> Result<()> { + ensure_no_nested_mounts(path)?; + let (root_uid, root_gid) = mapped_root_ids(plan)?; + prepare_managed_tree(path, plan, root_uid, root_gid) +} + +#[cfg(not(target_os = "linux"))] +pub fn prepare_managed_mount_source(_path: &Path, _plan: &SandboxIdMappingPlan) -> Result<()> { + Err(BoxError::ConfigError( + "Sandbox mount ownership preparation requires Linux".to_string(), + )) +} + +/// Verify that an external bind source's root is usable by the mapped root +/// identity. The runtime refuses to chown external host data implicitly. +#[cfg(unix)] +pub fn validate_external_mount_access( + path: &Path, + plan: &SandboxIdMappingPlan, + read_only: bool, +) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + let (uid, gid) = mapped_root_ids(plan)?; + let metadata = std::fs::metadata(path).map_err(BoxError::IoError)?; + let mode = metadata.mode(); + let permission_bits = if metadata.uid() == uid { + (mode >> 6) & 0o7 + } else if metadata.gid() == gid { + (mode >> 3) & 0o7 + } else { + mode & 0o7 + }; + let required = if metadata.is_dir() { + if read_only { + 0o5 + } else { + 0o7 + } + } else if read_only { + 0o4 + } else { + 0o6 + }; + if permission_bits & required != required { + return Err(BoxError::ConfigError(format!( + "External Sandbox mount {} is not {} by mapped container root {uid}:{gid}; adjust host ownership/permissions or use an A3S-managed volume", + path.display(), + if read_only { "readable" } else { "writable" } + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +pub fn validate_external_mount_access( + _path: &Path, + _plan: &SandboxIdMappingPlan, + _read_only: bool, +) -> Result<()> { + Err(BoxError::ConfigError( + "Sandbox bind mount validation requires Linux".to_string(), + )) +} + +#[cfg(target_os = "linux")] +struct DecodedEntry { + metadata: RootfsMetadataEntry, + relative: PathBuf, + target: PathBuf, +} + +/// Read the terminal persistent manifest when present, otherwise the immutable +/// image manifest. The mapping plan must cover every ID before `crun` starts. +pub fn inspect_rootfs_identity_requirements(root: &Path) -> Result { + let (manifest_path, manifest) = load_authoritative_manifest(root)?; + let mut maximum_uid = 0u32; + let mut maximum_gid = 0u32; + for entry in manifest.entries { + let uid = u32::try_from(entry.uid).map_err(|_| { + BoxError::OciImageError("rootfs metadata UID exceeds the Linux range".to_string()) + })?; + let gid = u32::try_from(entry.gid).map_err(|_| { + BoxError::OciImageError("rootfs metadata GID exceeds the Linux range".to_string()) + })?; + maximum_uid = maximum_uid.max(uid); + maximum_gid = maximum_gid.max(gid); + } + Ok(RootfsIdentityRequirements { + maximum_uid, + maximum_gid, + manifest_path, + }) +} + +/// Prepare one per-box rootfs for the exact user-namespace mapping. +/// +/// A root-run service can translate OCI container ownership to subordinate +/// host IDs directly. A non-root service leaves ownership replay to PID 1 from +/// inside the user namespace. Read-only rootfs is rejected for the latter until +/// an idmapped-mount path can guarantee replay before the read-only transition. +#[cfg(target_os = "linux")] +pub fn prepare_rootfs_ownership( + root: &Path, + plan: &SandboxIdMappingPlan, + effective_uid: u32, + read_only: bool, +) -> Result<()> { + if effective_uid != 0 { + if read_only { + return Err(BoxError::ConfigError( + "Sandbox read-only rootfs requires a root-run service until idmapped rootfs preparation is available" + .to_string(), + )); + } + return Ok(()); + } + + ensure_no_nested_mounts(root)?; + let (_, manifest) = load_authoritative_manifest(root)?; + let entries = decode_and_validate_entries(root, manifest)?; + let authoritative_paths: HashSet = + entries.iter().map(|entry| entry.relative.clone()).collect(); + + for entry in &entries { + let uid = map_container_id( + &plan.uid_mappings, + u32::try_from(entry.metadata.uid).map_err(|_| { + BoxError::OciImageError("rootfs metadata UID exceeds the Linux range".to_string()) + })?, + "UID", + )?; + let gid = map_container_id( + &plan.gid_mappings, + u32::try_from(entry.metadata.gid).map_err(|_| { + BoxError::OciImageError("rootfs metadata GID exceeds the Linux range".to_string()) + })?, + "GID", + )?; + lchown_if_needed(&entry.target, uid, gid)?; + } + + // Files written by the runtime after manifest generation (DNS, hostname, + // env staging, refreshed init, and the manifests themselves) are not all + // represented in the selected generation. Walk without following symlinks: + // already-mapped IDs are left untouched, while raw OCI IDs are translated. + shift_unlisted_entries(root, root, &authoritative_paths, plan)?; + + // chown clears setuid/setgid bits on regular files. Restore exact manifest + // modes deepest-first after every ownership change. + let mut modes: Vec<_> = entries + .iter() + .filter(|entry| entry.metadata.kind != RootfsEntryKind::Symlink) + .collect(); + modes.sort_by_key(|entry| std::cmp::Reverse(entry.relative.components().count())); + for entry in modes { + use std::os::unix::fs::PermissionsExt; + let mode = + runtime_managed_rootfs_mode(&entry.relative).unwrap_or(entry.metadata.mode & 0o7777); + std::fs::set_permissions(&entry.target, std::fs::Permissions::from_mode(mode)).map_err( + |error| BoxError::BoxBootError { + message: format!( + "Failed to restore Sandbox rootfs mode at {}: {error}", + entry.target.display() + ), + hint: None, + }, + )?; + } + + Ok(()) +} + +/// Capture authoritative guest-visible metadata for a quiesced Sandbox rootfs. +/// +/// The host sees user-namespace IDs, so every UID/GID is translated back +/// through the exact OCI mappings before the manifest is stored in a +/// filesystem Snapshot. The walk never follows symlinks and rejects special +/// files, preventing a FIFO or device node from entering the copy path. +#[cfg(target_os = "linux")] +pub(crate) fn capture_snapshot_rootfs_metadata( + root: &Path, + plan: &SandboxIdMappingPlan, +) -> Result { + ensure_no_nested_mounts(root)?; + let mut entries = Vec::new(); + collect_snapshot_rootfs_metadata(root, root, Path::new("."), plan, &mut entries)?; + entries.sort_by(|left, right| left.path_base64.cmp(&right.path_base64)); + Ok(RootfsMetadataManifest::new(entries)) +} + +#[cfg(target_os = "linux")] +fn collect_snapshot_rootfs_metadata( + root: &Path, + source: &Path, + manifest_path: &Path, + plan: &SandboxIdMappingPlan, + entries: &mut Vec, +) -> Result<()> { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + + let relative = source + .strip_prefix(root) + .map_err(|_| BoxError::OciImageError("Sandbox Snapshot walk escaped its root".into()))?; + if matches!( + relative.to_str(), + Some(".a3s_rootfs_metadata_v1.json") + | Some(".a3s_rootfs_metadata_v1.json.tmp") + | Some(".a3s_image_metadata_v1.json") + | Some(".a3s_image_metadata_v1.json.tmp") + | Some(".a3s_exit_code") + | Some("init.trace.log") + ) { + return Ok(()); + } + + let metadata = std::fs::symlink_metadata(source).map_err(BoxError::IoError)?; + let file_type = metadata.file_type(); + let (kind, link_target_base64) = if file_type.is_dir() { + (RootfsEntryKind::Directory, None) + } else if file_type.is_file() { + (RootfsEntryKind::Regular, None) + } else if file_type.is_symlink() { + let target = std::fs::read_link(source).map_err(BoxError::IoError)?; + ( + RootfsEntryKind::Symlink, + Some(base64::engine::general_purpose::STANDARD.encode(target.as_os_str().as_bytes())), + ) + } else { + let kind = if file_type.is_fifo() { + "fifo" + } else if file_type.is_socket() { + "socket" + } else if file_type.is_char_device() { + "character device" + } else if file_type.is_block_device() { + "block device" + } else { + "unknown" + }; + return Err(BoxError::OciImageError(format!( + "Sandbox Snapshot rootfs contains unsupported special file {} ({kind})", + source.display() + ))); + }; + entries.push(RootfsMetadataEntry { + path_base64: base64::engine::general_purpose::STANDARD + .encode(manifest_path.as_os_str().as_bytes()), + kind, + mode: metadata.mode(), + uid: unmap_host_id(&plan.uid_mappings, metadata.uid(), "UID")? as u64, + gid: unmap_host_id(&plan.gid_mappings, metadata.gid(), "GID")? as u64, + mtime: metadata.mtime().max(0) as u64, + size: metadata.size(), + link_target_base64, + }); + + if file_type.is_dir() { + let mut children: Vec<_> = std::fs::read_dir(source) + .map_err(BoxError::IoError)? + .collect::>() + .map_err(BoxError::IoError)?; + children.sort_by_key(std::fs::DirEntry::file_name); + for child in children { + collect_snapshot_rootfs_metadata( + root, + &child.path(), + &manifest_path.join(child.file_name()), + plan, + entries, + )?; + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn unmap_host_id(mappings: &[IdMapping], id: u32, kind: &str) -> Result { + for mapping in mappings { + let Some(end) = mapping.host_id.checked_add(mapping.size) else { + continue; + }; + if mapping.host_id <= id && id < end { + return mapping + .container_id + .checked_add(id - mapping.host_id) + .ok_or_else(|| { + BoxError::ConfigError(format!( + "Sandbox Snapshot {kind} reverse mapping overflows u32" + )) + }); + } + } + Err(BoxError::ConfigError(format!( + "Sandbox Snapshot host {kind} {id} is outside the OCI mappings" + ))) +} + +#[cfg(not(target_os = "linux"))] +pub fn prepare_rootfs_ownership( + _root: &Path, + _plan: &SandboxIdMappingPlan, + _effective_uid: u32, + _read_only: bool, +) -> Result<()> { + Err(BoxError::ConfigError( + "Sandbox rootfs ownership preparation requires Linux".to_string(), + )) +} + +fn load_authoritative_manifest(root: &Path) -> Result<(PathBuf, RootfsMetadataManifest)> { + let terminal = root.join(ROOTFS_METADATA_PATH.trim_start_matches('/')); + let image = root.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')); + let path = if terminal.exists() { terminal } else { image }; + let bytes = std::fs::read(&path).map_err(|error| BoxError::BoxBootError { + message: format!( + "Sandbox rootfs metadata is unavailable at {}: {error}", + path.display() + ), + hint: Some("Rebuild the per-box rootfs from its OCI image".to_string()), + })?; + let manifest: RootfsMetadataManifest = serde_json::from_slice(&bytes).map_err(|error| { + BoxError::OciImageError(format!( + "Invalid Sandbox rootfs metadata {}: {error}", + path.display() + )) + })?; + manifest.validate().map_err(BoxError::OciImageError)?; + Ok((path, manifest)) +} + +#[cfg(target_os = "linux")] +fn decode_and_validate_entries( + root: &Path, + manifest: RootfsMetadataManifest, +) -> Result> { + let mut decoded = Vec::with_capacity(manifest.entries.len()); + let mut unique = HashSet::with_capacity(manifest.entries.len()); + for metadata in manifest.entries { + let raw = base64::engine::general_purpose::STANDARD + .decode(&metadata.path_base64) + .map_err(|error| { + BoxError::OciImageError(format!("Invalid rootfs metadata path: {error}")) + })?; + // Manifests are produced and consumed on the same host, so the encoded + // platform path bytes can be reconstructed losslessly. + let encoded = unsafe { std::ffi::OsString::from_encoded_bytes_unchecked(raw) }; + let relative = safe_relative_path(Path::new(&encoded))?; + if relative == Path::new(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')) + || relative == Path::new(ROOTFS_METADATA_PATH.trim_start_matches('/')) + || !unique.insert(relative.clone()) + { + return Err(BoxError::OciImageError( + "Duplicate or reserved Sandbox rootfs metadata path".to_string(), + )); + } + let target = resolve_without_symlink_parent(root, &relative)?; + let filesystem = + std::fs::symlink_metadata(&target).map_err(|error| BoxError::BoxBootError { + message: format!( + "Sandbox rootfs metadata target {} is unavailable: {error}", + target.display() + ), + hint: None, + })?; + let actual_kind = if filesystem.file_type().is_dir() { + RootfsEntryKind::Directory + } else if filesystem.file_type().is_file() { + RootfsEntryKind::Regular + } else if filesystem.file_type().is_symlink() { + RootfsEntryKind::Symlink + } else { + return Err(BoxError::OciImageError(format!( + "Unsupported rootfs entry at {}", + target.display() + ))); + }; + if actual_kind != metadata.kind { + return Err(BoxError::OciImageError(format!( + "Sandbox rootfs metadata type mismatch at {}", + target.display() + ))); + } + if actual_kind == RootfsEntryKind::Symlink { + let expected = metadata.link_target_base64.as_ref().ok_or_else(|| { + BoxError::OciImageError("Symlink metadata is missing its target".to_string()) + })?; + let expected = base64::engine::general_purpose::STANDARD + .decode(expected) + .map_err(|error| { + BoxError::OciImageError(format!("Invalid symlink target metadata: {error}")) + })?; + if std::fs::read_link(&target) + .map_err(BoxError::IoError)? + .as_os_str() + .as_encoded_bytes() + != expected + { + return Err(BoxError::OciImageError(format!( + "Sandbox rootfs symlink mismatch at {}", + target.display() + ))); + } + } + decoded.push(DecodedEntry { + metadata, + relative, + target, + }); + } + Ok(decoded) +} + +#[cfg(any(target_os = "linux", test))] +fn safe_relative_path(path: &Path) -> Result { + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(name) => result.push(name), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(BoxError::OciImageError( + "Unsafe Sandbox rootfs metadata path".to_string(), + )) + } + } + } + Ok(result) +} + +#[cfg(target_os = "linux")] +fn resolve_without_symlink_parent(root: &Path, relative: &Path) -> Result { + let mut current = root.to_path_buf(); + let components: Vec<_> = relative.components().collect(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(name) = component else { + continue; + }; + current.push(name); + if index + 1 < components.len() + && std::fs::symlink_metadata(¤t) + .map_err(BoxError::IoError)? + .file_type() + .is_symlink() + { + return Err(BoxError::OciImageError(format!( + "Symlink parent in Sandbox rootfs metadata path: {}", + current.display() + ))); + } + } + Ok(current) +} + +#[cfg(target_os = "linux")] +fn shift_unlisted_entries( + root: &Path, + source: &Path, + authoritative: &HashSet, + plan: &SandboxIdMappingPlan, +) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + let relative = source + .strip_prefix(root) + .map_err(|_| BoxError::OciImageError("Sandbox rootfs walk escaped its root".to_string()))?; + let metadata = std::fs::symlink_metadata(source).map_err(BoxError::IoError)?; + if !authoritative.contains(relative) { + let uid = map_current_or_container_id(&plan.uid_mappings, metadata.uid(), "UID")?; + let gid = map_current_or_container_id(&plan.gid_mappings, metadata.gid(), "GID")?; + lchown_if_needed(source, uid, gid)?; + } + if metadata.file_type().is_dir() { + for child in std::fs::read_dir(source).map_err(BoxError::IoError)? { + shift_unlisted_entries( + root, + &child.map_err(BoxError::IoError)?.path(), + authoritative, + plan, + )?; + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn prepare_managed_tree( + path: &Path, + plan: &SandboxIdMappingPlan, + root_uid: u32, + root_gid: u32, +) -> Result<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let metadata = std::fs::symlink_metadata(path).map_err(BoxError::IoError)?; + let uid = if id_is_mapped(&plan.uid_mappings, metadata.uid()) { + metadata.uid() + } else { + root_uid + }; + let gid = if id_is_mapped(&plan.gid_mappings, metadata.gid()) { + metadata.gid() + } else { + root_gid + }; + let mode = metadata.mode() & 0o7777; + lchown_if_needed(path, uid, gid)?; + if !metadata.file_type().is_symlink() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .map_err(BoxError::IoError)?; + } + if metadata.file_type().is_dir() { + for child in std::fs::read_dir(path).map_err(BoxError::IoError)? { + prepare_managed_tree( + &child.map_err(BoxError::IoError)?.path(), + plan, + root_uid, + root_gid, + )?; + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn id_is_mapped(mappings: &[IdMapping], id: u32) -> bool { + mappings.iter().any(|mapping| { + mapping + .host_id + .checked_add(mapping.size) + .is_some_and(|end| mapping.host_id <= id && id < end) + }) +} + +#[cfg(target_os = "linux")] +fn ensure_no_nested_mounts(root: &Path) -> Result<()> { + let root = root.canonicalize().map_err(BoxError::IoError)?; + let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(BoxError::IoError)?; + for mount in mountinfo + .lines() + .filter_map(|line| line.split_whitespace().nth(4)) + .map(decode_mountinfo_path) + .map(PathBuf::from) + { + if mount != root && mount.starts_with(&root) { + return Err(BoxError::BoxBootError { + message: format!( + "Refusing Sandbox ownership preparation across nested mount {} under {}", + mount.display(), + root.display() + ), + hint: Some("Reconcile the stale mount before restarting the Sandbox".to_string()), + }); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn decode_mountinfo_path(value: &str) -> String { + value + .replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\") +} + +#[cfg(target_os = "linux")] +fn map_current_or_container_id(mappings: &[IdMapping], id: u32, kind: &str) -> Result { + if mappings.iter().any(|mapping| { + mapping + .host_id + .checked_add(mapping.size) + .is_some_and(|end| mapping.host_id <= id && id < end) + }) { + return Ok(id); + } + map_container_id(mappings, id, kind) +} + +fn map_container_id(mappings: &[IdMapping], id: u32, kind: &str) -> Result { + for mapping in mappings { + let Some(end) = mapping.container_id.checked_add(mapping.size) else { + continue; + }; + if mapping.container_id <= id && id < end { + return mapping + .host_id + .checked_add(id - mapping.container_id) + .ok_or_else(|| { + BoxError::ConfigError(format!("Sandbox {kind} mapping overflows u32")) + }); + } + } + Err(BoxError::ConfigError(format!( + "Sandbox {kind} mappings do not cover container ID {id}" + ))) +} + +#[cfg(target_os = "linux")] +fn lchown_if_needed(path: &Path, uid: u32, gid: u32) -> Result<()> { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + + let current = std::fs::symlink_metadata(path).map_err(BoxError::IoError)?; + if current.uid() == uid && current.gid() == gid { + return Ok(()); + } + let path_bytes = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| { + BoxError::OciImageError(format!( + "NUL byte in Sandbox rootfs path {}", + path.display() + )) + })?; + if unsafe { libc::lchown(path_bytes.as_ptr(), uid, gid) } != 0 { + return Err(BoxError::BoxBootError { + message: format!( + "Failed to map Sandbox rootfs ownership at {} to {uid}:{gid}: {}", + path.display(), + std::io::Error::last_os_error() + ), + hint: None, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use a3s_box_core::rootfs_metadata::ROOTFS_METADATA_SCHEMA; + + #[test] + fn mapping_translation_is_complete_and_exact() { + let mappings = vec![ + IdMapping { + container_id: 0, + host_id: 100_000, + size: 10, + }, + IdMapping { + container_id: 10, + host_id: 200_000, + size: 6, + }, + ]; + assert_eq!(map_container_id(&mappings, 0, "UID").unwrap(), 100_000); + assert_eq!(map_container_id(&mappings, 12, "UID").unwrap(), 200_002); + assert!(map_container_id(&mappings, 16, "UID").is_err()); + } + + #[test] + fn terminal_manifest_takes_precedence_for_identity_planning() { + let directory = tempfile::tempdir().unwrap(); + let manifest = |uid, gid| RootfsMetadataManifest { + schema: ROOTFS_METADATA_SCHEMA.to_string(), + entries: vec![RootfsMetadataEntry { + path_base64: base64::engine::general_purpose::STANDARD.encode("."), + kind: RootfsEntryKind::Directory, + mode: 0o755, + uid, + gid, + mtime: 0, + size: 0, + link_target_base64: None, + }], + }; + std::fs::write( + directory + .path() + .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')), + serde_json::to_vec(&manifest(1, 2)).unwrap(), + ) + .unwrap(); + std::fs::write( + directory + .path() + .join(ROOTFS_METADATA_PATH.trim_start_matches('/')), + serde_json::to_vec(&manifest(42, 43)).unwrap(), + ) + .unwrap(); + + let requirements = inspect_rootfs_identity_requirements(directory.path()).unwrap(); + assert_eq!(requirements.maximum_uid, 42); + assert_eq!(requirements.maximum_gid, 43); + assert!(requirements + .manifest_path + .ends_with(".a3s_rootfs_metadata_v1.json")); + } + + #[test] + fn unsafe_manifest_path_is_rejected() { + assert!(safe_relative_path(Path::new("../escape")).is_err()); + assert!(safe_relative_path(Path::new("/host")).is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn ownership_preparation_keeps_runtime_managed_files_readable() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = tempfile::tempdir().unwrap(); + let etc = directory.path().join("etc"); + std::fs::create_dir(&etc).unwrap(); + let hosts = etc.join("hosts"); + let probe = etc.join("probe"); + let init = directory.path().join("usr/sbin/init"); + std::fs::create_dir_all(init.parent().unwrap()).unwrap(); + std::fs::write(&hosts, "127.0.0.1 localhost\n").unwrap(); + std::fs::write(&probe, "probe\n").unwrap(); + for path in [&hosts, &probe] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap(); + } + std::fs::write(&init, "guest init\n").unwrap(); + std::fs::set_permissions(&init, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let owner = std::fs::metadata(directory.path()).unwrap(); + let entry = |path: &str, size: u64| RootfsMetadataEntry { + path_base64: base64::engine::general_purpose::STANDARD.encode(path), + kind: RootfsEntryKind::Regular, + mode: 0o100600, + uid: 0, + gid: 0, + mtime: 0, + size, + link_target_base64: None, + }; + let manifest = RootfsMetadataManifest { + schema: ROOTFS_METADATA_SCHEMA.to_string(), + entries: vec![ + entry("./etc/hosts", 20), + entry("./etc/probe", 6), + entry("./usr/sbin/init", 1), + ], + }; + std::fs::write( + directory + .path() + .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + let plan = SandboxIdMappingPlan { + uid_mappings: vec![IdMapping { + container_id: 0, + host_id: owner.uid(), + size: 1, + }], + gid_mappings: vec![IdMapping { + container_id: 0, + host_id: owner.gid(), + size: 1, + }], + maximum_container_uid: 0, + maximum_container_gid: 0, + }; + + prepare_rootfs_ownership(directory.path(), &plan, 0, false).unwrap(); + + assert_eq!( + std::fs::metadata(hosts).unwrap().permissions().mode() & 0o7777, + 0o644 + ); + assert_eq!( + std::fs::metadata(probe).unwrap().permissions().mode() & 0o7777, + 0o600 + ); + assert_eq!( + std::fs::metadata(init).unwrap().permissions().mode() & 0o7777, + 0o755 + ); + } +} diff --git a/src/runtime/src/snapshot.rs b/src/runtime/src/snapshot.rs index e141c2a5..6310176a 100644 --- a/src/runtime/src/snapshot.rs +++ b/src/runtime/src/snapshot.rs @@ -11,9 +11,18 @@ use std::cmp::Reverse; use std::path::{Path, PathBuf}; use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::rootfs_metadata::RootfsMetadataManifest; +#[cfg(test)] +use a3s_box_core::rootfs_metadata::{IMAGE_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH}; use a3s_box_core::snapshot::SnapshotMetadata; use a3s_box_core::SnapshotStoreBackend; +use crate::file_lock::FileLock; + +mod copy; + +use copy::{copy_dir_recursive, dir_size, install_rootfs_metadata}; + /// Persistent store for VM snapshots. pub struct SnapshotStore { /// Root directory for all snapshots @@ -21,6 +30,15 @@ pub struct SnapshotStore { } impl SnapshotStore { + pub(crate) fn acquire_exclusive_lock(&self) -> Result { + FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|error| { + BoxError::CacheError(format!( + "Failed to lock snapshot directory {}: {error}", + self.base_dir.display() + )) + }) + } + /// Create a new snapshot store at the given directory. pub fn new(base_dir: &Path) -> Result { std::fs::create_dir_all(base_dir).map_err(|e| { @@ -30,6 +48,13 @@ impl SnapshotStore { e )) })?; + let _lock = FileLock::acquire(&base_dir.join(".snapshot-store")).map_err(|e| { + BoxError::CacheError(format!( + "Failed to lock snapshot directory {}: {}", + base_dir.display(), + e + )) + })?; // Sweep leftover `.staging-*` dirs from a prior crashed/aborted save // (mirrors ImageStore::new). `save` builds the whole snapshot in // `.staging---` and only renames it into `/` after @@ -60,10 +85,38 @@ impl SnapshotStore { /// Copies the rootfs directory into the snapshot bundle. /// Returns the updated metadata with `size_bytes` populated. pub fn save( + &self, + metadata: SnapshotMetadata, + rootfs_source: &Path, + ) -> Result { + self.save_inner(metadata, rootfs_source, None) + } + + /// Save a managed Sandbox snapshot with an authoritative terminal rootfs + /// metadata manifest captured while the source execution is quiesced. + #[cfg(any(target_os = "linux", test))] + pub(crate) fn save_managed( + &self, + metadata: SnapshotMetadata, + rootfs_source: &Path, + rootfs_metadata: &RootfsMetadataManifest, + ) -> Result { + self.save_inner(metadata, rootfs_source, Some(rootfs_metadata)) + } + + fn save_inner( &self, mut metadata: SnapshotMetadata, rootfs_source: &Path, + rootfs_metadata: Option<&RootfsMetadataManifest>, ) -> Result { + let _lock = FileLock::acquire(&self.base_dir.join(".snapshot-store")).map_err(|e| { + BoxError::CacheError(format!( + "Failed to lock snapshot directory {}: {}", + self.base_dir.display(), + e + )) + })?; let snap_dir = self.base_dir.join(&metadata.id); if snap_dir.exists() { return Err(BoxError::CacheError(format!( @@ -77,25 +130,20 @@ impl SnapshotStore { // leaves at most a `.staging-*` dir (GC-able), never a partial // `/` that get/list ignore (they key on metadata.json) yet that // blocks re-create and never prunes. - use std::sync::atomic::{AtomicU64, Ordering}; - static STAGE_SEQ: AtomicU64 = AtomicU64::new(0); - let staging = self.base_dir.join(format!( - ".staging-{}-{}-{}", - metadata.id, - std::process::id(), - STAGE_SEQ.fetch_add(1, Ordering::Relaxed) - )); - let _ = std::fs::remove_dir_all(&staging); - std::fs::create_dir_all(&staging).map_err(|e| { - BoxError::CacheError(format!( - "Failed to create snapshot staging directory {}: {}", - staging.display(), - e - )) - })?; + let staging_prefix = format!(".staging-{}-{}-", metadata.id, std::process::id()); + let staging = tempfile::Builder::new() + .prefix(&staging_prefix) + .tempdir_in(&self.base_dir) + .map_err(|e| { + BoxError::CacheError(format!( + "Failed to create snapshot staging directory in {}: {}", + self.base_dir.display(), + e + )) + })?; // Copy rootfs if source exists - let rootfs_dest = staging.join("rootfs"); + let rootfs_dest = staging.path().join("rootfs"); if rootfs_source.exists() { copy_dir_recursive(rootfs_source, &rootfs_dest)?; } else { @@ -103,12 +151,15 @@ impl SnapshotStore { BoxError::CacheError(format!("Failed to create snapshot rootfs directory: {}", e)) })?; } + if let Some(rootfs_metadata) = rootfs_metadata { + install_rootfs_metadata(&rootfs_dest, rootfs_metadata)?; + } // Calculate size - metadata.size_bytes = dir_size(&staging); + metadata.size_bytes = dir_size(&rootfs_dest)?; // Write metadata into the staging dir. - let meta_path = staging.join("metadata.json"); + let meta_path = staging.path().join("metadata.json"); let json = serde_json::to_string_pretty(&metadata).map_err(|e| { BoxError::SerializationError(format!("Failed to serialize snapshot metadata: {}", e)) })?; @@ -122,8 +173,7 @@ impl SnapshotStore { // Atomic publish: the snapshot becomes visible (with its metadata) in one // step, or not at all. - std::fs::rename(&staging, &snap_dir).map_err(|e| { - let _ = std::fs::remove_dir_all(&staging); + std::fs::rename(staging.path(), &snap_dir).map_err(|e| { BoxError::CacheError(format!( "Failed to publish snapshot {}: {}", snap_dir.display(), @@ -209,6 +259,15 @@ impl SnapshotStore { /// Delete a snapshot by ID. pub fn delete(&self, id: &str) -> Result { + let _lock = self.acquire_exclusive_lock()?; + self.delete_locked(id) + } + + /// Delete while the caller holds [`Self::acquire_exclusive_lock`]. + /// + /// This is crate-visible so managed execution reservation can validate and + /// persist a Snapshot reference under the same lock used by deletion. + pub(crate) fn delete_locked(&self, id: &str) -> Result { let snap_dir = self.base_dir.join(id); if !snap_dir.exists() { return Ok(false); @@ -299,113 +358,6 @@ impl SnapshotStore { } } -/// Recursively copy a directory. -fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { - std::fs::create_dir_all(dst).map_err(|e| { - BoxError::CacheError(format!( - "Failed to create directory {}: {}", - dst.display(), - e - )) - })?; - - for entry in std::fs::read_dir(src).map_err(|e| { - BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e)) - })? { - let entry = entry - .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - let file_type = entry.file_type().map_err(|e| { - BoxError::CacheError(format!( - "Failed to read file type for {}: {}", - src_path.display(), - e - )) - })?; - - if file_type.is_symlink() { - copy_symlink(&src_path, &dst_path)?; - } else if file_type.is_dir() { - copy_dir_recursive(&src_path, &dst_path)?; - } else { - std::fs::copy(&src_path, &dst_path).map_err(|e| { - BoxError::CacheError(format!( - "Failed to copy {} → {}: {}", - src_path.display(), - dst_path.display(), - e - )) - })?; - } - } - - Ok(()) -} - -fn copy_symlink(src: &Path, dst: &Path) -> Result<()> { - let target = std::fs::read_link(src).map_err(|e| { - BoxError::CacheError(format!("Failed to read symlink {}: {}", src.display(), e)) - })?; - - #[cfg(unix)] - { - std::os::unix::fs::symlink(&target, dst).map_err(|e| { - BoxError::CacheError(format!( - "Failed to create symlink {} → {}: {}", - dst.display(), - target.display(), - e - )) - })?; - } - - #[cfg(windows)] - { - let is_dir = src.metadata().map(|m| m.is_dir()).unwrap_or(false); - let result = if is_dir { - std::os::windows::fs::symlink_dir(&target, dst) - } else { - std::os::windows::fs::symlink_file(&target, dst) - }; - result.map_err(|e| { - BoxError::CacheError(format!( - "Failed to create symlink {} → {}: {}", - dst.display(), - target.display(), - e - )) - })?; - } - - #[cfg(not(any(unix, windows)))] - { - let _ = target; - return Err(BoxError::CacheError(format!( - "Symlink copy is not supported on this platform: {}", - src.display() - ))); - } - - Ok(()) -} - -/// Calculate the total size of a directory recursively. -fn dir_size(path: &Path) -> u64 { - let mut total = 0u64; - if let Ok(entries) = std::fs::read_dir(path) { - for entry in entries.flatten() { - let p = entry.path(); - if p.is_dir() { - total += dir_size(&p); - } else if let Ok(meta) = p.metadata() { - total += meta.len(); - } - } - } - total -} - impl SnapshotStoreBackend for SnapshotStore { fn save(&self, metadata: SnapshotMetadata, rootfs_source: &Path) -> Result { self.save(metadata, rootfs_source) @@ -723,7 +675,7 @@ mod tests { std::fs::write(dir.join("a.txt"), "hello").unwrap(); std::fs::write(dir.join("b.txt"), "world!").unwrap(); - let size = dir_size(&dir); + let size = dir_size(&dir).unwrap(); assert_eq!(size, 11); // 5 + 6 } @@ -735,7 +687,7 @@ mod tests { std::fs::write(dir.join("a.txt"), "abc").unwrap(); std::fs::write(dir.join("sub/b.txt"), "defgh").unwrap(); - let size = dir_size(&dir); + let size = dir_size(&dir).unwrap(); assert_eq!(size, 8); // 3 + 5 } @@ -744,7 +696,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let dir = tmp.path().join("empty"); std::fs::create_dir_all(&dir).unwrap(); - assert_eq!(dir_size(&dir), 0); + assert_eq!(dir_size(&dir).unwrap(), 0); } #[test] @@ -768,6 +720,120 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn snapshot_size_does_not_follow_absolute_symlinks() { + use std::os::unix::ffi::OsStrExt; + + let tmp = TempDir::new().unwrap(); + let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap(); + let rootfs = tmp.path().join("rootfs"); + std::fs::create_dir(&rootfs).unwrap(); + let outside = tmp.path().join("outside"); + std::fs::write(&outside, vec![0_u8; 64 * 1024]).unwrap(); + std::os::unix::fs::symlink(&outside, rootfs.join("outside-link")).unwrap(); + + let saved = store + .save(make_metadata("symlink-size", "symlink-size"), &rootfs) + .unwrap(); + + assert_eq!( + saved.size_bytes, + outside.as_os_str().as_bytes().len() as u64 + ); + } + + #[cfg(unix)] + #[test] + fn snapshot_preserves_hardlinks_and_xattrs() { + use std::os::unix::fs::MetadataExt; + + let tmp = TempDir::new().unwrap(); + let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap(); + let rootfs = tmp.path().join("rootfs"); + std::fs::create_dir(&rootfs).unwrap(); + let first = rootfs.join("first"); + let second = rootfs.join("second"); + std::fs::write(&first, b"shared inode").unwrap(); + std::fs::hard_link(&first, &second).unwrap(); + xattr::set(&first, "user.a3s.snapshot", b"preserved").unwrap(); + + let saved = store + .save(make_metadata("hardlink-xattr", "hardlink-xattr"), &rootfs) + .unwrap(); + let captured = store.rootfs_path(&saved.id); + let captured_first = std::fs::metadata(captured.join("first")).unwrap(); + let captured_second = std::fs::metadata(captured.join("second")).unwrap(); + + assert_eq!(captured_first.dev(), captured_second.dev()); + assert_eq!(captured_first.ino(), captured_second.ino()); + assert_eq!(saved.size_bytes, b"shared inode".len() as u64); + assert_eq!( + xattr::get(captured.join("first"), "user.a3s.snapshot").unwrap(), + Some(b"preserved".to_vec()) + ); + } + + #[cfg(unix)] + #[test] + fn snapshot_rejects_fifo_without_leaking_staging() { + use std::os::unix::ffi::OsStrExt; + + let tmp = TempDir::new().unwrap(); + let snapshots = tmp.path().join("snapshots"); + let store = SnapshotStore::new(&snapshots).unwrap(); + let rootfs = tmp.path().join("rootfs"); + std::fs::create_dir(&rootfs).unwrap(); + let fifo = rootfs.join("blocking-fifo"); + let fifo_path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }, 0); + + let error = store + .save(make_metadata("fifo", "fifo"), &rootfs) + .unwrap_err(); + + assert!(error.to_string().contains("unsupported special file")); + assert!(store.get("fifo").unwrap().is_none()); + assert!( + std::fs::read_dir(snapshots) + .unwrap() + .flatten() + .all(|entry| !entry.file_name().to_string_lossy().starts_with(".staging-")), + "failed Snapshot must remove its staging tree" + ); + } + + #[test] + fn managed_snapshot_installs_only_terminal_rootfs_metadata() { + let tmp = TempDir::new().unwrap(); + let store = SnapshotStore::new(&tmp.path().join("snapshots")).unwrap(); + let rootfs = make_rootfs(&tmp); + std::fs::write( + rootfs.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')), + b"stale image metadata", + ) + .unwrap(); + let manifest = RootfsMetadataManifest::new(Vec::new()); + + store + .save_managed( + make_metadata("managed-metadata", "managed-metadata"), + &rootfs, + &manifest, + ) + .unwrap(); + + let captured = store.rootfs_path("managed-metadata"); + assert!(!captured + .join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')) + .exists()); + let stored: RootfsMetadataManifest = serde_json::from_slice( + &std::fs::read(captured.join(ROOTFS_METADATA_PATH.trim_start_matches('/'))).unwrap(), + ) + .unwrap(); + assert_eq!(stored, manifest); + } + #[test] fn new_sweeps_leftover_staging_dirs() { let tmp = TempDir::new().unwrap(); diff --git a/src/runtime/src/snapshot/copy.rs b/src/runtime/src/snapshot/copy.rs new file mode 100644 index 00000000..134c1b4b --- /dev/null +++ b/src/runtime/src/snapshot/copy.rs @@ -0,0 +1,395 @@ +//! Filesystem-safe Snapshot tree cloning and payload measurement. + +use std::path::Path; +#[cfg(unix)] +use std::path::PathBuf; + +use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::rootfs_metadata::{ + RootfsMetadataManifest, IMAGE_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH, +}; + +pub(super) fn install_rootfs_metadata( + rootfs: &Path, + metadata: &RootfsMetadataManifest, +) -> Result<()> { + metadata.validate().map_err(BoxError::OciImageError)?; + for reserved in [IMAGE_ROOTFS_METADATA_PATH, ROOTFS_METADATA_PATH] { + let path = rootfs.join(reserved.trim_start_matches('/')); + match std::fs::symlink_metadata(&path) { + Ok(existing) if existing.file_type().is_file() || existing.file_type().is_symlink() => { + std::fs::remove_file(&path).map_err(|error| { + BoxError::CacheError(format!( + "Failed to replace snapshot rootfs metadata {}: {error}", + path.display() + )) + })?; + } + Ok(_) => { + return Err(BoxError::CacheError(format!( + "Snapshot rootfs metadata path is not a regular file: {}", + path.display() + ))) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(BoxError::CacheError(format!( + "Failed to inspect snapshot rootfs metadata {}: {error}", + path.display() + ))) + } + } + } + + let destination = rootfs.join(ROOTFS_METADATA_PATH.trim_start_matches('/')); + let bytes = serde_json::to_vec(metadata).map_err(|error| { + BoxError::SerializationError(format!( + "Failed to encode snapshot rootfs metadata: {error}" + )) + })?; + std::fs::write(&destination, bytes).map_err(|error| { + BoxError::CacheError(format!( + "Failed to write snapshot rootfs metadata {}: {error}", + destination.display() + )) + })?; + Ok(()) +} + +/// Recursively clone a rootfs without following symlinks. Linux Sandbox +/// snapshots preserve hardlink identity, ownership, modes, timestamps, and +/// xattrs. Unsupported special files fail closed instead of being opened as +/// regular files (which would block forever for a FIFO). +#[cfg(unix)] +pub(super) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + let metadata = std::fs::symlink_metadata(src).map_err(snapshot_copy_error(src, "inspect"))?; + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + return Err(BoxError::CacheError(format!( + "Snapshot rootfs source is not a directory: {}", + src.display() + ))); + } + let mut state = SnapshotCopyState::default(); + copy_snapshot_directory(src, dst, &metadata, &mut state) +} + +#[cfg(unix)] +#[derive(Default)] +struct SnapshotCopyState { + hardlinks: std::collections::HashMap<(u64, u64), PathBuf>, +} + +#[cfg(unix)] +fn copy_snapshot_directory( + src: &Path, + dst: &Path, + metadata: &std::fs::Metadata, + state: &mut SnapshotCopyState, +) -> Result<()> { + std::fs::create_dir(dst).map_err(snapshot_copy_error(dst, "create directory"))?; + let mut entries: Vec<_> = std::fs::read_dir(src) + .map_err(snapshot_copy_error(src, "read directory"))? + .collect::>() + .map_err(snapshot_copy_error(src, "read directory entry"))?; + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + copy_snapshot_entry(&entry.path(), &dst.join(entry.file_name()), state)?; + } + finish_snapshot_copy(src, dst, metadata, false) +} + +#[cfg(unix)] +fn copy_snapshot_entry(src: &Path, dst: &Path, state: &mut SnapshotCopyState) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + let metadata = std::fs::symlink_metadata(src).map_err(snapshot_copy_error(src, "inspect"))?; + let file_type = metadata.file_type(); + if file_type.is_dir() { + return copy_snapshot_directory(src, dst, &metadata, state); + } + if file_type.is_symlink() { + let target = std::fs::read_link(src).map_err(snapshot_copy_error(src, "read symlink"))?; + std::os::unix::fs::symlink(&target, dst) + .map_err(snapshot_copy_error(dst, "create symlink"))?; + return finish_snapshot_copy(src, dst, &metadata, true); + } + if !file_type.is_file() { + return Err(BoxError::CacheError(format!( + "Snapshot rootfs contains unsupported special file {} ({})", + src.display(), + special_file_kind(&file_type) + ))); + } + + let hardlink_key = (metadata.dev(), metadata.ino()); + if metadata.nlink() > 1 { + if let Some(existing) = state.hardlinks.get(&hardlink_key) { + std::fs::hard_link(existing, dst) + .map_err(snapshot_copy_error(dst, "preserve hardlink"))?; + return Ok(()); + } + } + crate::cache::layer_cache::copy_file_cow(src, dst) + .map_err(snapshot_copy_error(dst, "copy regular file"))?; + finish_snapshot_copy(src, dst, &metadata, false)?; + if metadata.nlink() > 1 { + state.hardlinks.insert(hardlink_key, dst.to_path_buf()); + } + Ok(()) +} + +#[cfg(unix)] +fn finish_snapshot_copy( + src: &Path, + dst: &Path, + metadata: &std::fs::Metadata, + symlink: bool, +) -> Result<()> { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let current = std::fs::symlink_metadata(dst).map_err(snapshot_copy_error(dst, "inspect"))?; + if current.uid() != metadata.uid() || current.gid() != metadata.gid() { + let path = std::ffi::CString::new(dst.as_os_str().as_bytes()).map_err(|_| { + BoxError::CacheError(format!( + "Snapshot path contains a NUL byte: {}", + dst.display() + )) + })?; + if unsafe { libc::lchown(path.as_ptr(), metadata.uid(), metadata.gid()) } != 0 { + return Err(BoxError::CacheError(format!( + "Failed to preserve ownership on {}: {}", + dst.display(), + std::io::Error::last_os_error() + ))); + } + } + if !symlink { + std::fs::set_permissions( + dst, + std::fs::Permissions::from_mode(metadata.mode() & 0o7777), + ) + .map_err(snapshot_copy_error(dst, "preserve mode"))?; + filetime::set_file_times( + dst, + filetime::FileTime::from_last_access_time(metadata), + filetime::FileTime::from_last_modification_time(metadata), + ) + .map_err(snapshot_copy_error(dst, "preserve timestamps"))?; + } else { + filetime::set_symlink_file_times( + dst, + filetime::FileTime::from_last_access_time(metadata), + filetime::FileTime::from_last_modification_time(metadata), + ) + .map_err(snapshot_copy_error(dst, "preserve symlink timestamps"))?; + } + copy_snapshot_xattrs(src, dst)?; + Ok(()) +} + +#[cfg(unix)] +fn copy_snapshot_xattrs(src: &Path, dst: &Path) -> Result<()> { + use std::os::unix::ffi::OsStrExt; + + for name in xattr::list(src).map_err(snapshot_copy_error(src, "list xattrs"))? { + let raw_name = name.as_bytes(); + if raw_name.starts_with(b"trusted.overlay.") || raw_name.starts_with(b"user.overlay.") { + return Err(BoxError::CacheError(format!( + "Snapshot rootfs contains reserved overlay xattr {:?} at {}", + name, + src.display() + ))); + } + let value = xattr::get(src, &name) + .map_err(snapshot_copy_error(src, "read xattr"))? + .ok_or_else(|| { + BoxError::CacheError(format!( + "Snapshot xattr {:?} disappeared from {} while quiesced", + name, + src.display() + )) + })?; + xattr::set(dst, &name, &value).map_err(snapshot_copy_error(dst, "write xattr"))?; + } + Ok(()) +} + +#[cfg(unix)] +fn special_file_kind(file_type: &std::fs::FileType) -> &'static str { + use std::os::unix::fs::FileTypeExt; + + if file_type.is_fifo() { + "fifo" + } else if file_type.is_socket() { + "socket" + } else if file_type.is_char_device() { + "character device" + } else if file_type.is_block_device() { + "block device" + } else { + "unknown" + } +} + +#[cfg(unix)] +fn snapshot_copy_error<'a>( + path: &'a Path, + operation: &'static str, +) -> impl FnOnce(std::io::Error) -> BoxError + 'a { + move |error| { + BoxError::CacheError(format!( + "Failed to {operation} Snapshot path {}: {error}", + path.display() + )) + } +} + +/// Windows keeps the portable snapshot copy because the rootfs cache helper +/// intentionally rejects Windows symlinks. Managed Sandbox snapshots are +/// Linux-only, but the pre-existing VM Snapshot store remains cross-platform. +#[cfg(windows)] +pub(super) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + std::fs::create_dir_all(dst).map_err(|e| { + BoxError::CacheError(format!( + "Failed to create directory {}: {}", + dst.display(), + e + )) + })?; + + for entry in std::fs::read_dir(src).map_err(|e| { + BoxError::CacheError(format!("Failed to read directory {}: {}", src.display(), e)) + })? { + let entry = entry + .map_err(|e| BoxError::CacheError(format!("Failed to read directory entry: {}", e)))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + let file_type = entry.file_type().map_err(|e| { + BoxError::CacheError(format!( + "Failed to read file type for {}: {}", + src_path.display(), + e + )) + })?; + + if file_type.is_symlink() { + copy_symlink(&src_path, &dst_path)?; + } else if file_type.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path).map_err(|e| { + BoxError::CacheError(format!( + "Failed to copy {} → {}: {}", + src_path.display(), + dst_path.display(), + e + )) + })?; + } + } + + Ok(()) +} + +#[cfg(windows)] +fn copy_symlink(src: &Path, dst: &Path) -> Result<()> { + let target = std::fs::read_link(src).map_err(|e| { + BoxError::CacheError(format!("Failed to read symlink {}: {}", src.display(), e)) + })?; + + let is_dir = src.metadata().map(|m| m.is_dir()).unwrap_or(false); + let result = if is_dir { + std::os::windows::fs::symlink_dir(&target, dst) + } else { + std::os::windows::fs::symlink_file(&target, dst) + }; + result.map_err(|e| { + BoxError::CacheError(format!( + "Failed to create symlink {} → {}: {}", + dst.display(), + target.display(), + e + )) + })?; + + Ok(()) +} + +/// Calculate Snapshot payload bytes without following symlinks or counting one +/// hardlinked inode more than once. +pub(super) fn dir_size(path: &Path) -> Result { + #[cfg(unix)] + { + let mut seen = std::collections::HashSet::new(); + dir_size_unix(path, &mut seen) + } + #[cfg(not(unix))] + { + dir_size_portable(path) + } +} + +#[cfg(unix)] +fn dir_size_unix(path: &Path, seen: &mut std::collections::HashSet<(u64, u64)>) -> Result { + use std::os::unix::fs::MetadataExt; + + let metadata = std::fs::symlink_metadata(path).map_err(snapshot_copy_error(path, "size"))?; + if metadata.file_type().is_symlink() { + return Ok(metadata.len()); + } + if metadata.file_type().is_file() { + return Ok(if seen.insert((metadata.dev(), metadata.ino())) { + metadata.len() + } else { + 0 + }); + } + if !metadata.file_type().is_dir() { + return Err(BoxError::CacheError(format!( + "Snapshot contains unsupported special file while measuring {}", + path.display() + ))); + } + let mut total = 0_u64; + for entry in std::fs::read_dir(path).map_err(snapshot_copy_error(path, "measure directory"))? { + total = total.saturating_add(dir_size_unix( + &entry + .map_err(snapshot_copy_error(path, "measure directory entry"))? + .path(), + seen, + )?); + } + Ok(total) +} + +#[cfg(not(unix))] +fn dir_size_portable(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + BoxError::CacheError(format!( + "Failed to inspect Snapshot path {}: {error}", + path.display() + )) + })?; + if metadata.file_type().is_symlink() || metadata.file_type().is_file() { + return Ok(metadata.len()); + } + if !metadata.file_type().is_dir() { + return Err(BoxError::CacheError(format!( + "Snapshot contains unsupported special file while measuring {}", + path.display() + ))); + } + let mut total = 0_u64; + for entry in std::fs::read_dir(path).map_err(|error| { + BoxError::CacheError(format!( + "Failed to measure Snapshot directory {}: {error}", + path.display() + )) + })? { + total = total.saturating_add(dir_size_portable( + &entry.map_err(BoxError::IoError)?.path(), + )?); + } + Ok(total) +} diff --git a/src/runtime/src/vm/layout.rs b/src/runtime/src/vm/layout.rs index 4f2ede20..0329f038 100644 --- a/src/runtime/src/vm/layout.rs +++ b/src/runtime/src/vm/layout.rs @@ -10,6 +10,35 @@ use a3s_box_core::error::{BoxError, Result}; use super::{BoxLayout, VmManager}; +pub(crate) fn runtime_socket_dir(home_dir: &Path, box_id: &str) -> PathBuf { + #[cfg(all(unix, target_os = "macos"))] + { + let _ = home_dir; + PathBuf::from("/private/tmp") + .join("a3s-box-sockets") + .join(box_id) + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + let _ = home_dir; + PathBuf::from("/tmp").join("a3s-box-sockets").join(box_id) + } + + #[cfg(not(unix))] + { + home_dir.join("boxes").join(box_id).join("sockets") + } +} + +fn registry_auth_for_image(home_dir: &Path, reference: &str) -> Result { + let parsed = crate::oci::ImageReference::parse(reference)?; + Ok(crate::oci::RegistryAuth::from_credential_store_at( + home_dir, + &parsed.registry, + )) +} + impl VmManager { pub(crate) async fn prepare_layout(&self) -> Result { // Create box-specific directories @@ -61,6 +90,10 @@ impl VmManager { // result, slower). This mirrors the rootfs cache-hit path below. if let Some(lower) = snapshot_lower_dir(&box_dir) { if lower.is_dir() { + let oci_config = Some(crate::resolved_image::load_snapshot_oci_config( + &lower, + &self.config.image, + )?); tracing::info!( lower = %lower.display(), "Restoring snapshot via copy-on-write overlay lower" @@ -77,6 +110,9 @@ impl VmManager { tracing::warn!(error = %e, "Failed to refresh guest init on restored overlay"); } } + if let Some(config) = oci_config.as_ref() { + crate::resolved_image::persist_resolved_image_config(&box_dir, config)?; + } let tee_instance_config = self.generate_tee_config(&box_dir)?; return Ok(BoxLayout { rootfs_path, @@ -86,7 +122,7 @@ impl VmManager { port_forward_socket_path: socket_dir.join("portfwd.sock"), workspace_path, console_output: Some(logs_dir.join("console.log")), - oci_config: None, + oci_config, tee_instance_config, }); } @@ -113,6 +149,8 @@ impl VmManager { .map(|mut it| it.next().is_some()) .unwrap_or(false); if prebuilt_is_populated { + let oci_config = crate::resolved_image::load_resolved_image_config(&box_dir)? + .map(crate::oci::OciImageConfig::from); tracing::info!( rootfs = %prebuilt_rootfs.display(), "Booting from pre-populated rootfs (snapshot restore)" @@ -136,7 +174,7 @@ impl VmManager { port_forward_socket_path: socket_dir.join("portfwd.sock"), workspace_path, console_output: Some(logs_dir.join("console.log")), - oci_config: None, + oci_config, tee_instance_config, }); } @@ -176,10 +214,8 @@ impl VmManager { let images_dir = self.home_dir.join("images"); let store = crate::oci::ImageStore::new(&images_dir, crate::DEFAULT_IMAGE_CACHE_SIZE)?; - let mut puller = crate::oci::ImagePuller::new( - std::sync::Arc::new(store), - crate::oci::RegistryAuth::from_env(), - ); + let auth = registry_auth_for_image(&self.home_dir, reference)?; + let mut puller = crate::oci::ImagePuller::new(std::sync::Arc::new(store), auth); if let Some(ref m) = self.prom { puller = puller.set_metrics(m.clone()); } @@ -232,32 +268,58 @@ impl VmManager { prom.rootfs_cache_misses.inc(); } - let rootfs_path = box_dir.join("rootfs"); + let rootfs_path = self.rootfs_provider.prepare_empty(&box_dir)?; + let rootfs_populated = std::fs::read_dir(&rootfs_path) + .map(|mut entries| entries.next().is_some()) + .map_err(|error| { + BoxError::BuildError(format!( + "Failed to inspect rootfs {}: {error}", + rootfs_path.display() + )) + })?; let mut builder = OciRootfsBuilder::new(&rootfs_path).with_image(&image_path); - // Install guest init if available (runs as PID 1, mounts virtiofs shares, - // then execs the container entrypoint) - if let Ok(guest_init_path) = Self::find_guest_init() { + // A persistent copy/APFS provider already contains the prior + // terminal rootfs generation. Re-extracting the image would + // overwrite guest changes and fails on existing layer + // hardlinks. The image config remains immutable OCI metadata, + // so read it without rebuilding the filesystem. + if rootfs_populated { tracing::info!( - guest_init = %guest_init_path.display(), - "Installing guest init" + rootfs = %rootfs_path.display(), + "Reusing populated persistent rootfs" ); - builder = builder.with_guest_init(guest_init_path); + let config = builder.image_config()?; + (rootfs_path, Some(config)) } else { - tracing::warn!( - "Guest init binary not found; container entrypoint will run as PID 1" - ); - } + // Install guest init if available (runs as PID 1, mounts virtiofs shares, + // then execs the container entrypoint) + if let Ok(guest_init_path) = Self::find_guest_init() { + tracing::info!( + guest_init = %guest_init_path.display(), + "Installing guest init" + ); + builder = builder.with_guest_init(guest_init_path); + } else { + tracing::warn!( + "Guest init binary not found; container entrypoint will run as PID 1" + ); + } - builder.build()?; - let config = builder.image_config()?; + builder.build()?; + let config = builder.image_config()?; - // Store in cache for next time - self.store_rootfs_cache(&cache_key, &rootfs_path, reference); + // Store in cache for next time + self.store_rootfs_cache(&cache_key, &rootfs_path, reference); - (rootfs_path, Some(config)) + (rootfs_path, Some(config)) + } }; + if let Some(config) = oci_config.as_ref() { + crate::resolved_image::persist_resolved_image_config(&box_dir, config)?; + } + // Generate TEE configuration if enabled let tee_instance_config = self.generate_tee_config(&box_dir)?; @@ -275,29 +337,7 @@ impl VmManager { } pub(crate) fn socket_dir(&self) -> PathBuf { - #[cfg(all(unix, target_os = "macos"))] - { - // Use the canonical short temp path so macOS HVF runs can bind - // Unix sockets without relying on the /tmp symlink. - PathBuf::from("/private/tmp") - .join("a3s-box-sockets") - .join(&self.box_id) - } - - #[cfg(all(unix, not(target_os = "macos")))] - { - PathBuf::from("/tmp") - .join("a3s-box-sockets") - .join(&self.box_id) - } - - #[cfg(not(unix))] - { - self.home_dir - .join("boxes") - .join(&self.box_id) - .join("sockets") - } + runtime_socket_dir(&self.home_dir, &self.box_id) } /// Try to get a cached rootfs and copy it to the target path. @@ -338,20 +378,48 @@ impl VmManager { /// Returns `Some(cached_path)` if cache hit, `None` if cache miss. /// The caller is responsible for preparing the rootfs via `RootfsProvider`. pub(crate) fn try_rootfs_cache_path(&self, cache_key: &str) -> Result> { - if !self.config.cache.enabled { - return Ok(None); + #[cfg(target_os = "macos")] + { + if !self.config.cache.enabled { + return Ok(None); + } + let image = self + .resolve_cache_dir() + .join("rootfs-apfs-v2") + .join(format!("{cache_key}.sparseimage")); + if image.is_file() { + // Cache pruning is LRU. Refresh both timestamps without changing + // sparse-image contents so frequently used images remain hot. + if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&image) { + let now = std::time::SystemTime::now(); + let times = std::fs::FileTimes::new() + .set_accessed(now) + .set_modified(now); + let _ = file.set_times(times); + } + Ok(Some(image)) + } else { + Ok(None) + } } - let cache_dir = self.resolve_cache_dir().join("rootfs"); - let cache = match RootfsCache::new(&cache_dir) { - Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, "Failed to open rootfs cache, skipping"); + #[cfg(not(target_os = "macos"))] + { + if !self.config.cache.enabled { return Ok(None); } - }; - cache.get(cache_key) + let cache_dir = self.resolve_cache_dir().join("rootfs"); + let cache = match RootfsCache::new(&cache_dir) { + Ok(c) => c, + Err(e) => { + tracing::warn!(error = %e, "Failed to open rootfs cache, skipping"); + return Ok(None); + } + }; + + cache.get(cache_key) + } } /// Store a built rootfs in the cache for future reuse. @@ -363,40 +431,95 @@ impl VmManager { rootfs_path: &Path, description: &str, ) { - if !self.config.cache.enabled { - return; + #[cfg(target_os = "macos")] + { + use std::process::Command; + + if !self.config.cache.enabled { + return; + } + let cache_dir = self.resolve_cache_dir().join("rootfs-apfs-v2"); + if let Err(error) = std::fs::create_dir_all(&cache_dir) { + tracing::warn!(%error, "Failed to create APFS rootfs cache"); + return; + } + let mountpoint = rootfs_path.parent().unwrap_or(rootfs_path); + let box_dir = mountpoint.parent().unwrap_or(mountpoint); + let source = box_dir.join("rootfs-apfs-v2.sparseimage"); + let destination = cache_dir.join(format!("{cache_key}.sparseimage")); + let temporary = cache_dir.join(format!(".{cache_key}.tmp-{}", std::process::id())); + + crate::rootfs::unmount_box_rootfs(rootfs_path); + let cloned = Command::new("cp") + .arg("-c") + .arg(&source) + .arg(&temporary) + .status() + .is_ok_and(|status| status.success()); + if cloned { + if let Err(error) = std::fs::rename(&temporary, &destination) { + tracing::warn!(%error, "Failed to publish APFS rootfs cache image"); + } else { + tracing::debug!( + cache_key = %&cache_key[..cache_key.len().min(12)], + %description, + "Stored case-sensitive APFS rootfs cache" + ); + if let Err(error) = prune_apfs_rootfs_cache( + &cache_dir, + self.config.cache.max_rootfs_entries, + self.config.cache.max_cache_bytes, + cache_key, + ) { + tracing::warn!(%error, "Failed to prune APFS rootfs cache"); + } + } + } else { + tracing::warn!(source = %source.display(), "Failed to clone APFS rootfs cache image"); + let _ = std::fs::remove_file(&temporary); + } + if let Err(error) = self.rootfs_provider.prepare_empty(box_dir) { + tracing::warn!(%error, "Failed to remount rootfs after caching"); + } } - let cache_dir = self.resolve_cache_dir().join("rootfs"); - let cache = match RootfsCache::new(&cache_dir) { - Ok(c) => c, - Err(e) => { - tracing::warn!(error = %e, "Failed to open rootfs cache for storing"); + #[cfg(not(target_os = "macos"))] + { + if !self.config.cache.enabled { return; } - }; - match cache.put(cache_key, rootfs_path, description) { - Ok(_) => { - tracing::debug!( - cache_key = %&cache_key[..cache_key.len().min(12)], - description = %description, - "Stored rootfs in cache" - ); - // Prune if needed — but never evict a cache entry that is in use as - // a live overlay lower for a concurrent box (deleting the lowerdir - // under its mount(2) is the same-image concurrency bug this guards). - let protected = self.referenced_rootfs_cache_keys(); - if let Err(e) = cache.prune_protecting( - self.config.cache.max_rootfs_entries, - self.config.cache.max_cache_bytes, - &protected, - ) { - tracing::warn!(error = %e, "Failed to prune rootfs cache"); + let cache_dir = self.resolve_cache_dir().join("rootfs"); + let cache = match RootfsCache::new(&cache_dir) { + Ok(c) => c, + Err(e) => { + tracing::warn!(error = %e, "Failed to open rootfs cache for storing"); + return; + } + }; + + match cache.put(cache_key, rootfs_path, description) { + Ok(_) => { + tracing::debug!( + cache_key = %&cache_key[..cache_key.len().min(12)], + description = %description, + "Stored rootfs in cache" + ); + // Prune if needed — but never evict a cache entry that is in use as + // a live overlay lower for a concurrent box (deleting the lowerdir + // under its mount(2) is the same-image concurrency bug this guards). + let protected = self.referenced_rootfs_cache_keys(); + if let Err(e) = cache.prune_protecting( + self.config.cache.max_rootfs_entries, + self.config.cache.max_cache_bytes, + &protected, + ) { + tracing::warn!(error = %e, "Failed to prune rootfs cache"); + } + } + Err(e) => { + tracing::warn!(error = %e, "Failed to store rootfs in cache"); } - } - Err(e) => { - tracing::warn!(error = %e, "Failed to store rootfs in cache"); } } } @@ -412,6 +535,7 @@ impl VmManager { /// Rootfs-cache keys currently in use as an overlay lower by some live box. /// Boxes live under `/boxes//`; a removed box's marker is gone with /// its dir, so an evictable key is simply one no live box references. + #[cfg(not(target_os = "macos"))] fn referenced_rootfs_cache_keys(&self) -> std::collections::HashSet { let mut set = std::collections::HashSet::new(); if let Ok(entries) = std::fs::read_dir(self.home_dir.join("boxes")) { @@ -688,6 +812,72 @@ impl VmManager { } } +#[cfg(target_os = "macos")] +fn prune_apfs_rootfs_cache( + cache_dir: &Path, + max_entries: usize, + max_allocated_bytes: u64, + protected_key: &str, +) -> std::io::Result<()> { + use std::os::unix::fs::MetadataExt; + + struct Entry { + path: PathBuf, + key: String, + modified: std::time::SystemTime, + allocated_bytes: u64, + } + + let mut entries = Vec::new(); + for item in std::fs::read_dir(cache_dir)? { + let item = item?; + let path = item.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(key) = name.strip_suffix(".sparseimage") else { + continue; + }; + if key.starts_with('.') || !item.file_type()?.is_file() { + continue; + } + let key = key.to_string(); + let metadata = item.metadata()?; + entries.push(Entry { + path, + key, + modified: metadata.modified().unwrap_or(std::time::UNIX_EPOCH), + // `len()` is the sparse image's 64 GiB virtual capacity. `blocks()` + // reflects physical 512-byte blocks and is the bounded resource. + allocated_bytes: metadata.blocks().saturating_mul(512), + }); + } + + entries.sort_by_key(|entry| entry.modified); + let mut count = entries.len(); + let mut allocated: u64 = entries.iter().map(|entry| entry.allocated_bytes).sum(); + for entry in entries { + if count <= max_entries && allocated <= max_allocated_bytes { + break; + } + if entry.key == protected_key { + continue; + } + match std::fs::remove_file(&entry.path) { + Ok(()) => { + count = count.saturating_sub(1); + allocated = allocated.saturating_sub(entry.allocated_bytes); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + count = count.saturating_sub(1); + allocated = allocated.saturating_sub(entry.allocated_bytes); + } + Err(error) => return Err(error), + } + } + Ok(()) +} + /// Read the snapshot-restore copy-on-write overlay lower marker, if present and /// non-empty. `snapshot restore` writes the snapshot's stored rootfs path here; /// the runtime mounts it as a read-only overlay lower instead of copying the @@ -708,6 +898,7 @@ mod tests { use super::*; use crate::cache::RootfsCache; use a3s_box_core::config::BoxConfig; + use a3s_box_core::{SnapshotImageConfig, SnapshotMetadata}; use std::sync::Arc; use tempfile::TempDir; use tokio::sync::RwLock; @@ -740,9 +931,34 @@ mod tests { shim_exit_code: None, pull_progress_fn: None, log_config: a3s_box_core::log::LogConfig::default(), + resolved_execution_plan: None, } } + #[test] + fn vm_image_auth_uses_the_managers_explicit_home() { + let home = TempDir::new().unwrap(); + let store = crate::oci::CredentialStore::new(home.path().join("auth/credentials.json")); + store + .store( + "manager-layout.invalid:5443", + "layout-user", + "layout-secret", + ) + .unwrap(); + + let auth = registry_auth_for_image( + home.path(), + "manager-layout.invalid:5443/a3s/private:latest", + ) + .unwrap(); + + assert_eq!( + auth.basic_credentials(), + Some(("layout-user".to_string(), "layout-secret".to_string())) + ); + } + #[test] fn test_snapshot_lower_dir_marker() { let tmp = TempDir::new().unwrap(); @@ -764,6 +980,74 @@ mod tests { ); } + #[tokio::test] + async fn snapshot_lower_layout_restores_the_resolved_image_entrypoint() { + let home = TempDir::new().unwrap(); + let snapshot_id = "snapshot-with-image-config"; + let snapshot_dir = home.path().join("snapshots").join(snapshot_id); + let lower = snapshot_dir.join("rootfs"); + std::fs::create_dir_all(lower.join("usr/local/bin")).unwrap(); + std::fs::write(lower.join("usr/local/bin/envd"), b"envd").unwrap(); + + let mut metadata = SnapshotMetadata::new( + snapshot_id.to_string(), + snapshot_id.to_string(), + "source-box".to_string(), + "example.invalid/runtime:latest".to_string(), + ); + metadata.image_config = Some(SnapshotImageConfig { + entrypoint: Some(vec!["/usr/local/bin/envd".to_string()]), + cmd: Some(vec!["--port".to_string(), "49983".to_string()]), + env: vec![("RUNTIME".to_string(), "a3s".to_string())], + working_dir: Some("/home/user".to_string()), + user: Some("1000:1000".to_string()), + ..Default::default() + }); + std::fs::write( + snapshot_dir.join("metadata.json"), + serde_json::to_vec_pretty(&metadata).unwrap(), + ) + .unwrap(); + + let box_dir = home.path().join("boxes/test-box"); + std::fs::create_dir_all(&box_dir).unwrap(); + std::fs::write( + box_dir.join(".snapshot-lower"), + lower.to_string_lossy().as_bytes(), + ) + .unwrap(); + let mut vm = make_vm_manager_with_home(home.path()); + vm.config.image = "example.invalid/runtime:latest".to_string(); + vm.rootfs_provider = Box::new(crate::rootfs::CopyProvider); + + let layout = vm.prepare_layout().await.unwrap(); + let image_config = layout + .oci_config + .as_ref() + .expect("snapshot layout must restore the resolved image configuration"); + assert_eq!( + image_config.entrypoint, + Some(vec!["/usr/local/bin/envd".to_string()]) + ); + assert_eq!( + image_config.cmd, + Some(vec!["--port".to_string(), "49983".to_string()]) + ); + + // Keep the assertion independent of whether a guest-init test artifact is + // available next to the test binary on this host. + let _ = std::fs::remove_file(layout.rootfs_path.join("sbin/init")); + let spec = vm.build_instance_spec(&layout).unwrap(); + assert_eq!(spec.entrypoint.executable, "/usr/local/bin/envd"); + assert_eq!(spec.entrypoint.args, vec!["--port", "49983"]); + assert!(spec + .entrypoint + .env + .iter() + .any(|(key, value)| key == "RUNTIME" && value == "a3s")); + assert_eq!(spec.workdir, "/home/user"); + } + #[test] fn test_resolve_cache_dir_default() { let tmp = TempDir::new().unwrap(); @@ -847,6 +1131,7 @@ mod tests { assert!(!cache_dir.exists()); } + #[cfg(not(target_os = "macos"))] #[test] fn test_store_rootfs_cache_success() { let tmp = TempDir::new().unwrap(); @@ -865,6 +1150,7 @@ mod tests { assert!(result.is_some()); } + #[cfg(not(target_os = "macos"))] #[test] fn test_store_rootfs_cache_prunes_on_store() { let tmp = TempDir::new().unwrap(); @@ -887,6 +1173,58 @@ mod tests { assert!(cache.entry_count().unwrap() <= 2); } + #[cfg(target_os = "macos")] + #[test] + fn test_prune_apfs_rootfs_cache_bounds_entries_and_protects_new_entry() { + let tmp = TempDir::new().unwrap(); + let cache_dir = tmp.path().join("rootfs-apfs"); + std::fs::create_dir_all(&cache_dir).unwrap(); + + for key in ["oldest", "middle", "new"] { + std::fs::write( + cache_dir.join(format!("{key}.sparseimage")), + vec![b'x'; 4096], + ) + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + std::fs::write(cache_dir.join(".partial.tmp-1"), b"temporary").unwrap(); + + prune_apfs_rootfs_cache(&cache_dir, 1, u64::MAX, "new").unwrap(); + + assert!(!cache_dir.join("oldest.sparseimage").exists()); + assert!(!cache_dir.join("middle.sparseimage").exists()); + assert!(cache_dir.join("new.sparseimage").exists()); + assert!(cache_dir.join(".partial.tmp-1").exists()); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_prune_apfs_rootfs_cache_uses_allocated_bytes_not_virtual_length() { + use std::os::unix::fs::MetadataExt; + + let tmp = TempDir::new().unwrap(); + let cache_dir = tmp.path().join("rootfs-apfs"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let old = cache_dir.join("old.sparseimage"); + let protected = cache_dir.join("protected.sparseimage"); + std::fs::write(&old, vec![b'x'; 8192]).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + std::fs::write(&protected, vec![b'y'; 4096]).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&protected) + .unwrap() + .set_len(64 * 1024 * 1024 * 1024) + .unwrap(); + let protected_allocated = protected.metadata().unwrap().blocks() * 512; + + prune_apfs_rootfs_cache(&cache_dir, usize::MAX, protected_allocated, "protected").unwrap(); + + assert!(!old.exists()); + assert!(protected.exists()); + } + #[cfg(unix)] #[tokio::test] async fn test_exec_command_rejects_created_state() { @@ -977,6 +1315,7 @@ mod tests { assert_eq!(request.user, Some("1000:1000".to_string())); } + #[cfg(not(target_os = "macos"))] #[test] fn test_try_and_store_roundtrip() { let tmp = TempDir::new().unwrap(); diff --git a/src/runtime/src/vm/mod.rs b/src/runtime/src/vm/mod.rs index 46717bfc..12f97a1d 100644 --- a/src/runtime/src/vm/mod.rs +++ b/src/runtime/src/vm/mod.rs @@ -4,19 +4,23 @@ mod layout; mod network; mod ready; pub mod reap; +mod sandbox; mod spec; +pub(crate) use layout::runtime_socket_dir; + use std::path::{Path, PathBuf}; use std::sync::Arc; /// Callback type for image pull progress: `(current, total, digest, size_bytes)`. -type PullProgressFn = Arc; +pub type PullProgressFn = Arc; use a3s_box_core::config::BoxConfig; #[cfg(unix)] use a3s_box_core::config::TeeConfig; use a3s_box_core::error::{BoxError, Result}; use a3s_box_core::event::{BoxEvent, EventEmitter}; +use a3s_box_core::execution::{ExecutionBackend, ResolvedExecutionPlan}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tracing::Instrument; @@ -143,6 +147,9 @@ pub struct VmManager { /// the log processor for the box's lifetime (set by the CLI via /// [`VmManager::set_log_config`]). pub(crate) log_config: a3s_box_core::log::LogConfig, + + /// Backend-neutral resolution captured before any boot side effects. + pub(crate) resolved_execution_plan: Option, } impl VmManager { @@ -175,6 +182,7 @@ impl VmManager { shim_exit_code: None, pull_progress_fn: None, log_config: a3s_box_core::log::LogConfig::default(), + resolved_execution_plan: None, } } @@ -206,6 +214,7 @@ impl VmManager { shim_exit_code: None, pull_progress_fn: None, log_config: a3s_box_core::log::LogConfig::default(), + resolved_execution_plan: None, } } @@ -341,6 +350,7 @@ impl VmManager { shim_exit_code: None, pull_progress_fn: None, log_config: a3s_box_core::log::LogConfig::default(), + resolved_execution_plan: None, } } @@ -360,6 +370,57 @@ impl VmManager { self.exec_client.as_ref() } + #[cfg(unix)] + async fn connect_exec_client_for_request(socket_path: &Path) -> Result { + const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); + + let client = ExecClient::connect(socket_path).await?; + match tokio::time::timeout(ATTEMPT_TIMEOUT, client.heartbeat()).await { + Ok(Ok(true)) => Ok(client), + Ok(Ok(false)) => Err(BoxError::ExecError(format!( + "Exec client not connected: heartbeat failed at {}", + socket_path.display() + ))), + Ok(Err(error)) => Err(error), + Err(_) => Err(BoxError::ExecError(format!( + "Exec client not connected: heartbeat timed out at {}", + socket_path.display() + ))), + } + } + + /// Wait until the guest exec server can complete a heartbeat. + /// + /// Cold foreground boots may proceed after the short diagnostic readiness + /// cap so logs remain visible. A warm pool has a stronger contract: an idle + /// VM must actually be executable before it is published to callers. + #[cfg(unix)] + pub async fn wait_for_exec_available(&mut self, timeout: std::time::Duration) -> Result<()> { + let socket_path = self + .exec_socket_path + .clone() + .ok_or_else(|| BoxError::ExecError("Exec socket path is unavailable".to_string()))?; + let deadline = tokio::time::Instant::now() + timeout; + loop { + match Self::connect_exec_client_for_request(&socket_path).await { + Ok(client) => { + self.exec_client = Some(client); + return Ok(()); + } + Err(error) if tokio::time::Instant::now() < deadline => { + tracing::debug!(%error, "Waiting for pooled VM exec readiness"); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + Err(error) => return Err(error), + } + } + } + + #[cfg(not(unix))] + pub async fn wait_for_exec_available(&mut self, _timeout: std::time::Duration) -> Result<()> { + Ok(()) + } + /// Attach this manager to an already-running shim process. /// /// This is useful for crash recovery or control-plane restart flows where @@ -400,6 +461,29 @@ impl VmManager { Ok(()) } + /// Attach this manager to an already-running Windows shim process. + #[cfg(windows)] + pub async fn attach_running_process( + &mut self, + pid: u32, + exec_socket_path: PathBuf, + pty_socket_path: Option, + ) -> Result<()> { + let handler = crate::vmm::ShimHandler::from_pid(pid, self.box_id.clone()); + if !handler.is_running() { + return Err(BoxError::StateError(format!( + "Cannot attach to non-running VM process {pid}" + ))); + } + + self.exec_socket_path = Some(exec_socket_path); + self.pty_socket_path = pty_socket_path; + self.port_forward_socket_path = None; + *self.handler.write().await = Some(Box::new(handler)); + *self.state.write().await = BoxState::Ready; + Ok(()) + } + /// Get the exec socket path, if the VM has been booted. pub fn exec_socket_path(&self) -> Option<&Path> { self.exec_socket_path.as_deref() @@ -471,6 +555,11 @@ impl VmManager { self.image_config.as_ref() } + /// Return the immutable execution resolution captured for this boot. + pub fn resolved_execution_plan(&self) -> Option<&ResolvedExecutionPlan> { + self.resolved_execution_plan.as_ref() + } + /// Get the exit code of the container, if it has exited. /// /// Returns `Some(code)` after `destroy()` has been called and the shim @@ -480,12 +569,21 @@ impl VmManager { self.shim_exit_code } + fn persisted_exit_code(&self) -> Option { + crate::rootfs::read_persisted_exit_code(&self.home_dir.join("boxes").join(&self.box_id)) + } + /// Poll the owned VM process for natural exit without sending a signal. /// /// This is used by foreground CLI flows where the container command may /// finish on its own and the CLI should clean up instead of waiting for /// a Ctrl-C. pub async fn try_wait_exit(&mut self) -> Result> { + if let Some(code) = self.persisted_exit_code() { + self.shim_exit_code = Some(code); + return Ok(Some(code)); + } + let mut handler = self.handler.write().await; let Some(handler) = handler.as_mut() else { return Ok(self.shim_exit_code); @@ -499,6 +597,21 @@ impl VmManager { Ok(None) } + /// Return true once the foreground container is known to have finished, even + /// if the shim exit status has not been reaped yet. + pub async fn has_exited(&self) -> bool { + if self.shim_exit_code.is_some() || self.persisted_exit_code().is_some() { + return true; + } + + self.handler + .read() + .await + .as_ref() + .map(|handler| handler.has_exited()) + .unwrap_or(false) + } + /// Run a command as the container MAIN in an IDLE-booted (deferred-main) VM. /// /// Sends the `spawn-main` control frame carrying `spec_json` (the command), @@ -512,18 +625,44 @@ impl VmManager { spec_json: &[u8], timeout: std::time::Duration, ) -> Result { + let log_dir = self.home_dir.join("boxes").join(&self.box_id).join("logs"); + let console_out_path = log_dir.join("console.log"); + let console_err_path = a3s_box_core::log::stderr_console_path(&console_out_path); + let console_out_start = std::fs::metadata(&console_out_path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + let console_err_start = std::fs::metadata(&console_err_path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + let acked = { - let client = self - .exec_client - .as_ref() - .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?; + let owned_client; + let client = if let Some(client) = self.exec_client.as_ref() { + client + } else { + let socket_path = self + .exec_socket_path + .as_deref() + .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?; + owned_client = Self::connect_exec_client_for_request(socket_path).await?; + &owned_client + }; client.spawn_main(Some(spec_json)).await? }; - if !acked { - return Err(BoxError::ExecError( - "spawn-main was not acknowledged by the guest".to_string(), - )); - } + let exit_wait_timeout = if acked { + timeout + } else { + // Very short deferred mains can exit and halt the VM before the + // guest's ACK frame makes it back to the host. Treat a missing ACK as + // provisional: if the VM exits promptly, the spawn succeeded and the + // real exit code/logs are authoritative; otherwise fail quickly + // instead of waiting the full command timeout for an IDLE VM. + tracing::debug!( + box_id = %self.box_id, + "spawn-main was not acknowledged; waiting briefly for main exit" + ); + timeout.min(std::time::Duration::from_secs(2)) + }; // Wait for the main to exit — guest-init persists the code and halts the VM. let start = std::time::Instant::now(); @@ -531,35 +670,50 @@ impl VmManager { if let Some(code) = self.try_wait_exit().await? { break code; } - if start.elapsed() >= timeout { - return Err(BoxError::ExecError( - "deferred main did not exit within the timeout".to_string(), - )); + if start.elapsed() >= exit_wait_timeout { + let message = if acked { + "deferred main did not exit within the timeout" + } else { + "spawn-main was not acknowledged by the guest" + }; + return Err(BoxError::ExecError(message.to_string())); } tokio::time::sleep(std::time::Duration::from_millis(50)).await; }; // Let the shim's log processor finish draining console.log into the json - // file (it flushes as the VM halts): poll until container.json stops - // growing for one interval (bounded at 1s) instead of a fixed sleep — - // fast when the drain is already done, safe when it lags. - let json_path = self - .home_dir - .join("boxes") - .join(&self.box_id) - .join("logs") - .join("container.json"); + // file (it flushes as the VM halts). A single short "stable length" + // sample is not enough here: deferred-main can persist its exit code + // before the final stdout/stderr bytes have reached the host tailer, + // especially with pre-warmed pools. Require a small quiet window before + // reading logs, bounded so no-output commands still return promptly. + let json_path = log_dir.join("container.json"); let drain_start = std::time::Instant::now(); - let mut last_len = u64::MAX; + let max_wait = std::time::Duration::from_secs(2); + let min_wait = std::time::Duration::from_millis(500); + let quiet_window = std::time::Duration::from_millis(200); + let mut last_len: Option = None; + let mut last_change = drain_start; loop { let len = std::fs::metadata(&json_path).map(|m| m.len()).unwrap_or(0); - if len == last_len || drain_start.elapsed() >= std::time::Duration::from_secs(1) { + if last_len != Some(len) { + last_len = Some(len); + last_change = std::time::Instant::now(); + } + let elapsed = drain_start.elapsed(); + if elapsed >= max_wait || (elapsed >= min_wait && last_change.elapsed() >= quiet_window) + { break; } - last_len = len; - tokio::time::sleep(std::time::Duration::from_millis(40)).await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let (mut stdout, mut stderr) = self.read_container_logs(); + if stdout.is_empty() { + stdout = Self::read_file_from_offset(&console_out_path, console_out_start); + } + if stderr.is_empty() { + stderr = Self::read_file_from_offset(&console_err_path, console_err_start); } - let (stdout, stderr) = self.read_container_logs(); Ok(a3s_box_core::exec::ExecOutput { stdout, stderr, @@ -567,6 +721,24 @@ impl VmManager { }) } + fn read_file_from_offset(path: &Path, offset: u64) -> Vec { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = match std::fs::File::open(path) { + Ok(file) => file, + Err(_) => return vec![], + }; + if file.seek(SeekFrom::Start(offset)).is_err() { + return vec![]; + } + + let mut bytes = Vec::new(); + if file.read_to_end(&mut bytes).is_err() { + return vec![]; + } + bytes + } + /// Read the box's json-file console logs, split into stdout/stderr by stream. fn read_container_logs(&self) -> (Vec, Vec) { let path = self @@ -617,10 +789,17 @@ impl VmManager { } drop(state); - let client = self - .exec_client - .as_ref() - .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?; + let owned_client; + let client = if let Some(client) = self.exec_client.as_ref() { + client + } else { + let socket_path = self + .exec_socket_path + .as_deref() + .ok_or_else(|| BoxError::ExecError("Exec client not connected".to_string()))?; + owned_client = Self::connect_exec_client_for_request(socket_path).await?; + &owned_client + }; let exec_start = std::time::Instant::now(); let result = client.exec_command(request).await; @@ -674,6 +853,15 @@ impl VmManager { } } + let execution_plan = a3s_box_core::resolve_execution(&self.config)?; + self.resolved_execution_plan = Some(execution_plan.clone()); + if execution_plan.backend == ExecutionBackend::Crun { + let boot_start = std::time::Instant::now(); + return self + .boot_sandbox(execution_plan, &boot_span, boot_start) + .await; + } + let boot_start = std::time::Instant::now(); tracing::info!(parent: &boot_span, box_id = %self.box_id, "Booting VM"); @@ -766,6 +954,38 @@ impl VmManager { spec.network = Some(net_config); } + #[cfg(target_os = "macos")] + if spec.network.is_none() + && matches!(self.config.network, a3s_box_core::NetworkMode::Tsi) + && !self.config.port_map.is_empty() + { + let net_config = match self.setup_published_default_network() { + Ok(network) => network, + Err(error) => { + self.cleanup_boot_failure().await; + return Err(error); + } + }; + let ip_cidr = format!("{}/{}", net_config.ip_address, net_config.prefix_len); + spec.entrypoint + .env + .push(("A3S_NET_IP".to_string(), ip_cidr)); + spec.entrypoint.env.push(( + "A3S_NET_GATEWAY".to_string(), + net_config.gateway.to_string(), + )); + spec.entrypoint.env.push(( + "A3S_NET_DNS".to_string(), + net_config + .dns_servers + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + )); + spec.network = Some(net_config); + } + // 3. Initialize VMM provider (use injected provider or default to VmController) if self.provider.is_none() { let shim_path = match VmController::find_shim() { @@ -1056,6 +1276,17 @@ impl VmManager { } drop(state); + if self + .resolved_execution_plan + .as_ref() + .is_some_and(|plan| plan.backend == ExecutionBackend::Crun) + || self.config.isolation.is_sandbox() + { + return Err(BoxError::StateError( + "Pause is not supported by the Sandbox backend yet".to_string(), + )); + } + if let Some(pid) = self.pid().await { // Safety: sending SIGSTOP to pause the process let ret = unsafe { libc::kill(pid as i32, libc::SIGSTOP) }; @@ -1080,6 +1311,16 @@ impl VmManager { /// Can be called on a paused VM to resume execution. #[cfg(unix)] pub async fn resume(&self) -> Result<()> { + if self + .resolved_execution_plan + .as_ref() + .is_some_and(|plan| plan.backend == ExecutionBackend::Crun) + || self.config.isolation.is_sandbox() + { + return Err(BoxError::StateError( + "Resume is not supported by the Sandbox backend yet".to_string(), + )); + } if let Some(pid) = self.pid().await { // Safety: sending SIGCONT to resume the process let ret = unsafe { libc::kill(pid as i32, libc::SIGCONT) }; @@ -1189,6 +1430,16 @@ impl VmManager { &self, update: &crate::resize::ResourceUpdate, ) -> Result { + if self + .resolved_execution_plan + .as_ref() + .is_some_and(|plan| plan.backend == ExecutionBackend::Crun) + || self.config.isolation.is_sandbox() + { + return Err(BoxError::StateError( + "Live resource updates are not supported by the Sandbox backend yet".to_string(), + )); + } // Reject Tier 1 changes upfront crate::resize::validate_update(update)?; @@ -1459,6 +1710,28 @@ mod tests { vm.wait_for_vm_running().await.unwrap(); } + #[tokio::test] + async fn test_try_wait_exit_reads_guest_persisted_exit_code() { + let tmp = tempfile::tempdir().unwrap(); + let box_id = "box-exit-code".to_string(); + let mut vm = + VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone()); + vm.home_dir = tmp.path().to_path_buf(); + + let exit_path = tmp + .path() + .join("boxes") + .join(&box_id) + .join("upper") + .join(".a3s_exit_code"); + std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap(); + std::fs::write(&exit_path, "42\n").unwrap(); + + assert_eq!(vm.try_wait_exit().await.unwrap(), Some(42)); + assert_eq!(vm.exit_code(), Some(42)); + assert!(vm.has_exited().await); + } + #[cfg(unix)] #[tokio::test] async fn test_wait_for_exec_ready_returns_when_handler_already_exited() { @@ -1477,6 +1750,36 @@ mod tests { assert!(vm.exec_client.is_none()); } + #[cfg(unix)] + #[tokio::test] + async fn test_wait_for_exec_ready_returns_when_guest_exit_code_persisted() { + let tmp = tempfile::tempdir().unwrap(); + let box_id = "box-exec-finished".to_string(); + let mut vm = + VmManager::with_box_id(BoxConfig::default(), EventEmitter::new(16), box_id.clone()); + vm.home_dir = tmp.path().to_path_buf(); + + let exit_path = tmp + .path() + .join("boxes") + .join(&box_id) + .join("upper") + .join(".a3s_exit_code"); + std::fs::create_dir_all(exit_path.parent().unwrap()).unwrap(); + std::fs::write(&exit_path, "17\n").unwrap(); + + tokio::time::timeout( + std::time::Duration::from_secs(1), + vm.wait_for_exec_ready(&tmp.path().join("missing-exec.sock")), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(vm.exit_code(), Some(17)); + assert!(vm.exec_client.is_none()); + } + #[cfg(unix)] #[tokio::test] async fn test_probe_exec_ready_once_ignores_missing_socket() { diff --git a/src/runtime/src/vm/network.rs b/src/runtime/src/vm/network.rs index d0168d67..253e5239 100644 --- a/src/runtime/src/vm/network.rs +++ b/src/runtime/src/vm/network.rs @@ -8,6 +8,46 @@ use crate::vmm::NetworkInstanceConfig; use super::VmManager; impl VmManager { + /// Configure an isolated virtio-net backend for a default-network box that + /// publishes host ports on macOS. + /// + /// libkrun's reverse TSI listener can accept TCP while stalling payloads, + /// especially when the client is another TSI guest. A private netproxy + /// instance provides the same outbound connectivity and published-port CLI + /// contract without joining a user-visible bridge network. + #[cfg(target_os = "macos")] + pub(crate) fn setup_published_default_network(&mut self) -> Result { + let ip = std::net::Ipv4Addr::new(10, 89, 0, 2); + let gateway = std::net::Ipv4Addr::new(10, 89, 0, 1); + let prefix_len = 24; + let dns_servers: Vec = if self.config.dns.is_empty() { + vec![std::net::Ipv4Addr::new(8, 8, 8, 8)] + } else { + self.config + .dns + .iter() + .filter_map(|value| value.parse().ok()) + .collect() + }; + let box_dir = self.home_dir.join("boxes").join(&self.box_id); + let mut netproxy = crate::network::NetProxyManager::new(&box_dir); + netproxy.spawn(ip, gateway, prefix_len, &dns_servers, &self.config.port_map)?; + let config = NetworkInstanceConfig { + net_socket_path: netproxy.socket_path().to_path_buf(), + net_stats_path: Some(netproxy.stats_path().to_path_buf()), + net_socket_fd: netproxy.net_socket_fd(), + net_proxy_fd: netproxy.net_proxy_fd(), + bridge_socket_dir: None, + ip_address: ip, + gateway, + prefix_len, + mac_address: [0x02, 0x42, 0x0a, 0x59, 0x00, 0x02], + dns_servers, + }; + self.net_manager = Some(Box::new(netproxy)); + Ok(config) + } + /// Write `/etc/hostname` when a hostname override is configured. pub(crate) fn write_hostname_file(&self, layout: &super::BoxLayout) -> Result<()> { let Some(hostname) = self.config.hostname.as_deref() else { @@ -34,12 +74,34 @@ impl VmManager { let add_hosts = self.parse_add_hosts()?; let aliases = self.hostname_aliases(None); if aliases.is_empty() && add_hosts.is_empty() { - return Ok(()); + return self.ensure_standalone_hosts_readable(layout); } self.write_hosts_content(layout, None, &aliases, &[], &add_hosts) } + fn ensure_standalone_hosts_readable(&self, layout: &super::BoxLayout) -> Result<()> { + let hosts_path = layout.rootfs_path.join("etc/hosts"); + if !hosts_path.exists() { + return self.write_hosts_content(layout, None, &[], &[], &[]); + } + + #[cfg(unix)] + std::fs::set_permissions( + &hosts_path, + ::from_mode(0o644), + ) + .map_err(|e| { + BoxError::NetworkError(format!( + "Failed to set permissions on {}: {}", + hosts_path.display(), + e + )) + })?; + + Ok(()) + } + /// Set up bridge networking by looking up the network, spawning passt, /// and building the NetworkInstanceConfig for the VM spec. #[cfg(any(target_os = "linux", target_os = "macos"))] @@ -131,6 +193,8 @@ impl VmManager { net_socket_fd, #[cfg(target_os = "macos")] net_proxy_fd, + #[cfg(target_os = "macos")] + bridge_socket_dir: Some(macos_bridge_socket_dir(&self.home_dir, network_name)), ip_address: ip, gateway, prefix_len, @@ -230,6 +294,21 @@ impl VmManager { } } +#[cfg(target_os = "macos")] +fn macos_bridge_socket_dir(home: &std::path::Path, network_name: &str) -> std::path::PathBuf { + use sha2::{Digest, Sha256}; + + let mut digest = Sha256::new(); + digest.update(home.as_os_str().as_encoded_bytes()); + digest.update([0]); + digest.update(network_name.as_bytes()); + let key = hex::encode(digest.finalize()); + let uid = unsafe { libc::getuid() }; + std::path::PathBuf::from("/private/tmp/a3s-box-switches") + .join(uid.to_string()) + .join(&key[..24]) +} + /// Parse a MAC address string "02:42:0a:58:00:02" into [u8; 6]. #[cfg(any(target_os = "linux", target_os = "macos", test))] pub(crate) fn parse_mac(mac_str: &str) -> std::result::Result<[u8; 6], String> { @@ -336,6 +415,35 @@ mod tests { assert!(hosts.contains("10.88.0.10 db.local")); } + #[cfg(unix)] + #[test] + fn test_write_standalone_hosts_file_repairs_restrictive_cached_mode() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let layout = test_layout(dir.path().join("rootfs")); + let hosts_path = layout.rootfs_path.join("etc/hosts"); + std::fs::create_dir_all(hosts_path.parent().unwrap()).unwrap(); + std::fs::write(&hosts_path, "127.0.0.1 localhost\n::1 localhost\n").unwrap(); + std::fs::set_permissions(&hosts_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let vm = VmManager::with_box_id( + a3s_box_core::config::BoxConfig::default(), + a3s_box_core::event::EventEmitter::new(16), + "box-id".to_string(), + ); + + vm.write_standalone_hosts_file(&layout).unwrap(); + + assert_eq!( + std::fs::read_to_string(&hosts_path).unwrap(), + "127.0.0.1 localhost\n::1 localhost\n" + ); + assert_eq!( + std::fs::metadata(&hosts_path).unwrap().permissions().mode() & 0o777, + 0o644 + ); + } + #[test] fn test_write_hostname_file() { let dir = TempDir::new().unwrap(); diff --git a/src/runtime/src/vm/ready.rs b/src/runtime/src/vm/ready.rs index decb405b..27c7ae5c 100644 --- a/src/runtime/src/vm/ready.rs +++ b/src/runtime/src/vm/ready.rs @@ -7,6 +7,20 @@ use crate::grpc::ExecClient; use super::VmManager; +const DEFAULT_EXEC_READY_TIMEOUT_MS: u64 = 15_000; +const EXEC_READY_PROGRESS_LOG_MS: u64 = 5_000; + +fn parse_exec_ready_timeout_ms(value: Option<&str>) -> u64 { + value + .and_then(|raw| raw.parse::().ok()) + .filter(|timeout| *timeout > 0) + .unwrap_or(DEFAULT_EXEC_READY_TIMEOUT_MS) +} + +fn exec_ready_timeout_ms() -> u64 { + parse_exec_ready_timeout_ms(std::env::var("A3S_EXEC_READY_TIMEOUT_MS").ok().as_deref()) +} + impl VmManager { /// Confirm the VM didn't fail on launch (for generic OCI images without an agent). /// @@ -83,21 +97,29 @@ impl VmManager { const POLL_INTERVAL: Duration = Duration::from_millis(200); // Last-resort backstop against a wedged-but-alive guest that binds but // never accepts. A healthy guest passes the heartbeat the instant its - // accept loop runs (however late), and an exited VM returns immediately - // below — so this cap is not the expected wait. - const MAX_WAIT_MS: u64 = 120_000; + // accept loop runs, and an exited VM returns immediately below. Keep the + // default short enough that foreground `run` starts streaming the guest's + // logs promptly; callers that truly need a longer cold-boot grace can set + // A3S_EXEC_READY_TIMEOUT_MS. + let max_wait_ms = exec_ready_timeout_ms(); tracing::debug!( socket_path = %exec_socket_path.display(), + timeout_ms = max_wait_ms, "Waiting for exec server readiness" ); let start = std::time::Instant::now(); + let mut next_progress_log_ms = EXEC_READY_PROGRESS_LOG_MS; loop { // Return at once if the VM has already exited (zombie-aware: has_exited // treats a zombie shim as exited, unlike is_running's kill(pid,0)). A // fast-exiting container never stalls here. + if self.try_wait_exit().await?.is_some() { + tracing::debug!("VM exited before exec server became ready"); + return Ok(()); + } if let Some(ref handler) = *self.handler.read().await { if handler.has_exited() { tracing::debug!("VM exited before exec server became ready"); @@ -119,13 +141,26 @@ impl VmManager { } } - if start.elapsed().as_millis() >= MAX_WAIT_MS as u128 { + let elapsed_ms = start.elapsed().as_millis() as u64; + if elapsed_ms >= max_wait_ms { tracing::warn!( - timeout_ms = MAX_WAIT_MS, - "Exec server did not become ready within the safety cap; exec/attach connect on demand and may still succeed once the guest finishes starting" + timeout_ms = max_wait_ms, + elapsed_ms, + socket_path = %exec_socket_path.display(), + "Exec server did not become ready within the safety cap; proceeding so foreground logs and process exit are visible. Exec/attach will connect on demand once the guest finishes starting." ); return Ok(()); } + if elapsed_ms >= next_progress_log_ms { + tracing::warn!( + elapsed_ms, + timeout_ms = max_wait_ms, + socket_path = %exec_socket_path.display(), + "Still waiting for exec server readiness; guest init may be mounting volumes, starting the container, or blocked before its accept loop" + ); + next_progress_log_ms = + next_progress_log_ms.saturating_add(EXEC_READY_PROGRESS_LOG_MS); + } tokio::time::sleep(POLL_INTERVAL).await; } @@ -157,3 +192,25 @@ impl VmManager { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_exec_ready_timeout_ms() { + assert_eq!( + parse_exec_ready_timeout_ms(None), + DEFAULT_EXEC_READY_TIMEOUT_MS + ); + assert_eq!( + parse_exec_ready_timeout_ms(Some("0")), + DEFAULT_EXEC_READY_TIMEOUT_MS + ); + assert_eq!( + parse_exec_ready_timeout_ms(Some("not-a-number")), + DEFAULT_EXEC_READY_TIMEOUT_MS + ); + assert_eq!(parse_exec_ready_timeout_ms(Some("2500")), 2500); + } +} diff --git a/src/runtime/src/vm/reap.rs b/src/runtime/src/vm/reap.rs index 131b0d6c..7b84c281 100644 --- a/src/runtime/src/vm/reap.rs +++ b/src/runtime/src/vm/reap.rs @@ -1,4 +1,4 @@ -//! Crash-recovery reaping of orphaned sandbox microVMs. +//! Crash-recovery reaping of orphaned box runtimes. //! //! A clean shutdown destroys each VM via its in-memory handle (overlay //! unmount + box-dir removal). After a crash (`SIGKILL`, OOM, power loss) the @@ -20,6 +20,58 @@ pub fn reap_orphaned_box(box_id: &str) { reap_orphaned_box_in(&a3s_box_core::dirs_home(), box_id); } +/// Delete a durable Sandbox OCI runtime generation without removing its Box +/// rootfs or persisted CLI state. +/// +/// Callers must run this before unmounting or deleting Box paths: a failed +/// runtime cleanup may mean a shared-kernel process still uses the rootfs. +#[cfg(target_os = "linux")] +pub fn cleanup_recorded_sandbox_runtime(box_dir: &Path, box_id: &str) -> a3s_box_core::Result<()> { + cleanup_recorded_sandbox_runtime_in(&a3s_box_core::dirs_home(), box_dir, box_id) +} + +/// Wait for a naturally exited Sandbox generation to finish projecting both +/// console streams before a caller archives or reads its final logs. +#[cfg(target_os = "linux")] +pub fn wait_for_recorded_sandbox_log_drain( + box_dir: &Path, + box_id: &str, + timeout: std::time::Duration, +) -> a3s_box_core::Result { + let home_dir = a3s_box_core::dirs_home(); + wait_for_recorded_sandbox_log_drain_in(&home_dir, box_dir, box_id, timeout) +} + +#[cfg(target_os = "linux")] +fn wait_for_recorded_sandbox_log_drain_in( + home_dir: &Path, + box_dir: &Path, + box_id: &str, + timeout: std::time::Duration, +) -> a3s_box_core::Result { + // Waiting is read-only: it neither executes the recorded runtime nor + // signals a process. Validate fixed paths and the PID/start-time pair, but + // leave runtime artifact certification to paths that query or execute crun. + let Some(record) = load_recorded_sandbox_runtime_identity(home_dir, box_dir, box_id)? else { + return Ok(true); + }; + Ok(wait_for_log_worker_identity(&record, timeout)) +} + +#[cfg(target_os = "linux")] +pub(crate) fn cleanup_recorded_sandbox_runtime_in( + home_dir: &Path, + box_dir: &Path, + box_id: &str, +) -> a3s_box_core::Result<()> { + match reap_orphaned_crun(home_dir, box_dir, box_id) { + SandboxReap::NotPresent | SandboxReap::Cleaned => Ok(()), + SandboxReap::Failed => Err(a3s_box_core::BoxError::StateError(format!( + "Failed to clean recorded Sandbox runtime for {box_id}; refusing to touch its rootfs" + ))), + } +} + /// [`reap_orphaned_box`] against an explicit home directory (for testing). #[cfg(target_os = "linux")] fn reap_orphaned_box_in(home_dir: &Path, box_id: &str) { @@ -28,6 +80,15 @@ fn reap_orphaned_box_in(home_dir: &Path, box_id: &str) { return; } + match reap_orphaned_crun(home_dir, &box_dir, box_id) { + SandboxReap::NotPresent | SandboxReap::Cleaned => {} + SandboxReap::Failed => { + // A live shared-kernel process may still be using the rootfs. Never + // unmount or delete it after an unverified/failed runtime cleanup. + return; + } + } + let killed = kill_orphaned_shim(box_id); // Wait for the killed shim(s) to actually exit before touching the overlay: // they hold the merged rootfs, so unmounting/removing it while they are @@ -69,6 +130,244 @@ fn reap_orphaned_box_in(home_dir: &Path, box_id: &str) { } } +#[cfg(target_os = "linux")] +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct SandboxRuntimeRecord { + schema: String, + container_id: String, + runtime_path: std::path::PathBuf, + runtime_root: std::path::PathBuf, + bundle_dir: std::path::PathBuf, + init_pid: u32, + #[serde(default)] + log_worker_pid: Option, + #[serde(default)] + log_worker_pid_start_time: Option, +} + +/// Validated durable evidence for one live or stopped Sandbox generation. +#[cfg(target_os = "linux")] +#[derive(Debug)] +pub(crate) struct RecordedSandboxRuntime { + pub(crate) runtime_path: std::path::PathBuf, + pub(crate) runtime_root: std::path::PathBuf, + pub(crate) bundle_dir: std::path::PathBuf, + pub(crate) init_pid: u32, + pub(crate) log_worker_pid: Option, + pub(crate) log_worker_pid_start_time: Option, +} + +/// Load and validate the runtime-owned Sandbox record for one internal box ID. +/// +/// Every persisted path is checked against the expected internal layout and +/// the recorded runtime binary is re-certified before callers may execute it. +#[cfg(target_os = "linux")] +pub(crate) fn load_recorded_sandbox_runtime( + home_dir: &Path, + box_dir: &Path, + box_id: &str, +) -> a3s_box_core::Result> { + let Some(mut record) = load_recorded_sandbox_runtime_identity(home_dir, box_dir, box_id)? + else { + return Ok(None); + }; + let capabilities = crate::sandbox::probe_sandbox_capabilities(Some(&record.runtime_path)); + let runtime = capabilities.runtime.ok_or_else(|| { + a3s_box_core::BoxError::StateError(format!( + "Cannot verify the recorded Sandbox runtime for {box_id}: {:?}", + capabilities.failures + )) + })?; + record.runtime_path = runtime.path; + Ok(Some(record)) +} + +#[cfg(target_os = "linux")] +fn load_recorded_sandbox_runtime_identity( + home_dir: &Path, + box_dir: &Path, + box_id: &str, +) -> a3s_box_core::Result> { + let expected_box_dir = home_dir.join("boxes").join(box_id); + if box_dir != expected_box_dir { + return Err(a3s_box_core::BoxError::StateError(format!( + "Sandbox runtime record has an unexpected host directory for {box_id}" + ))); + } + let record_path = box_dir.join("sandbox/runtime.json"); + let bytes = match std::fs::read(&record_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(a3s_box_core::BoxError::IoError(error)), + }; + let record: SandboxRuntimeRecord = serde_json::from_slice(&bytes).map_err(|error| { + a3s_box_core::BoxError::StateError(format!( + "Invalid Sandbox runtime record at {}: {error}", + record_path.display() + )) + })?; + let expected_runtime_root = home_dir.join("run/crun").join(box_id); + let expected_bundle = box_dir.join("sandbox/bundle"); + let log_worker_identity_valid = match (record.log_worker_pid, record.log_worker_pid_start_time) + { + (None, None) => true, + (Some(pid), Some(start_time)) => pid > 0 && start_time > 0, + _ => false, + }; + if record.schema != "a3s.box.sandbox-runtime.v1" + || record.container_id != box_id + || record.runtime_root != expected_runtime_root + || record.bundle_dir != expected_bundle + || record.init_pid == 0 + || !log_worker_identity_valid + { + return Err(a3s_box_core::BoxError::StateError(format!( + "Sandbox runtime record failed path or identity validation for {box_id}" + ))); + } + + Ok(Some(RecordedSandboxRuntime { + runtime_path: record.runtime_path, + runtime_root: record.runtime_root, + bundle_dir: record.bundle_dir, + init_pid: record.init_pid, + log_worker_pid: record.log_worker_pid, + log_worker_pid_start_time: record.log_worker_pid_start_time, + })) +} + +#[cfg(target_os = "linux")] +enum SandboxReap { + NotPresent, + Cleaned, + Failed, +} + +/// Reconcile a durable `crun` record before touching its rootfs. All paths and +/// the runtime artifact are revalidated; persisted PIDs are diagnostic only +/// and are never signalled directly because PID reuse would make that unsafe. +#[cfg(target_os = "linux")] +fn reap_orphaned_crun(home_dir: &Path, box_dir: &Path, box_id: &str) -> SandboxReap { + use std::process::Command; + + let record_path = box_dir.join("sandbox/runtime.json"); + let record = match load_recorded_sandbox_runtime(home_dir, box_dir, box_id) { + Ok(Some(record)) => record, + Ok(None) => return SandboxReap::NotPresent, + Err(error) => { + tracing::error!(box_id, %error, "Invalid Sandbox runtime record during crash recovery"); + return SandboxReap::Failed; + } + }; + let state = match crate::sandbox::handler::CrunHandler::query_state_at( + &record.runtime_path, + &record.runtime_root, + box_id, + ) { + Ok(state) => state, + Err(error) => { + tracing::error!(box_id, %error, "Failed to query orphaned Sandbox state"); + return SandboxReap::Failed; + } + }; + if state.is_some_and(|state| state.status != "stopped") { + let output = Command::new(&record.runtime_path) + .arg("--root") + .arg(&record.runtime_root) + .arg("kill") + .arg(box_id) + .arg(libc::SIGKILL.to_string()) + .env("LC_ALL", "C") + .output(); + if let Err(error) = output { + tracing::error!(box_id, %error, "Failed to signal orphaned Sandbox"); + return SandboxReap::Failed; + } + } + + let output = Command::new(&record.runtime_path) + .arg("--root") + .arg(&record.runtime_root) + .arg("delete") + .arg("--force") + .arg(box_id) + .env("LC_ALL", "C") + .output(); + match output { + Ok(output) if output.status.success() => {} + Ok(output) => { + match crate::sandbox::handler::CrunHandler::query_state_at( + &record.runtime_path, + &record.runtime_root, + box_id, + ) { + Ok(None) => {} + _ => { + tracing::error!( + box_id, + stderr = %String::from_utf8_lossy(&output.stderr).trim(), + "Failed to delete orphaned Sandbox runtime state" + ); + return SandboxReap::Failed; + } + } + } + Err(error) => { + tracing::error!(box_id, %error, "Failed to start Sandbox cleanup command"); + return SandboxReap::Failed; + } + } + + drain_recorded_log_worker(&record, box_id); + let _ = std::fs::remove_dir_all(&record.bundle_dir); + let _ = std::fs::remove_dir_all(&record.runtime_root); + let _ = std::fs::remove_file(&record_path); + tracing::info!(box_id, "Reaped orphaned crun Sandbox after runtime restart"); + SandboxReap::Cleaned +} + +#[cfg(target_os = "linux")] +fn drain_recorded_log_worker(record: &RecordedSandboxRuntime, box_id: &str) { + let (Some(pid), Some(_start_time)) = (record.log_worker_pid, record.log_worker_pid_start_time) + else { + return; + }; + if wait_for_log_worker_identity(record, std::time::Duration::from_secs(2)) { + return; + } + + tracing::warn!( + box_id, + log_worker_pid = pid, + "Recovered Sandbox log worker did not drain after crun cleanup; terminating it" + ); + if let Ok(pid) = i32::try_from(pid) { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } +} + +#[cfg(target_os = "linux")] +fn wait_for_log_worker_identity( + record: &RecordedSandboxRuntime, + timeout: std::time::Duration, +) -> bool { + let (Some(pid), Some(start_time)) = (record.log_worker_pid, record.log_worker_pid_start_time) + else { + // Runtime records written before the worker fields have no process to + // wait for and retain their legacy raw-console behavior. + return true; + }; + let deadline = std::time::Instant::now() + timeout; + while crate::process::is_process_running_with_identity(pid, Some(start_time)) + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + !crate::process::is_process_running_with_identity(pid, Some(start_time)) +} + /// Poll until every pid in `pids` has exited, or `timeout` elapses. #[cfg(target_os = "linux")] fn wait_for_exit(pids: &[i32], timeout: std::time::Duration) { @@ -93,6 +392,32 @@ fn wait_for_exit(pids: &[i32], timeout: std::time::Duration) { #[cfg(not(target_os = "linux"))] pub fn reap_orphaned_box(_box_id: &str) {} +#[cfg(not(target_os = "linux"))] +pub fn cleanup_recorded_sandbox_runtime( + _box_dir: &std::path::Path, + _box_id: &str, +) -> a3s_box_core::Result<()> { + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn wait_for_recorded_sandbox_log_drain( + _box_dir: &std::path::Path, + _box_id: &str, + _timeout: std::time::Duration, +) -> a3s_box_core::Result { + Ok(true) +} + +#[cfg(not(target_os = "linux"))] +pub(crate) fn cleanup_recorded_sandbox_runtime_in( + _home_dir: &std::path::Path, + _box_dir: &std::path::Path, + _box_id: &str, +) -> a3s_box_core::Result<()> { + Ok(()) +} + #[cfg(all(test, not(target_os = "linux")))] mod tests { use super::*; @@ -142,6 +467,31 @@ fn kill_orphaned_shim(box_id: &str) -> Vec { mod tests { use super::*; + fn write_runtime_record( + home_dir: &Path, + box_dir: &Path, + box_id: &str, + mutate: impl FnOnce(&mut SandboxRuntimeRecord), + ) { + let mut record = SandboxRuntimeRecord { + schema: "a3s.box.sandbox-runtime.v1".to_string(), + container_id: box_id.to_string(), + runtime_path: Path::new("/definitely/missing/certified-crun").to_path_buf(), + runtime_root: home_dir.join("run/crun").join(box_id), + bundle_dir: box_dir.join("sandbox/bundle"), + init_pid: 42, + log_worker_pid: None, + log_worker_pid_start_time: None, + }; + mutate(&mut record); + std::fs::create_dir_all(box_dir.join("sandbox")).unwrap(); + std::fs::write( + box_dir.join("sandbox/runtime.json"), + serde_json::to_vec(&record).unwrap(), + ) + .unwrap(); + } + #[test] fn test_reap_removes_box_dir() { // A box dir with no live shim / mount (e.g. left by a crash) is removed. @@ -162,4 +512,65 @@ mod tests { // No boxes/ dir at all — must not panic or error. reap_orphaned_box_in(home.path(), "absent-box-uuid"); } + + #[test] + fn cleanup_absent_sandbox_runtime_preserves_box_directory() { + let home = tempfile::tempdir().unwrap(); + let box_id = "cleanup-test-no-runtime-record"; + let box_dir = home.path().join("boxes").join(box_id); + std::fs::create_dir_all(&box_dir).unwrap(); + + cleanup_recorded_sandbox_runtime_in(home.path(), &box_dir, box_id).unwrap(); + + assert!(box_dir.exists()); + } + + #[test] + fn recorded_sandbox_runtime_rejects_an_unexpected_box_directory() { + let home = tempfile::tempdir().unwrap(); + let box_id = "recorded-sandbox-unexpected-directory"; + let box_dir = home.path().join("external").join(box_id); + write_runtime_record(home.path(), &box_dir, box_id, |_| {}); + + let error = load_recorded_sandbox_runtime(home.path(), &box_dir, box_id).unwrap_err(); + + assert!(error.to_string().contains("unexpected host directory")); + } + + #[test] + fn recorded_sandbox_runtime_rejects_invalid_paths_before_certification() { + let home = tempfile::tempdir().unwrap(); + let box_id = "recorded-sandbox-invalid-paths"; + let box_dir = home.path().join("boxes").join(box_id); + write_runtime_record(home.path(), &box_dir, box_id, |record| { + record.runtime_root = home.path().join("run/crun/another-box"); + }); + + let error = load_recorded_sandbox_runtime(home.path(), &box_dir, box_id).unwrap_err(); + let message = error.to_string(); + + assert!(message.contains("path or identity validation")); + assert!(!message.contains("Cannot verify the recorded Sandbox runtime")); + } + + #[test] + fn log_drain_wait_validates_identity_without_recertifying_crun() { + let home = tempfile::tempdir().unwrap(); + let box_id = "recorded-sandbox-log-drain"; + let box_dir = home.path().join("boxes").join(box_id); + write_runtime_record(home.path(), &box_dir, box_id, |_| {}); + + assert!(wait_for_recorded_sandbox_log_drain_in( + home.path(), + &box_dir, + box_id, + std::time::Duration::ZERO, + ) + .unwrap()); + + let error = load_recorded_sandbox_runtime(home.path(), &box_dir, box_id).unwrap_err(); + assert!(error + .to_string() + .contains("Cannot verify the recorded Sandbox runtime")); + } } diff --git a/src/runtime/src/vm/sandbox.rs b/src/runtime/src/vm/sandbox.rs new file mode 100644 index 00000000..2fe4f252 --- /dev/null +++ b/src/runtime/src/vm/sandbox.rs @@ -0,0 +1,698 @@ +//! `crun` Sandbox boot path. + +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; + +use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::event::BoxEvent; +use a3s_box_core::execution::ResolvedExecutionPlan; +use base64::Engine; +use sha2::{Digest, Sha256}; + +use crate::sandbox::{ + compile_oci_spec, inspect_rootfs_identity_requirements, plan_id_mappings, + prepare_crun_path_access, prepare_managed_mount_source, prepare_rootfs_ownership, + probe_sandbox_capabilities, validate_external_mount_access, write_bundle, CrunController, + SandboxBundleSpec, SandboxLaunchSpec, SandboxMount, SandboxResources, SandboxTmpfs, +}; + +use super::{BoxState, VmManager}; + +impl VmManager { + pub(super) async fn boot_sandbox( + &mut self, + execution_plan: ResolvedExecutionPlan, + boot_span: &tracing::Span, + boot_start: std::time::Instant, + ) -> Result<()> { + // This probe is deliberately before image pulls, rootfs mounts, volume + // creation, or bundle writes. Every mandatory control is fail-closed. + let capability_start = std::time::Instant::now(); + let capabilities = probe_sandbox_capabilities(None); + capabilities.require_ready()?; + let runtime = capabilities + .runtime + .clone() + .ok_or_else(|| BoxError::BoxBootError { + message: "Sandbox capability probe did not return a certified crun artifact" + .to_string(), + hint: None, + })?; + // Sandbox logging is hosted by the packaged shim in a dedicated worker + // mode so it survives detached CLI clients. Resolve it before image or + // rootfs preparation to keep a missing artifact side-effect free. + let log_worker_path = crate::vmm::VmController::find_shim()?; + let user_namespace = + capabilities + .user_namespace + .as_ref() + .ok_or_else(|| BoxError::BoxBootError { + message: "Sandbox capability probe did not return user-namespace evidence" + .to_string(), + hint: None, + })?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.capability", + capability_start.elapsed(), + ); + + let box_dir = self.home_dir.join("boxes").join(&self.box_id); + let sandbox_dir = box_dir.join("sandbox"); + let bundle_dir = sandbox_dir.join("bundle"); + let runtime_root = self.home_dir.join("run").join("crun").join(&self.box_id); + let runtime_record = sandbox_dir.join("runtime.json"); + let controller = CrunController::new(runtime.clone()); + controller.require_absent(&runtime_root, &self.box_id)?; + + tracing::info!( + parent: boot_span, + box_id = %self.box_id, + isolation_class = "shared-kernel", + runtime = %runtime.path.display(), + "Booting Sandbox" + ); + + let layout_start = std::time::Instant::now(); + let layout = match self.prepare_layout().await { + Ok(layout) => layout, + Err(error) => { + self.cleanup_boot_failure().await; + return Err(error); + } + }; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.layout", + layout_start.elapsed(), + ); + self.image_config = layout.oci_config.clone(); + + let prepare = (|| -> Result<_> { + let instance_prepare_start = std::time::Instant::now(); + let resolv_content = a3s_box_core::dns::generate_resolv_conf(&self.config.dns); + std::fs::write(layout.rootfs_path.join("etc/resolv.conf"), resolv_content) + .map_err(BoxError::IoError)?; + self.write_hostname_file(&layout)?; + self.write_standalone_hosts_file(&layout)?; + + let instance_spec = self.build_instance_spec(&layout)?; + if !matches!( + instance_spec.entrypoint.executable.as_str(), + "/sbin/init" | "/usr/sbin/init" + ) || !instance_spec + .entrypoint + .env + .iter() + .any(|(key, _)| key == "BOX_EXEC_EXEC") + { + return Err(BoxError::BoxBootError { + message: "Sandbox requires the packaged a3s-box guest init as OCI PID 1" + .to_string(), + hint: Some("Install the matching a3s-box-guest-init artifact".to_string()), + }); + } + + let (mounts, tmpfs) = self.compile_sandbox_mounts(&layout, &instance_spec)?; + ensure_mount_destinations(&layout.rootfs_path, &mounts, &tmpfs)?; + + let rootfs_ids = inspect_rootfs_identity_requirements(&layout.rootfs_path)?; + let (account_uid, account_gid) = maximum_account_ids(&layout.rootfs_path)?; + let (process_uid, process_gid) = maximum_process_ids(&instance_spec.entrypoint.env)?; + let maximum_uid = rootfs_ids.maximum_uid.max(account_uid).max(process_uid); + let maximum_gid = rootfs_ids.maximum_gid.max(account_gid).max(process_gid); + let id_mappings = plan_id_mappings(user_namespace, maximum_uid, maximum_gid)?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.instance_prepare", + instance_prepare_start.elapsed(), + ); + + let mount_sources_start = std::time::Instant::now(); + self.prepare_sandbox_mount_sources(&layout, &mounts, &id_mappings)?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.mount_sources", + mount_sources_start.elapsed(), + ); + let rootfs_ownership_start = std::time::Instant::now(); + prepare_rootfs_ownership( + &layout.rootfs_path, + &id_mappings, + user_namespace.effective_uid, + self.config.read_only, + )?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.rootfs_ownership", + rootfs_ownership_start.elapsed(), + ); + + let bundle_start = std::time::Instant::now(); + let resources = SandboxResources::from_box_config(&self.config)?; + let execution_plan_digest = digest_json(&execution_plan)?; + let bundle_spec = SandboxBundleSpec { + box_id: self.box_id.clone(), + rootfs_path: layout.rootfs_path.clone(), + rootfs_read_only: self.config.read_only, + hostname: self + .config + .hostname + .clone() + .unwrap_or_else(|| self.box_id.clone()), + init_environment: instance_spec.entrypoint.env.clone(), + mounts, + tmpfs, + id_mappings, + resources, + requested_capabilities: self.config.cap_add.clone(), + execution_plan_digest, + runtime_digest: format!("sha256:{}", runtime.sha256), + }; + let oci_spec = compile_oci_spec(&bundle_spec)?; + write_bundle(&bundle_dir, &oci_spec, &execution_plan, &capabilities)?; + prepare_crun_path_access( + &self.home_dir, + &self.box_id, + &bundle_dir, + &layout.rootfs_path, + &bundle_spec.id_mappings, + )?; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.bundle", + bundle_start.elapsed(), + ); + + Ok((instance_spec, bundle_spec)) + })(); + + let (instance_spec, _bundle_spec) = match prepare { + Ok(value) => value, + Err(error) => { + self.cleanup_boot_failure().await; + return Err(error); + } + }; + + let console_output = instance_spec + .console_output + .clone() + .unwrap_or_else(|| box_dir.join("logs").join("console.log")); + let launch = SandboxLaunchSpec { + container_id: self.box_id.clone(), + bundle_dir, + runtime_root, + runtime_record, + exec_socket_path: layout.exec_socket_path.clone(), + pty_socket_path: layout.pty_socket_path.clone(), + stdout_path: console_output.clone(), + stderr_path: a3s_box_core::log::stderr_console_path(&console_output), + init_log_path: box_dir.join("logs").join("sandbox-init.log"), + log_config: self.log_config.clone(), + log_worker_path, + log_worker_log_path: box_dir.join("logs").join("sandbox-log-worker.log"), + log_worker_ready_path: sandbox_dir.join("bundle").join("log-worker.ready"), + }; + let launch_start = std::time::Instant::now(); + let handler = match controller.start(launch).await { + Ok(handler) => handler, + Err(error) => { + self.cleanup_boot_failure().await; + return Err(error); + } + }; + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.launch", + launch_start.elapsed(), + ); + *self.handler.write().await = Some(Box::new(handler)); + + let readiness_start = std::time::Instant::now(); + if let Err(error) = async { + // CrunController::start only returns after the certified runtime + // reports this exact generation as running. The generic VM grace + // period would merely recheck process liveness for a fixed 250 ms; + // the heartbeat path below already checks liveness on every + // attempt and returns immediately for a naturally exited one-shot. + #[cfg(unix)] + self.wait_for_exec_ready(&layout.exec_socket_path).await?; + Ok(()) + } + .await + { + self.cleanup_boot_failure().await; + return Err(error); + } + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.readiness", + readiness_start.elapsed(), + ); + + self.exec_socket_path = Some(layout.exec_socket_path); + self.pty_socket_path = Some(layout.pty_socket_path); + // Port publishing is intentionally rejected for Sandbox. Keep no stale + // VM port-forward path in the public manager state. + self.port_forward_socket_path = None; + *self.state.write().await = BoxState::Ready; + + if let Some(ref prom) = self.prom { + prom.vm_boot_duration + .observe(boot_start.elapsed().as_secs_f64()); + prom.vm_created_total.inc(); + prom.vm_count.with_label_values(&["ready"]).inc(); + } + self.event_emitter.emit(BoxEvent::empty("box.ready")); + tracing::info!( + parent: boot_span, + box_id = %self.box_id, + "Sandbox ready" + ); + a3s_box_core::lifecycle_profile::record_lifecycle_phase( + "sandbox.start_total", + boot_start.elapsed(), + ); + Ok(()) + } + + fn compile_sandbox_mounts( + &self, + layout: &super::BoxLayout, + instance_spec: &crate::vmm::InstanceSpec, + ) -> Result<(Vec, Vec)> { + let mut mounts = Vec::new(); + let mut user_destinations = HashSet::new(); + for volume in &self.config.volumes { + let mount = parse_sandbox_volume(volume)?; + user_destinations.insert(mount.destination.clone()); + mounts.push(mount); + } + if !user_destinations.contains(Path::new("/workspace")) { + mounts.insert( + 0, + SandboxMount { + source: layout.workspace_path.clone(), + destination: PathBuf::from("/workspace"), + read_only: false, + }, + ); + } + + if let Some(image) = layout.oci_config.as_ref() { + let mut anonymous_index = self.config.volumes.len(); + for destination in &image.volumes { + let destination = normalized_container_path(destination, "volume destination")?; + if user_destinations.contains(&destination) { + continue; + } + let tag = format!("vol{anonymous_index}"); + let source = instance_spec + .fs_mounts + .iter() + .find(|mount| mount.tag == tag) + .ok_or_else(|| BoxError::BoxBootError { + message: format!( + "Required Sandbox anonymous volume {tag} was not materialized" + ), + hint: None, + })? + .host_path + .canonicalize() + .map_err(BoxError::IoError)?; + mounts.push(SandboxMount { + source, + destination, + read_only: false, + }); + anonymous_index += 1; + } + } + + let mut tmpfs = Vec::with_capacity(self.config.tmpfs.len()); + for value in &self.config.tmpfs { + tmpfs.push(parse_sandbox_tmpfs(value)?); + } + Ok((mounts, tmpfs)) + } + + fn prepare_sandbox_mount_sources( + &self, + layout: &super::BoxLayout, + mounts: &[SandboxMount], + id_mappings: &crate::sandbox::SandboxIdMappingPlan, + ) -> Result<()> { + let managed = self.managed_sandbox_mount_sources(&layout.workspace_path, mounts)?; + + for mount in mounts { + if managed.contains(&mount.source) { + prepare_managed_mount_source(&mount.source, id_mappings)?; + } else { + validate_external_mount_access(&mount.source, id_mappings, mount.read_only)?; + } + } + Ok(()) + } + + fn managed_sandbox_mount_sources( + &self, + workspace_path: &Path, + mounts: &[SandboxMount], + ) -> Result> { + let mut managed = HashSet::new(); + if self.config.workspace.as_os_str().is_empty() { + managed.insert(workspace_path.to_path_buf()); + } + let volume_store = crate::volume::VolumeStore::new( + self.home_dir.join("volumes.json"), + self.home_dir.join("volumes"), + ); + let volumes = volume_store.load()?; + for name in &self.anonymous_volumes { + let volume = volumes.get(name).ok_or_else(|| BoxError::BoxBootError { + message: format!("Sandbox anonymous volume {name} disappeared during boot"), + hint: None, + })?; + managed.insert( + PathBuf::from(&volume.mount_point) + .canonicalize() + .map_err(BoxError::IoError)?, + ); + } + + // Named volumes are resolved to host paths before VmManager boots, so + // their names are not present in BoxConfig. Match only mount roots that + // are registered in A3S's volume store; arbitrary bind mounts remain + // external and are never chowned implicitly. + for volume in volumes.values() { + let Ok(source) = PathBuf::from(&volume.mount_point).canonicalize() else { + // A stale, unused volume entry must not prevent unrelated boxes + // from starting. A mounted missing path already fails while the + // Sandbox volume specification is canonicalized. + continue; + }; + if mounts.iter().any(|mount| mount.source == source) { + managed.insert(source); + } + } + + Ok(managed) + } +} + +fn parse_sandbox_volume(value: &str) -> Result { + let (without_mode, read_only) = match value.rsplit_once(':') { + Some((prefix, "ro")) => (prefix, true), + Some((prefix, "rw")) => (prefix, false), + _ => (value, false), + }; + let (source, destination) = without_mode.rsplit_once(':').ok_or_else(|| { + BoxError::ConfigError(format!( + "Invalid Sandbox volume {value:?}; expected host:guest[:ro|rw]" + )) + })?; + if source.is_empty() { + return Err(BoxError::ConfigError(format!( + "Sandbox volume source is empty: {value:?}" + ))); + } + let source = PathBuf::from(source); + if !source.exists() { + std::fs::create_dir_all(&source).map_err(BoxError::IoError)?; + } + let source = source.canonicalize().map_err(BoxError::IoError)?; + let destination = normalized_container_path(destination, "volume destination")?; + Ok(SandboxMount { + source, + destination, + read_only, + }) +} + +fn parse_sandbox_tmpfs(value: &str) -> Result { + const DEFAULT_SIZE: u64 = 64 * 1024 * 1024; + let (destination, options) = value + .split_once(':') + .map_or((value, None), |(path, options)| (path, Some(options))); + let size_bytes = match options { + None | Some("") => DEFAULT_SIZE, + Some(option) => option + .strip_prefix("size=") + .ok_or_else(|| { + BoxError::ConfigError(format!( + "Invalid Sandbox tmpfs option {option:?}; only size= is supported" + )) + }) + .and_then(parse_byte_size)?, + }; + Ok(SandboxTmpfs { + destination: normalized_container_path(destination, "tmpfs destination")?, + size_bytes, + }) +} + +fn parse_byte_size(value: &str) -> Result { + let value = value.trim(); + let split = value + .find(|character: char| !character.is_ascii_digit()) + .unwrap_or(value.len()); + let number = value[..split] + .parse::() + .map_err(|_| BoxError::ConfigError(format!("Invalid Sandbox tmpfs size {value:?}")))?; + let multiplier = match value[split..].to_ascii_lowercase().as_str() { + "" | "b" => 1, + "k" | "kb" | "kib" => 1024, + "m" | "mb" | "mib" => 1024 * 1024, + "g" | "gb" | "gib" => 1024 * 1024 * 1024, + _ => { + return Err(BoxError::ConfigError(format!( + "Invalid Sandbox tmpfs size suffix in {value:?}" + ))) + } + }; + number + .checked_mul(multiplier) + .filter(|size| *size > 0) + .ok_or_else(|| { + BoxError::ConfigError(format!( + "Sandbox tmpfs size overflows or is zero: {value:?}" + )) + }) +} + +fn normalized_container_path(value: &str, label: &str) -> Result { + let path = PathBuf::from(value); + if !path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::CurDir | Component::ParentDir | Component::Prefix(_) + ) + }) + { + return Err(BoxError::ConfigError(format!( + "Sandbox {label} must be an absolute normalized path: {value:?}" + ))); + } + Ok(path) +} + +fn ensure_mount_destinations( + rootfs: &Path, + mounts: &[SandboxMount], + tmpfs: &[SandboxTmpfs], +) -> Result<()> { + for mount in mounts { + ensure_mount_destination(rootfs, &mount.destination, mount.source.is_file())?; + } + for mount in tmpfs { + ensure_mount_destination(rootfs, &mount.destination, false)?; + } + Ok(()) +} + +fn ensure_mount_destination(rootfs: &Path, destination: &Path, file: bool) -> Result<()> { + let relative = destination.strip_prefix("/").map_err(|_| { + BoxError::ConfigError(format!( + "Sandbox mount destination is not absolute: {}", + destination.display() + )) + })?; + let mut current = rootfs.to_path_buf(); + let components: Vec<_> = relative.components().collect(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(name) = component else { + return Err(BoxError::ConfigError(format!( + "Invalid Sandbox mount destination {}", + destination.display() + ))); + }; + current.push(name); + let final_component = index + 1 == components.len(); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(BoxError::ConfigError(format!( + "Sandbox mount destination traverses a symlink at {}", + current.display() + ))) + } + Ok(metadata) if final_component && file && !metadata.is_file() => { + return Err(BoxError::ConfigError(format!( + "Sandbox file mount destination is not a file: {}", + current.display() + ))) + } + Ok(metadata) if (!final_component || !file) && !metadata.is_dir() => { + return Err(BoxError::ConfigError(format!( + "Sandbox directory mount destination is not a directory: {}", + current.display() + ))) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if final_component && file { + std::fs::File::create(¤t).map_err(BoxError::IoError)?; + } else { + std::fs::create_dir(¤t).map_err(BoxError::IoError)?; + } + } + Err(error) => return Err(BoxError::IoError(error)), + } + } + Ok(()) +} + +fn maximum_account_ids(rootfs: &Path) -> Result<(u32, u32)> { + let mut maximum_uid = 0u32; + let mut maximum_gid = 0u32; + if let Ok(passwd) = std::fs::read_to_string(rootfs.join("etc/passwd")) { + for line in passwd.lines().filter(|line| !line.starts_with('#')) { + let fields: Vec<_> = line.split(':').collect(); + if fields.len() >= 4 { + if let Ok(uid) = fields[2].parse::() { + maximum_uid = maximum_uid.max(uid); + } + if let Ok(gid) = fields[3].parse::() { + maximum_gid = maximum_gid.max(gid); + } + } + } + } + if let Ok(group) = std::fs::read_to_string(rootfs.join("etc/group")) { + for line in group.lines().filter(|line| !line.starts_with('#')) { + if let Some(Ok(gid)) = line.split(':').nth(2).map(str::parse::) { + maximum_gid = maximum_gid.max(gid); + } + } + } + Ok((maximum_uid, maximum_gid)) +} + +fn maximum_process_ids(environment: &[(String, String)]) -> Result<(u32, u32)> { + let Some(encoded) = environment + .iter() + .find_map(|(key, value)| (key == "BOX_EXEC_USER").then_some(value)) + else { + return Ok((0, 0)); + }; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|error| BoxError::ConfigError(format!("Invalid encoded Sandbox user: {error}")))?; + let user = String::from_utf8(bytes) + .map_err(|error| BoxError::ConfigError(format!("Sandbox user is not UTF-8: {error}")))?; + let mut parts = user.split(':'); + let parse_numeric = |value: &str| -> Result { + if value == "root" { + Ok(0) + } else { + value.parse::().map_err(|_| { + BoxError::ConfigError(format!( + "Sandbox group in {user:?} must be numeric before OCI launch" + )) + }) + } + }; + let user_part = parts.next().unwrap_or_default(); + // Named users are resolved by guest-init from /etc/passwd. All passwd and + // group IDs were already included by maximum_account_ids above. + let uid = if user_part == "root" { + 0 + } else { + user_part.parse::().unwrap_or(0) + }; + let gid = parts.next().map(parse_numeric).transpose()?.unwrap_or(0); + if parts.next().is_some() { + return Err(BoxError::ConfigError(format!( + "Invalid Sandbox user {user:?}" + ))); + } + Ok((uid, gid)) +} + +fn digest_json(value: &impl serde::Serialize) -> Result { + let bytes = serde_json::to_vec(value).map_err(|error| { + BoxError::SerializationError(format!("Failed to encode execution plan: {error}")) + })?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} + +#[cfg(test)] +mod tests { + use a3s_box_core::{volume::VolumeConfig, BoxConfig, EventEmitter}; + + use super::*; + + #[test] + fn parses_volume_and_tmpfs_without_shell_interpretation() { + let directory = tempfile::tempdir().unwrap(); + let value = format!("{}:/work:ro", directory.path().display()); + let mount = parse_sandbox_volume(&value).unwrap(); + assert_eq!(mount.destination, Path::new("/work")); + assert!(mount.read_only); + + let tmpfs = parse_sandbox_tmpfs("/scratch:size=128m").unwrap(); + assert_eq!(tmpfs.size_bytes, 128 * 1024 * 1024); + } + + #[test] + fn mount_destination_rejects_symlink_parent() { + let rootfs = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink("/", rootfs.path().join("escape")).unwrap(); + let error = + ensure_mount_destination(rootfs.path(), Path::new("/escape/host"), false).unwrap_err(); + assert!(error.to_string().contains("symlink")); + } + + #[test] + fn named_volume_mounts_are_classified_as_a3s_managed() { + let home = tempfile::tempdir().unwrap(); + let external = tempfile::tempdir().unwrap(); + let store = crate::volume::VolumeStore::new( + home.path().join("volumes.json"), + home.path().join("volumes"), + ); + let volume = store.create(VolumeConfig::new("sandbox-data", "")).unwrap(); + let named_source = PathBuf::from(volume.mount_point).canonicalize().unwrap(); + let external_source = external.path().canonicalize().unwrap(); + let mounts = vec![ + SandboxMount { + source: named_source.clone(), + destination: PathBuf::from("/data"), + read_only: false, + }, + SandboxMount { + source: external_source.clone(), + destination: PathBuf::from("/external"), + read_only: false, + }, + ]; + let mut manager = VmManager::with_box_id( + BoxConfig::default(), + EventEmitter::new(16), + "sandbox-managed-volume-test".to_string(), + ); + manager.home_dir = home.path().to_path_buf(); + let workspace = home.path().join("boxes/test/workspace"); + + let managed = manager + .managed_sandbox_mount_sources(&workspace, &mounts) + .unwrap(); + + assert!(managed.contains(&workspace)); + assert!(managed.contains(&named_source)); + assert!(!managed.contains(&external_source)); + } +} diff --git a/src/runtime/src/vm/spec.rs b/src/runtime/src/vm/spec.rs index 60ed42f0..63ab9f00 100644 --- a/src/runtime/src/vm/spec.rs +++ b/src/runtime/src/vm/spec.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use a3s_box_core::config::TeeConfig; use a3s_box_core::error::{BoxError, Result}; +use a3s_box_core::rootfs_metadata::RUNTIME_ENV_PATH; use crate::oci::OciImageConfig; use crate::rootfs::GUEST_WORKDIR; @@ -51,6 +52,9 @@ impl VmManager { .filter_map(|v| v.split(':').nth(1).map(String::from)) .collect(); let mut anon_vol_offset = self.config.volumes.len(); + let mut seen_anonymous_volumes = std::collections::HashSet::new(); + self.anonymous_volumes + .retain(|name| seen_anonymous_volumes.insert(name.clone())); if let Some(ref oci_config) = layout.oci_config { for vol_path in &oci_config.volumes { @@ -77,7 +81,9 @@ impl VmManager { host_path: PathBuf::from(&host_path), read_only: false, }); - self.anonymous_volumes.push(anon_name.clone()); + if seen_anonymous_volumes.insert(anon_name.clone()) { + self.anonymous_volumes.push(anon_name.clone()); + } if created { self.created_anonymous_volumes.push(anon_name); } @@ -90,6 +96,14 @@ impl VmManager { ); } Err(e) => { + if self.config.isolation.is_sandbox() { + return Err(BoxError::BoxBootError { + message: format!( + "Failed to create required Sandbox anonymous volume for {vol_path}: {e}" + ), + hint: None, + }); + } tracing::warn!( path = vol_path, error = %e, @@ -123,14 +137,13 @@ impl VmManager { ); (exec, args, oci_config.env.clone()) } - None => ( - "/bin/sh".to_string(), - vec![ - "-c".to_string(), - "echo No command specified; exec /bin/sh".to_string(), - ], - vec![], - ), + None => { + let (exec, args) = Self::resolve_config_entrypoint( + &self.config.cmd, + self.config.entrypoint_override.as_deref(), + ); + (exec, args, vec![]) + } }; a3s_box_core::env::merge_env_pairs(&mut container_env, &self.config.extra_env); @@ -173,6 +186,17 @@ impl VmManager { if let Some(user) = &user { env.push(("BOX_EXEC_USER".to_string(), b64(user))); } + if !self.config.stdin_open { + env.push(("BOX_EXEC_STDIN".to_string(), "null".to_string())); + } + if let Some(cache_mode) = self + .config + .virtiofs_cache + .clone() + .or_else(|| env_nonempty("A3S_VIRTIOFS_CACHE")) + { + env.push(("A3S_VIRTIOFS_CACHE".to_string(), cache_mode)); + } // Container environment variables. Values are base64-encoded like the // rest (so `"`/spaces/etc. survive); the key stays raw (env names are a @@ -188,7 +212,9 @@ impl VmManager { .map(|(key, value)| format!("{}={}\n", key, b64(value))) .collect(); if !env_file_body.is_empty() { - let host_path = layout.rootfs_path.join(".a3s-box-env"); + let host_path = layout + .rootfs_path + .join(RUNTIME_ENV_PATH.trim_start_matches('/')); std::fs::write(&host_path, env_file_body).map_err(|e| BoxError::BoxBootError { message: format!( "failed to stage guest env file {}: {}", @@ -197,7 +223,22 @@ impl VmManager { ), hint: None, })?; - env.push(("BOX_EXEC_ENV_FILE".to_string(), "/.a3s-box-env".to_string())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&host_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| BoxError::BoxBootError { + message: format!( + "failed to secure guest env file {}: {error}", + host_path.display() + ), + hint: None, + })?; + } + env.push(( + "BOX_EXEC_ENV_FILE".to_string(), + RUNTIME_ENV_PATH.to_string(), + )); } // Pass user volume mounts to guest init for mounting inside the VM. @@ -360,14 +401,18 @@ impl VmManager { env, } } - None => Entrypoint { - executable: "/bin/sh".to_string(), - args: vec![ - "-c".to_string(), - "echo No command specified; exec /bin/sh".to_string(), - ], - env: self.config.extra_env.clone(), - }, + None => { + let (executable, args) = Self::resolve_config_entrypoint( + &self.config.cmd, + self.config.entrypoint_override.as_deref(), + ); + + Entrypoint { + executable, + args, + env: self.config.extra_env.clone(), + } + } } }; @@ -392,6 +437,12 @@ impl VmManager { .env .push(("BOX_CRI_PORT_FWD".to_string(), "1".to_string())); + if self.config.persistent { + entrypoint + .env + .push(("BOX_PERSIST_ROOTFS_METADATA".to_string(), "1".to_string())); + } + // Inject sidecar configuration so guest-init can launch the sidecar process if let Some(ref sidecar) = self.config.sidecar { entrypoint @@ -509,16 +560,43 @@ impl VmManager { (exec, args) } else { // Neither set: fall back to /bin/sh (universal across all Linux distros) - ( - "/bin/sh".to_string(), - vec![ - "-c".to_string(), - "echo No command specified; exec /bin/sh".to_string(), - ], - ) + Self::default_entrypoint() } } + /// Resolve an entrypoint from the box config alone. + /// + /// Snapshot restores can mount a prepared rootfs without an OCI config file, + /// but the CLI record still preserves the original ENTRYPOINT/CMD. Keep the + /// same Docker ordering here: entrypoint args first, then CMD. + fn resolve_config_entrypoint( + cmd: &[String], + entrypoint_override: Option<&[String]>, + ) -> (String, Vec) { + if let Some(entrypoint) = entrypoint_override.filter(|entrypoint| !entrypoint.is_empty()) { + let exec = entrypoint[0].clone(); + let mut args: Vec = entrypoint.iter().skip(1).cloned().collect(); + args.extend(cmd.iter().cloned()); + (exec, args) + } else if !cmd.is_empty() { + let exec = cmd[0].clone(); + let args: Vec = cmd.iter().skip(1).cloned().collect(); + (exec, args) + } else { + Self::default_entrypoint() + } + } + + fn default_entrypoint() -> (String, Vec) { + ( + "/bin/sh".to_string(), + vec![ + "-c".to_string(), + "echo No command specified; exec /bin/sh".to_string(), + ], + ) + } + fn guest_init_exec_path(rootfs_path: &Path) -> Option<&'static str> { let sbin_init = rootfs_path.join("sbin").join("init"); if sbin_init.exists() { @@ -890,6 +968,34 @@ mod tests { .map(|(_, v)| v.as_str()) } + #[test] + fn test_build_instance_spec_passes_configured_virtiofs_cache_mode() { + let dir = tempdir().unwrap(); + let layout = test_layout(dir.path(), Some(test_oci_config(None, None)), true); + let mut vm = test_vm_manager(BoxConfig { + virtiofs_cache: Some("always".to_string()), + ..Default::default() + }); + + let spec = vm.build_instance_spec(&layout).unwrap(); + + assert_eq!(env_value(&spec, "A3S_VIRTIOFS_CACHE"), Some("always")); + } + + #[test] + fn test_persistent_box_requests_terminal_rootfs_metadata() { + let dir = tempdir().unwrap(); + let layout = test_layout(dir.path(), Some(test_oci_config(None, None)), true); + let mut vm = test_vm_manager(BoxConfig { + persistent: true, + ..Default::default() + }); + + let spec = vm.build_instance_spec(&layout).unwrap(); + + assert_eq!(env_value(&spec, "BOX_PERSIST_ROOTFS_METADATA"), Some("1")); + } + #[test] fn test_run_path_plumbs_cpu_cgroup_limits_to_guest() { // The `run` boot path must hand the CPU cgroup limits to guest-init as @@ -1205,6 +1311,60 @@ mod tests { assert_eq!(VmManager::guest_init_exec_path(rootfs), Some("/sbin/init")); } + #[test] + fn test_build_instance_spec_restored_rootfs_uses_saved_cmd_with_guest_init() { + let dir = tempdir().unwrap(); + let layout = test_layout(dir.path(), None, true); + let mut vm = test_vm_manager(BoxConfig { + cmd: vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 3600".to_string(), + ], + ..Default::default() + }); + + let spec = vm.build_instance_spec(&layout).unwrap(); + + assert_eq!(spec.entrypoint.executable, "/sbin/init"); + assert_eq!( + env_value(&spec, "BOX_EXEC_EXEC").map(b64d).as_deref(), + Some("/bin/sh") + ); + assert_eq!(env_value(&spec, "BOX_EXEC_ARGC"), Some("2")); + assert_eq!( + env_value(&spec, "BOX_EXEC_ARG_0").map(b64d).as_deref(), + Some("-c") + ); + assert_eq!( + env_value(&spec, "BOX_EXEC_ARG_1").map(b64d).as_deref(), + Some("sleep 3600") + ); + assert!(!spec + .entrypoint + .env + .iter() + .any(|(_, value)| value.contains("No command specified"))); + } + + #[test] + fn test_build_instance_spec_restored_rootfs_uses_saved_entrypoint_without_guest_init() { + let dir = tempdir().unwrap(); + let layout = test_layout(dir.path(), None, false); + let mut vm = test_vm_manager(BoxConfig { + cmd: vec!["hello".to_string()], + entrypoint_override: Some(vec!["/bin/echo".to_string(), "prefix".to_string()]), + extra_env: vec![("FOO".to_string(), "bar".to_string())], + ..Default::default() + }); + + let spec = vm.build_instance_spec(&layout).unwrap(); + + assert_eq!(spec.entrypoint.executable, "/bin/echo"); + assert_eq!(spec.entrypoint.args, vec!["prefix", "hello"]); + assert_eq!(env_value(&spec, "FOO"), Some("bar")); + } + #[test] fn test_build_instance_spec_prefers_config_workdir_and_user() { let dir = tempdir().unwrap(); @@ -1435,10 +1595,20 @@ mod tests { let mut second_vm = test_vm_manager(BoxConfig::default()); second_vm.home_dir = home.path().to_path_buf(); + second_vm.anonymous_volumes = vec![volume_name.clone(), volume_name.clone()]; + second_vm.build_instance_spec(&layout).unwrap(); second_vm.build_instance_spec(&layout).unwrap(); assert_eq!(second_vm.anonymous_volumes, vec![volume_name]); assert!(second_vm.created_anonymous_volumes.is_empty()); + assert_eq!( + store + .get(&second_vm.anonymous_volumes[0]) + .unwrap() + .unwrap() + .in_use_by, + vec!["test-box".to_string()] + ); } #[cfg(target_os = "windows")] diff --git a/src/runtime/src/vmm/controller.rs b/src/runtime/src/vmm/controller.rs index c5f4e692..12869f5f 100644 --- a/src/runtime/src/vmm/controller.rs +++ b/src/runtime/src/vmm/controller.rs @@ -541,7 +541,10 @@ exec /bin/sleep 30 #[cfg(unix)] fn wait_for_file(path: &std::path::Path) { for _ in 0..250 { - if path.exists() { + // Shell redirection creates the file before `printf` writes its + // contents. Waiting for existence alone makes the test race with + // the fake shim and intermittently observe an empty value in CI. + if path.metadata().is_ok_and(|metadata| metadata.len() > 0) { return; } std::thread::sleep(std::time::Duration::from_millis(20)); @@ -602,6 +605,8 @@ exec /bin/sleep 30 let mut handler = controller.start(&spec).await.unwrap(); wait_for_file(&args_file); + wait_for_file(&restore_file); + wait_for_file(&temp.path().join("logs").join("shim.stderr.log")); assert!(socket_dir.exists()); let args = std::fs::read_to_string(&args_file).unwrap(); diff --git a/src/runtime/src/vmm/handler.rs b/src/runtime/src/vmm/handler.rs index 3bf8f294..dbfb3de8 100644 --- a/src/runtime/src/vmm/handler.rs +++ b/src/runtime/src/vmm/handler.rs @@ -12,6 +12,8 @@ use sysinfo::{Pid, System}; /// Provides lifecycle operations (stop, metrics, status) for a VM identified by PID. pub struct ShimHandler { pid: u32, + /// Stable host-process identity captured when the handler is created. + pid_start_time: Option, box_id: String, /// Child process handle for proper lifecycle management. /// When we spawn the process, we keep the Child to properly wait() on stop. @@ -33,6 +35,7 @@ impl ShimHandler { let pid = process.id(); Self { pid, + pid_start_time: crate::process::pid_start_time(pid), box_id, process: Some(process), metrics_sys: Mutex::new(System::new()), @@ -47,6 +50,7 @@ impl ShimHandler { pub fn from_pid(pid: u32, box_id: String) -> Self { Self { pid, + pid_start_time: crate::process::pid_start_time(pid), box_id, process: None, metrics_sys: Mutex::new(System::new()), @@ -70,6 +74,16 @@ impl VmHandler for ShimHandler { // Graceful shutdown: send configured signal first, wait, then SIGKILL if needed. // This gives libkrun time to flush its virtio-blk buffers to disk. + // `try_wait_exit` may already have reaped an owned child. Never signal + // that old numeric PID after it becomes eligible for reuse. + if self.exit_code.is_some() { + self.process.take(); + return Ok(()); + } + if !self.is_running() { + return Ok(()); + } + if let Some(mut process) = self.process.take() { // Step 1: Send configured stop signal for graceful shutdown let pid = process.id(); @@ -131,13 +145,15 @@ impl VmHandler for ShimHandler { } if result < 0 { // Error - process may not be our child (common in attached mode) - let exists = unsafe { libc::kill(self.pid as i32, 0) } == 0; - if !exists { + if !self.is_running() { return Ok(()); // Already dead } } if start.elapsed().as_millis() > timeout_ms as u128 { + if !self.is_running() { + return Ok(()); + } tracing::warn!( pid = self.pid, timeout_ms, @@ -207,6 +223,10 @@ impl VmHandler for ShimHandler { } fn metrics(&self) -> VmMetrics { + if !self.is_running() { + return VmMetrics::default(); + } + let pid = Pid::from_u32(self.pid); // Use the shared System instance for stateful CPU tracking @@ -235,16 +255,12 @@ impl VmHandler for ShimHandler { #[cfg(unix)] fn is_running(&self) -> bool { - // Check if process exists by sending signal 0 - unsafe { libc::kill(self.pid as i32, 0) == 0 } + crate::process::is_process_alive_with_identity(self.pid, self.pid_start_time) } #[cfg(windows)] fn is_running(&self) -> bool { - // Use sysinfo to check if process exists - let mut sys = System::new(); - sys.refresh_process(Pid::from_u32(self.pid)); - sys.process(Pid::from_u32(self.pid)).is_some() + crate::process::is_process_alive_with_identity(self.pid, self.pid_start_time) } fn exit_code(&self) -> Option { @@ -304,6 +320,27 @@ mod tests { assert_eq!(handler.exit_code(), None); } + #[cfg(target_os = "linux")] + #[test] + fn attached_handler_rejects_a_reused_pid_identity() { + let mut child = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + let mut handler = ShimHandler::from_pid(child.id(), "box-stale-pid".to_string()); + handler.pid_start_time = Some(u64::MAX); + + assert!(!handler.is_running()); + let metrics = handler.metrics(); + assert!(metrics.cpu_percent.is_none()); + assert!(metrics.memory_bytes.is_none()); + handler.stop(libc::SIGTERM, 0).unwrap(); + assert!(child.try_wait().unwrap().is_none()); + + let _ = child.kill(); + let _ = child.wait(); + } + #[test] fn test_shim_handler_try_wait_exit_captures_child_exit_code() { let child = std::process::Command::new("sh") @@ -339,8 +376,9 @@ mod tests { handler.stop(libc::SIGTERM, 2_000).unwrap(); assert!(!handler.is_running()); - assert_eq!(handler.exit_code(), None); - assert_eq!(handler.try_wait_exit().unwrap(), None); + let exit_code = handler.exit_code(); + assert!(matches!(exit_code, None | Some(0))); + assert_eq!(handler.try_wait_exit().unwrap(), exit_code); } #[cfg(unix)] diff --git a/src/sdk/Cargo.toml b/src/sdk/Cargo.toml index a9d21a47..dd74723e 100644 --- a/src/sdk/Cargo.toml +++ b/src/sdk/Cargo.toml @@ -5,13 +5,46 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "Rust SDK for a3s-box. Includes a programmable CI/CD pipeline API (pipelines as code, one MicroVM kernel per step)." +description = "Rust SDK for a3s-box direct runtime-backed management APIs." + +[features] +default = [] +pipeline-cli = [] [dependencies] -# none — a thin, dependency-free wrapper over the `a3s-box` CLI. +a3s-box-core = { version = "3.0", path = "../core" } +a3s-box-runtime = { version = "3.0", path = "../runtime" } +chrono = { workspace = true } +libc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sysinfo = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } # Thin runner that bridges any agent (a3s-code / Claude Code / Codex) to the # pipeline API: a line-based spec on stdin -> JSON Report on stdout. [[bin]] name = "a3s-box-ci" path = "src/bin/a3s-box-ci.rs" +required-features = ["pipeline-cli"] + +[[example]] +name = "pipeline" +path = "examples/pipeline.rs" +required-features = ["pipeline-cli"] + +[[test]] +name = "integration_kvm" +path = "tests/integration_kvm.rs" +required-features = ["pipeline-cli"] + +[[test]] +name = "soak_kvm" +path = "tests/soak_kvm.rs" +required-features = ["pipeline-cli"] + +[dev-dependencies] +async-trait = { workspace = true } +tempfile = { workspace = true } diff --git a/src/sdk/README.md b/src/sdk/README.md index 7a8c5480..eb2c0cf7 100644 --- a/src/sdk/README.md +++ b/src/sdk/README.md @@ -1,75 +1,165 @@ # a3s-box-sdk -The Rust SDK for **a3s-box**. Today it provides a **programmable CI/CD pipeline** API -(`a3s_box_sdk::pipeline`) — a pipeline is a Rust program, not a YAML file, and each step -runs in its own MicroVM (one Linux kernel per step, so an untrusted step can't escape to -the host or a sibling step). More capabilities will be added over time; the crate is -intentionally not limited to CI. +The Rust SDK for **a3s-box** direct runtime APIs. -Dependency-free: a thin wrapper over the `a3s-box` CLI (which owns the box lifecycle and -state). Set `A3S_BOX` if `a3s-box` is not on `PATH`. +By default, the SDK does not spawn the `a3s-box` CLI. `A3sBoxClient` calls +`a3s-box-runtime` stores and socket clients directly, returning typed Rust data +for management apps, automation, and tests. -## Pipelines - -Warm a base box **once** (clone + install deps), snapshot it, fork per step. -Run steps **sequentially** (fail-fast) or **in parallel** (collect-all → a typed -report): +## Runtime-Backed Client ```rust -use a3s_box_sdk::pipeline::{warm_base, WarmBase, FileCache, Step}; - -fn main() -> Result<(), a3s_box_sdk::pipeline::PipelineError> { - let cache = FileCache::new(".ci-cache")?; // skip a step when its inputs are unchanged - let base = warm_base( - WarmBase::new("node:20", "git clone $REPO /w && cd /w && npm ci") // runs ONCE - .env("REPO", "https://github.com/me/app") - .cache(&cache), - )?; - - // Sequential, fail-fast: a non-zero exit returns Err. - base.step(Step::new("lint", "cd /w && npm run lint"))?; - - // Parallel, collect-all: each step is an isolated CoW fork; <=4 at a time. - let report = base.run_parallel(vec![ - Step::new("test", "cd /w && npm test"), - Step::new("build", "cd /w && npm run build"), - ], 4); - - println!("{}", report.to_json()); // {"passed":..,"total_ms":..,"steps":[..]} - if !report.passed { /* inspect report.failures() */ } - Ok(()) // `base` drops here -> snapshot auto-removed (or call base.dispose()) -} +use a3s_box_sdk::{ + A3sBoxClient, BuildImage, CreateNetwork, CreateSnapshot, CreateVolume, ListBoxesOptions, + PullImage, ReadBoxLogsOptions, RemoveBox, RestoreSnapshot, StopBox, +}; + +# async fn example() -> Result<(), a3s_box_sdk::ClientError> { +let client = A3sBoxClient::new(); + +let boxes = client.list_boxes(ListBoxesOptions::all())?; +let disk = client.runtime_disk_usage()?; +let stats = client.list_box_stats()?; +let logs = client.read_box_logs("web", ReadBoxLogsOptions::tail(20))?; +let stopped = client.stop_box("web", StopBox::new()).await?; +let snapshot = client.create_snapshot("web", CreateSnapshot::new().name("web-snapshot"))?; +let restored = client.restore_snapshot(&snapshot.id, RestoreSnapshot::new())?; +let removed = client.remove_box("web", RemoveBox::new())?; + +let pulled = client.pull_image(PullImage::new("alpine:latest")).await?; +let inspect = client.inspect_image("alpine:latest").await?; +let history = client.image_history("alpine:latest").await?; +let built = client + .build_image(BuildImage::new(".").tag("local/app:dev").quiet(true)) + .await?; +let tagged = client + .tag_image(a3s_box_sdk::TagImage::new("local/app:dev", "local/app:latest")) + .await?; + +let volume = client.create_volume(CreateVolume::new("cache").label("role", "build"))?; +let network = client.create_network(CreateNetwork::new("dev").subnet("10.89.44.0/24"))?; + +println!( + "{} boxes, {} disk bytes, {} stats, {} logs, stopped {}, snapshot {}, restored {}, removed {}, pulled {}, inspected {}, history {}, built {}, tagged {}, volume {}, network {}", + boxes.len(), + disk.total_bytes, + stats.len(), + logs.len(), + stopped.name, + snapshot.name, + restored.name, + removed.name, + pulled.reference, + inspect.is_some(), + history.as_ref().map_or(0, Vec::len), + built.reference, + tagged.reference, + volume.name, + network.name +); +# Ok(()) } ``` -- `run_parallel` is the way to use a3s-box's cheap (~ms) CoW fork at scale — a - matrix / evolution-style batch — without hand-rolling threads (every method - takes `&self`). -- A step reports a metric by printing `::metric =` to stdout; it - surfaces as `StepResult::metrics` (the scoring channel for a selection loop). -- `StepResult` carries separated `stdout`/`stderr`, `duration_ms`, and `cached`; - `Report::to_json()` is the machine-readable handoff to an agent/scorer. -- `Step::allow_failure()` keeps a non-zero step from failing the run; `Step::input(..)` - adds extra cache-key parts. - -The base **auto-disposes** its snapshot on drop, and each per-step box is removed -on every path (including a panic), so a long-running batch doesn't leak. - -## Why forking is cheap +Use `A3sBoxClient::from_home(path)` for tests or tools that should operate on a +non-default a3s-box state directory. -a3s-box's `snapshot restore` is **copy-on-write**: each fork mounts the snapshot's -pristine rootfs as a read-only overlay lower with its own upper — near-instant, -a few MB per fork, and isolated. So snapshot-per-step fan-out costs almost nothing. +## Managed Lifecycle -## What it hides +The SDK submits lifecycle requests directly to the same generation-fenced +`ExecutionManager` used by the CLI and compatibility service. It does not spawn +the CLI or construct a parallel box record. -CLI footguns verified on a real KVM host, so you don't hit them: `run`/`exec` need -`--` before the command; `snapshot restore` yields a *created* box (started before -exec); `snapshot rm` keys on snapshot ID, not name; `rm -f` of a missing box is a -no-op here (idempotent reruns). +```rust +use std::collections::BTreeMap; + +use a3s_box_sdk::{ + A3sBoxClient, BoxConfig, CreateExecutionRequest, ExecutionIsolation, + ExecutionRecordPolicy, OperationId, +}; + +# async fn lifecycle() -> Result<(), a3s_box_sdk::ClientError> { +let client = A3sBoxClient::new(); +let operation = OperationId::new("example-create")?; +let request = CreateExecutionRequest { + external_sandbox_id: "example-sandbox".to_string(), + config: BoxConfig { + image: "alpine:latest".to_string(), + isolation: ExecutionIsolation::Sandbox, + cmd: vec!["sleep".to_string(), "60".to_string()], + ..BoxConfig::default() + }, + labels: BTreeMap::new(), + policy: ExecutionRecordPolicy { + name: Some("sdk-example".to_string()), + ..ExecutionRecordPolicy::default() + }, +}; + +let reservation = client.create_box(request, &operation).await?; +let lease = client + .start_box(&reservation.execution_id, reservation.generation) + .await?; +let status = client.inspect_execution(&lease.execution_id).await?; +client + .kill_execution(&status.execution_id, status.generation) + .await?; +# Ok(()) } +``` -## Run the example +`run_box` provides the idempotent create-and-start composition. Typed methods +also expose inspect, pause, resume, restart, kill, and operation reconciliation. +`A3sBoxClient::with_execution_manager` accepts an explicit typed manager for +embedding or tests without changing request semantics. + +## API Coverage + +- Boxes: generation-fenced create, start, run, inspect, pause, resume, restart, + kill, and reconciliation; plus list, get, legacy pause/unpause, Unix stop, + remove, prune inactive boxes, log snapshots, and host-side stats snapshots. +- Images: list, get, inspect local OCI metadata, read OCI history, pull, build, + tag, push, remove, and evict. +- Volumes: list, get, create, remove, and prune. +- Networks: list, get, create, remove, connect inactive boxes, disconnect inactive + boxes, and prune. +- Snapshots: list, get, create from a box rootfs, restore into a new created box + record, remove, and prune. +- Diagnostics: a3s-box/core/runtime/SDK versions, home path, host + virtualization availability, and runtime disk usage grouped by boxes, images, + volumes, snapshots, state files, and other local data. +- Running boxes on Unix: exec, file transfer, heartbeat, main-process signal, + deferred-main spawn, PTY client, and attestation report through runtime sockets. + +The client reads the shared `boxes.json` state format through an SDK-local model +so it does not depend on the CLI crate. Image, volume, network, snapshot, build, +registry, exec, PTY, and attestation operations use `a3s-box-runtime` directly. + +Managed lifecycle methods preserve the complete typed `BoxConfig` and +`ExecutionRecordPolicy` request and call the canonical runtime facade. Pause, +unpause, Unix stop, and box removal remain available through the existing +query-based management surface for backwards compatibility. The default SDK +does not shell out for lifecycle commands. + +## Maintenance Calls + +Destructive APIs include `remove_box`, `prune_boxes`, `remove_image`, `evict_images`, +`remove_volume`, `prune_volumes`, `remove_network`, `prune_networks`, +`remove_snapshot`, and `prune_snapshots`. Product UIs should pair these with +selection state and confirmation prompts. + +`restore_snapshot` is not destructive, but it creates a new box record and box +directory, so product UIs should still pair it with explicit source selection +and confirmation. + +## Optional Pipeline Runner + +The historical programmable CI runner is still available behind an explicit +feature: ```bash -cargo test -p a3s-box-sdk # offline unit tests -A3S_BOX=/path/to/a3s-box cargo run -p a3s-box-sdk --example pipeline # live, needs /dev/kvm +cargo test -p a3s-box-sdk --features pipeline-cli +A3S_BOX=/path/to/a3s-box cargo run -p a3s-box-sdk --features pipeline-cli --bin a3s-box-ci ``` + +This optional runner drives lifecycle-heavy commands through the installed +`a3s-box` binary because those flows are not yet exposed as stable runtime client +APIs. It is not part of the default SDK surface. diff --git a/src/sdk/src/box_state.rs b/src/sdk/src/box_state.rs new file mode 100644 index 00000000..1ee5e4ab --- /dev/null +++ b/src/sdk/src/box_state.rs @@ -0,0 +1,3 @@ +//! Runtime-owned reader and transactional writer for local box state. + +pub(crate) use a3s_box_runtime::{BoxRecord, BoxStateStore as StateFile}; diff --git a/src/sdk/src/client/core.rs b/src/sdk/src/client/core.rs new file mode 100644 index 00000000..4c438dc5 --- /dev/null +++ b/src/sdk/src/client/core.rs @@ -0,0 +1,967 @@ +/// Runtime-backed SDK client for local a3s-box state. +#[derive(Clone)] +pub struct A3sBoxClient { + paths: A3sBoxPaths, + image_cache_size: u64, + execution_manager: Arc, +} + +impl std::fmt::Debug for A3sBoxClient { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("A3sBoxClient") + .field("paths", &self.paths) + .field("image_cache_size", &self.image_cache_size) + .finish_non_exhaustive() + } +} + +impl A3sBoxClient { + /// Create a client for the default a3s-box home. + pub fn new() -> Self { + Self::default() + } + + /// Create a client rooted at a custom a3s-box home directory. + pub fn from_home(home: impl Into) -> Self { + Self::with_paths(A3sBoxPaths::from_home(home)) + } + + /// Create a client using explicit state paths. + pub fn with_paths(paths: A3sBoxPaths) -> Self { + let execution_manager = Arc::new(a3s_box_runtime::LocalExecutionManager::with_vm_backend( + paths.boxes_file.clone(), + paths.home.clone(), + )); + Self::with_execution_manager(paths, execution_manager) + } + + /// Create a client with an explicit backend-neutral execution manager. + /// + /// This keeps lifecycle calls on the same canonical facade used by the CLI + /// and remote compatibility service while allowing an embedding application + /// to inject its own manager implementation. + pub fn with_execution_manager( + paths: A3sBoxPaths, + execution_manager: Arc, + ) -> Self { + Self { + paths, + image_cache_size: a3s_box_runtime::DEFAULT_IMAGE_CACHE_SIZE, + execution_manager, + } + } + + /// Override the image cache size used when opening the runtime image store. + pub fn with_image_cache_size(mut self, image_cache_size: u64) -> Self { + self.image_cache_size = image_cache_size; + self + } + + /// Return the state paths used by this client. + pub fn paths(&self) -> &A3sBoxPaths { + &self.paths + } + + /// Collect local runtime diagnostics without spawning the CLI. + pub fn runtime_diagnostics(&self) -> RuntimeDiagnostics { + RuntimeDiagnostics::collect(&self.paths) + } + + /// Collect local runtime disk usage without spawning the CLI. + pub fn runtime_disk_usage(&self) -> Result { + RuntimeDiskUsage::collect(&self.paths) + } + + /// List box records from the shared state file. + pub fn list_boxes(&self, options: ListBoxesOptions) -> Result> { + let state = self.load_state()?; + Ok(state + .list(options.all) + .into_iter() + .map(BoxSummary::from_record) + .collect()) + } + + /// Get one box by exact id, short id, id prefix, or name. + pub fn get_box(&self, query: &str) -> Result> { + let state = self.load_state()?; + Ok(resolve_record(&state, query) + .transpose()? + .map(BoxSummary::from_record)) + } + + /// Remove a box record and its host-side runtime resources. + /// + /// By default active boxes are rejected. Pass [`RemoveBox::force`] to mirror + /// CLI-style forced removal, which only signals a recorded PID after the + /// PID identity check still matches the original box process. + pub fn remove_box(&self, query: &str, request: RemoveBox) -> Result { + let state = self.load_state()?; + let record = resolve_required_record(&state, query)?.clone(); + + if record.is_active() { + if !request.force { + return Err(ClientError::Validation(format!( + "box {} is {}. Stop it before removing it, or force removal explicitly.", + record.name, record.status + ))); + } + + terminate_recorded_process(&record); + } + + let id = record.id.clone(); + let name = record.name.clone(); + cleanup_removed_box(&self.paths, &record); + let removed = + StateFile::modify(&self.paths.boxes_file, |state| Ok(state.remove_by_id(&id)))?; + if !removed { + return Err(ClientError::BoxNotFound(id)); + } + + Ok(RemoveBoxSummary { id, name }) + } + + /// Remove all created, stopped, and dead boxes from SDK-managed state. + /// + /// Running and paused boxes are kept. Host-side runtime resources for each + /// removed box are cleaned up after the records are removed under the state + /// lock, so concurrent readers no longer observe pruned boxes. + pub fn prune_boxes(&self) -> Result> { + let records = StateFile::modify(&self.paths.boxes_file, |state| { + let records = state + .list(true) + .into_iter() + .filter(|record| is_prunable_box_record(record)) + .cloned() + .collect::>(); + for record in &records { + state.remove_by_id(&record.id); + } + Ok(records) + })?; + + for record in &records { + cleanup_removed_box(&self.paths, record); + } + + Ok(records + .into_iter() + .map(|record| RemoveBoxSummary { + id: record.id, + name: record.name, + }) + .collect()) + } + + /// Pause a running box by stopping its host shim process and updating state. + /// + /// This mirrors the CLI's pause semantics for the direct SDK surface: only a + /// currently running box can be paused, and stale or reused PIDs are rejected + /// before any signal is sent. + pub fn pause_box(&self, query: &str) -> Result { + self.signal_box_status_transition(query, LifecycleTransition::Pause) + } + + /// Resume a paused box by continuing its host shim process and updating state. + pub fn unpause_box(&self, query: &str) -> Result { + self.signal_box_status_transition(query, LifecycleTransition::Unpause) + } + + /// Stop a running or paused box with guest-first graceful shutdown. + #[cfg(unix)] + pub async fn stop_box(&self, query: &str, request: StopBox) -> Result { + let state = self.load_state()?; + let record = resolve_required_record(&state, query)?.clone(); + require_active(&record, "stop")?; + let pid = require_live_pid(&record, "stop")?; + if record.status == "paused" { + send_host_signal(pid, libc::SIGCONT) + .map_err(|error| ClientError::Validation(error.to_string()))?; + } + + let stop_signal = record + .stop_signal + .as_deref() + .map(parse_signal_name) + .unwrap_or(libc::SIGTERM); + let timeout_secs = request.timeout_secs.or(record.stop_timeout).unwrap_or(10); + let outcome = + graceful_stop_via_guest(pid, &exec_socket(&record), stop_signal, timeout_secs).await; + let exit_code = stopped_exit_code(record.exit_code, outcome, stop_signal); + let record_id = record.id.clone(); + let auto_removed = record.auto_remove; + let name = record.name.clone(); + + if auto_removed { + cleanup_removed_box(&self.paths, &record); + StateFile::modify(&self.paths.boxes_file, |state| { + state.remove_by_id(&record_id); + Ok(()) + })?; + return Ok(StopBoxSummary { + id: record_id, + name, + outcome, + exit_code, + auto_removed: true, + box_summary: None, + }); + } + + cleanup_stopped_box(&self.paths, &record); + let box_summary = StateFile::modify(&self.paths.boxes_file, |state| { + let Some(record) = state.find_by_id_mut(&record_id) else { + return Ok(None); + }; + record.status = "stopped".to_string(); + record.pid = None; + record.stopped_by_user = true; + record.exit_code = exit_code; + record.health_status = "none".to_string(); + record.health_retries = 0; + Ok(Some(BoxSummary::from_record(record))) + })? + .ok_or_else(|| ClientError::BoxNotFound(record_id.clone()))?; + + Ok(StopBoxSummary { + id: record_id, + name, + outcome, + exit_code, + auto_removed: false, + box_summary: Some(box_summary), + }) + } + + /// Read recent logs for one box from the runtime log files. + /// + /// The SDK follows the same source preference as the CLI: structured + /// `logs/container.json` first, then the raw console log as a fallback. This + /// method is a bounded snapshot reader; it does not follow live output. + pub fn read_box_logs( + &self, + query: &str, + options: ReadBoxLogsOptions, + ) -> Result> { + let state = self.load_state()?; + let record = resolve_required_record(&state, query)?; + + if record.log_config.driver == LogDriver::None { + return Err(ClientError::Validation(format!( + "logging is disabled for box {}", + record.name + ))); + } + + let Some(source) = resolve_log_source(record) else { + return Ok(Vec::new()); + }; + + read_log_source(source, options.tail) + } + + /// Collect host-side resource usage snapshots for all active boxes. + /// + /// CPU and memory are read from the recorded shim process. Network counters + /// are read from the runtime netproxy stats file when it exists. This is a + /// bounded snapshot reader; it does not stream and does not exec into the + /// guest. + pub fn list_box_stats(&self) -> Result> { + let state = self.load_state()?; + let records = state + .list(true) + .into_iter() + .filter(|record| record.is_active()) + .collect::>(); + Ok(collect_box_stats(&records)) + } + + /// Collect one host-side resource usage snapshot by exact id, short id, + /// id prefix, or name. Returns `None` when the box is not active or its host + /// process is no longer available. + pub fn get_box_stats(&self, query: &str) -> Result> { + let state = self.load_state()?; + let record = resolve_required_record(&state, query)?; + if !record.is_active() { + return Ok(None); + } + Ok(collect_box_stats(&[record]).into_iter().next()) + } + + /// List cached images from the runtime image store. + pub async fn list_images(&self) -> Result> { + let store = self.open_image_store()?; + let mut images = store + .list() + .await + .into_iter() + .map(ImageSummary::from) + .collect::>(); + images.sort_by(|a, b| a.reference.cmp(&b.reference)); + Ok(images) + } + + /// Resolve one image by reference or digest. + pub async fn get_image(&self, reference_or_digest: &str) -> Result> { + let store = self.open_image_store()?; + let images = store.list().await; + match resolve_stored_image(&images, reference_or_digest)? { + Some(image) => Ok(store + .get(&image.reference) + .await + .or(Some(image)) + .map(ImageSummary::from)), + None => Ok(None), + } + } + + /// Inspect one cached image's local OCI configuration. + pub async fn inspect_image( + &self, + reference_or_digest: &str, + ) -> Result> { + let store = self.open_image_store()?; + let images = store.list().await; + let Some(image) = resolve_stored_image(&images, reference_or_digest)? else { + return Ok(None); + }; + Ok(Some(ImageInspectSummary::from_stored_image(image)?)) + } + + /// Read one cached image's OCI build history. + pub async fn image_history( + &self, + reference_or_digest: &str, + ) -> Result>> { + let store = self.open_image_store()?; + let images = store.list().await; + let Some(image) = resolve_stored_image(&images, reference_or_digest)? else { + return Ok(None); + }; + Ok(Some(load_image_history(&image.path)?)) + } + + /// Add a new tag pointing at an existing cached image. + pub async fn tag_image(&self, request: TagImage) -> Result { + request.validate()?; + let store = self.open_image_store()?; + let images = store.list().await; + let source = resolve_stored_image(&images, &request.source)?.ok_or_else(|| { + ClientError::Validation(format!("image '{}' is not cached", request.source)) + })?; + Ok(ImageSummary::from( + store + .put(&request.target, &source.digest, &source.path) + .await?, + )) + } + + /// Remove one cached image by reference or digest. + pub async fn remove_image(&self, reference_or_digest: &str) -> Result<()> { + self.open_image_store()? + .remove(reference_or_digest) + .await + .map_err(ClientError::Runtime) + } + + /// Evict least-recently-used images until the image cache is under its limit. + pub async fn evict_images(&self) -> Result> { + Ok(self.open_image_store()?.evict().await?) + } + + /// Pull an OCI image through the runtime image puller and cache it locally. + pub async fn pull_image(&self, request: PullImage) -> Result { + request.validate()?; + let store = Arc::new(self.open_image_store()?); + let auth = request.registry_auth()?; + let puller = ImagePuller::with_platform(store, auth, request.platform.clone()) + .with_signature_policy(request.signature_policy.clone()); + + if request.force { + puller.force_pull(&request.reference).await?; + } else { + puller.pull(&request.reference).await?; + } + + self.get_image(&request.reference).await?.ok_or_else(|| { + ClientError::Validation(format!("image '{}' was not cached", request.reference)) + }) + } + + /// Build an OCI image with the runtime Dockerfile build engine. + pub async fn build_image(&self, request: BuildImage) -> Result { + request.validate()?; + let store = Arc::new(self.open_image_store()?); + let result = a3s_box_runtime::oci::build::build( + RuntimeBuildConfig { + context_dir: request.context_dir, + dockerfile_path: request.dockerfile_path, + tag: request.tag, + build_args: request.build_args, + quiet: request.quiet, + platforms: request.platforms, + target: request.target, + no_cache: request.no_cache, + metrics: None, + run_pool: None, + }, + store, + ) + .await?; + + Ok(BuildImageSummary::from(result)) + } + + /// Push a locally cached image through the runtime registry pusher. + pub async fn push_image(&self, request: PushImage) -> Result { + request.validate()?; + let image = self.get_image(&request.source).await?.ok_or_else(|| { + ClientError::Validation(format!("image '{}' is not cached", request.source)) + })?; + let target = ImageReference::parse(&request.target).map_err(ClientError::Runtime)?; + let auth = request.registry_auth(&target); + let result = RegistryPusher::with_auth_and_protocol(auth, request.registry_protocol) + .push(&target, &image.path) + .await?; + + Ok(PushImageSummary::from_push_result(request.target, result)) + } + + /// List named volumes from the runtime volume store. + pub fn list_volumes(&self) -> Result> { + let store = self.volume_store(); + let mut volumes = store + .list()? + .into_iter() + .map(VolumeSummary::from) + .collect::>(); + volumes.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(volumes) + } + + /// Get one named volume. + pub fn get_volume(&self, name: &str) -> Result> { + Ok(self.volume_store().get(name)?.map(VolumeSummary::from)) + } + + /// Create a named volume. + pub fn create_volume(&self, request: CreateVolume) -> Result { + request.validate()?; + let mut config = VolumeConfig::new(&request.name, ""); + config.driver = request.driver; + config.labels = request.labels; + config.size_limit = request.size_limit; + Ok(VolumeSummary::from(self.volume_store().create(config)?)) + } + + /// Remove a named volume. + pub fn remove_volume(&self, name: &str, force: bool) -> Result { + Ok(VolumeSummary::from( + self.volume_store().remove(name, force)?, + )) + } + + /// Remove all unused named volumes. + pub fn prune_volumes(&self) -> Result> { + Ok(self.volume_store().prune()?) + } + + /// List networks from the runtime network store. + pub fn list_networks(&self) -> Result> { + let store = self.network_store(); + let mut networks = store + .list()? + .into_iter() + .map(NetworkSummary::from) + .collect::>(); + networks.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(networks) + } + + /// Get one network. + pub fn get_network(&self, name: &str) -> Result> { + Ok(self.network_store().get(name)?.map(NetworkSummary::from)) + } + + /// Create a bridge network. + pub fn create_network(&self, request: CreateNetwork) -> Result { + request.validate()?; + let mut config = + NetworkConfig::new(&request.name, &request.subnet).map_err(ClientError::Validation)?; + config.driver = request.driver; + config.labels = request.labels; + config.policy.isolation = request.isolation; + config.policy.validate().map_err(ClientError::Validation)?; + + let store = self.network_store(); + store.create(config)?; + store + .get(&request.name)? + .map(NetworkSummary::from) + .ok_or_else(|| { + ClientError::Validation(format!("network '{}' was not saved", request.name)) + }) + } + + /// Remove a network. The runtime rejects networks that still have endpoints. + pub fn remove_network(&self, name: &str) -> Result { + Ok(NetworkSummary::from(self.network_store().remove(name)?)) + } + + /// Remove all unused non-predefined networks. + /// + /// A network is unused when it has no endpoints and no box record references + /// it by `network_name` or bridge `network_mode`. Docker-style predefined + /// networks (`bridge`, `host`, `none`) are never pruned. + pub fn prune_networks(&self) -> Result> { + let state = self.load_state()?; + let in_use = state + .list(true) + .into_iter() + .filter_map(record_network_name) + .map(str::to_string) + .collect::>(); + + let mut networks = self.network_store().list()?; + networks.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut removed = Vec::new(); + for network in networks { + if is_predefined_network(&network.name) + || !network.endpoints.is_empty() + || in_use.contains(&network.name) + { + continue; + } + self.network_store().remove(&network.name)?; + removed.push(network.name); + } + Ok(removed) + } + + /// Attach an inactive box record to a network and allocate an endpoint. + /// + /// Hot-plug for active boxes is not supported by the runtime yet, so this + /// mirrors the CLI rule: stop the box before changing its network. + pub fn connect_network( + &self, + network: &str, + box_query: &str, + ) -> Result { + let mut state = self.load_state()?; + let record = resolve_required_record(&state, box_query)?.clone(); + require_inactive_for_network_change(&record, "connect to a network")?; + + let endpoint = self.network_store().with_write_lock( + |networks| -> std::result::Result { + let config = networks.get_mut(network).ok_or_else(|| { + a3s_box_core::error::BoxError::NetworkError(format!( + "network '{}' not found", + network + )) + })?; + config + .policy + .validate() + .map_err(a3s_box_core::error::BoxError::NetworkError)?; + config + .connect(&record.id, &record.name) + .map_err(a3s_box_core::error::BoxError::NetworkError) + }, + )?; + + let state_record = state + .find_by_id_mut(&record.id) + .ok_or_else(|| ClientError::BoxNotFound(box_query.to_string()))?; + state_record.network_mode = NetworkMode::Bridge { + network: network.to_string(), + }; + state_record.network_name = Some(network.to_string()); + state.save()?; + + Ok(NetworkEndpointSummary::from(endpoint)) + } + + /// Detach an inactive box record from a network. + pub fn disconnect_network( + &self, + network: &str, + box_query: &str, + ) -> Result { + let mut state = self.load_state()?; + let record = resolve_required_record(&state, box_query)?.clone(); + require_inactive_for_network_change(&record, "disconnect from a network")?; + + let endpoint = self.network_store().with_write_lock( + |networks| -> std::result::Result { + let config = networks.get_mut(network).ok_or_else(|| { + a3s_box_core::error::BoxError::NetworkError(format!( + "network '{}' not found", + network + )) + })?; + config + .disconnect(&record.id) + .map_err(a3s_box_core::error::BoxError::NetworkError) + }, + )?; + + let state_record = state + .find_by_id_mut(&record.id) + .ok_or_else(|| ClientError::BoxNotFound(box_query.to_string()))?; + state_record.network_mode = NetworkMode::Tsi; + state_record.network_name = None; + state.save()?; + + Ok(NetworkEndpointSummary::from(endpoint)) + } + + /// List VM snapshots from the runtime snapshot store. + pub fn list_snapshots(&self) -> Result> { + Ok(self + .snapshot_store()? + .list()? + .into_iter() + .map(SnapshotSummary::from) + .collect()) + } + + /// Get one VM snapshot by id. + pub fn get_snapshot(&self, id: &str) -> Result> { + Ok(self.snapshot_store()?.get(id)?.map(SnapshotSummary::from)) + } + + /// Remove one VM snapshot by id. + pub fn remove_snapshot(&self, id: &str) -> Result { + validate_name("snapshot", id)?; + Ok(self.snapshot_store()?.delete(id)?) + } + + /// Create a VM snapshot from a box's current on-disk root filesystem. + pub fn create_snapshot( + &self, + box_query: &str, + request: CreateSnapshot, + ) -> Result { + request.validate()?; + let state = self.load_state()?; + let record = resolve_required_record(&state, box_query)?.clone(); + let rootfs_path = resolve_box_rootfs(&record.box_dir).ok_or_else(|| { + ClientError::Validation(format!( + "rootfs not found for box {} under {} (looked for merged/ and rootfs/)", + record.name, + record.box_dir.display() + )) + })?; + let snapshot_id = format!( + "snap-{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) + ); + let snapshot_name = request + .name + .unwrap_or_else(|| format!("{}-snapshot", record.name)); + + let mut metadata = SnapshotMetadata::new( + snapshot_id, + snapshot_name, + record.id.clone(), + record.image.clone(), + ); + metadata.vcpus = record.cpus; + metadata.memory_mb = record.memory_mb; + metadata.volumes = record.volumes.clone(); + metadata.env = record.env.clone(); + metadata.cmd = record.cmd.clone(); + metadata.entrypoint = record.entrypoint.clone(); + metadata.workdir = record.workdir.clone(); + metadata.port_map = record.port_map.clone(); + metadata.labels = record.labels.clone(); + metadata.network_mode = Some(record.network_mode.to_string()); + metadata.description = request.description.unwrap_or_default(); + metadata.image_config = load_resolved_image_config(&record.box_dir)?; + if metadata.image_config.is_none() { + return Err(ClientError::Validation(format!( + "resolved image configuration is missing for box {}; restart it before creating a filesystem snapshot", + record.name + ))); + } + + Ok(SnapshotSummary::from( + self.snapshot_store()?.save(metadata, &rootfs_path)?, + )) + } + + /// Restore a snapshot into a new, created box record. + pub fn restore_snapshot( + &self, + snapshot_query: &str, + request: RestoreSnapshot, + ) -> Result { + request.validate()?; + let store = self.snapshot_store()?; + let metadata = resolve_snapshot_metadata(&store, snapshot_query)?; + metadata + .require_image_config() + .map_err(|error| ClientError::Validation(error.to_string()))?; + let state = self.load_state()?; + let box_name = request + .name + .unwrap_or_else(|| default_restored_box_name(&state, &metadata)); + validate_name("box", &box_name)?; + if state.find_by_name(&box_name).is_some() { + return Err(ClientError::Validation(format!( + "box name '{}' already exists", + box_name + ))); + } + + let snap_rootfs = store.rootfs_path(&metadata.id); + if !snap_rootfs.exists() { + return Err(ClientError::Validation(format!( + "snapshot rootfs is missing for snapshot {}", + metadata.id + ))); + } + + let box_id = uuid::Uuid::new_v4().to_string(); + let short_id = BoxRecord::make_short_id(&box_id); + let box_dir = self.paths.home.join("boxes").join(&box_id); + let socket_dir = box_dir.join("sockets"); + let logs_dir = box_dir.join("logs"); + let mut box_dir_guard = BoxDirGuard::new(box_dir.clone()); + std::fs::create_dir_all(&socket_dir)?; + std::fs::create_dir_all(&logs_dir)?; + std::fs::write( + box_dir.join(".snapshot-lower"), + snap_rootfs.to_string_lossy().as_bytes(), + )?; + + let record = BoxRecord { + id: box_id, + short_id, + name: box_name, + image: metadata.image.clone(), + isolation: Default::default(), + managed_execution: None, + status: "created".to_string(), + pid: None, + pid_start_time: None, + cpus: metadata.vcpus, + memory_mb: metadata.memory_mb, + volumes: metadata.volumes.clone(), + virtiofs_cache: None, + env: metadata.env.clone(), + cmd: metadata.cmd.clone(), + entrypoint: metadata.entrypoint.clone(), + box_dir: box_dir.clone(), + exec_socket_path: socket_dir.join("exec.sock"), + console_log: logs_dir.join("console.log"), + created_at: chrono::Utc::now(), + started_at: None, + auto_remove: false, + hostname: None, + user: None, + workdir: metadata.workdir.clone(), + restart_policy: "no".to_string(), + port_map: metadata.port_map.clone(), + labels: metadata.labels.clone(), + stopped_by_user: false, + restart_count: 0, + max_restart_count: 0, + exit_code: None, + health_check: None, + healthcheck_disabled: false, + health_status: "none".to_string(), + health_retries: 0, + health_last_check: None, + network_mode: NetworkMode::default(), + network_name: None, + volume_names: vec![], + tmpfs: vec![], + anonymous_volumes: vec![], + resource_limits: a3s_box_core::config::ResourceLimits::default(), + log_config: a3s_box_core::log::LogConfig::default(), + add_host: vec![], + platform: None, + init: false, + read_only: false, + cap_add: vec![], + cap_drop: vec![], + security_opt: vec![], + privileged: false, + devices: vec![], + gpus: None, + shm_size: None, + stop_signal: None, + stop_timeout: None, + oom_kill_disable: false, + oom_score_adj: None, + }; + let summary = BoxSummary::from_record(&record); + let registered = StateFile::modify(&self.paths.boxes_file, |state| { + if state.find_by_name(&summary.name).is_some() { + return Ok(false); + } + state.records_mut().push(record); + Ok(true) + })?; + if !registered { + return Err(ClientError::Validation(format!( + "box name '{}' already exists", + summary.name + ))); + } + box_dir_guard.disarm(); + Ok(summary) + } + + /// Prune old snapshots according to count and byte limits. + /// + /// A value of 0 means unlimited for each limit, matching the runtime store. + pub fn prune_snapshots(&self, max_count: usize, max_bytes: u64) -> Result> { + Ok(self.snapshot_store()?.prune(max_count, max_bytes)?) + } + + /// Execute a command in a running box through the runtime exec client. + #[cfg(unix)] + pub async fn exec_box(&self, query: &str, request: &ExecRequest) -> Result { + Ok(self.exec_client(query).await?.exec_command(request).await?) + } + + /// Transfer a file to or from a running box through the runtime exec client. + #[cfg(unix)] + pub async fn transfer_box_file( + &self, + query: &str, + request: &FileRequest, + ) -> Result { + Ok(self + .exec_client(query) + .await? + .file_transfer(request) + .await?) + } + + /// Check whether a running box's exec server responds to heartbeat. + #[cfg(unix)] + pub async fn heartbeat_box(&self, query: &str) -> Result { + Ok(self.exec_client(query).await?.heartbeat().await?) + } + + /// Ask the guest to deliver a signal to the main process. + #[cfg(unix)] + pub async fn signal_box_main(&self, query: &str, signal: i32) -> Result { + Ok(self.exec_client(query).await?.signal_main(signal).await?) + } + + /// Ask a deferred-main guest to spawn its configured main process. + #[cfg(unix)] + pub async fn spawn_box_main(&self, query: &str, spec_json: Option<&[u8]>) -> Result { + Ok(self.exec_client(query).await?.spawn_main(spec_json).await?) + } + + /// Open the runtime exec client for a running box. + #[cfg(unix)] + pub async fn exec_client(&self, query: &str) -> Result { + let socket = self.require_runtime_socket(query, RuntimeSocket::Exec)?; + Ok(ExecClient::connect(&socket).await?) + } + + /// Open the runtime PTY client for a running box. + #[cfg(unix)] + pub async fn pty_client(&self, query: &str) -> Result { + let socket = self.require_runtime_socket(query, RuntimeSocket::Pty)?; + Ok(PtyClient::connect(&socket).await?) + } + + /// Request a raw attestation report through the runtime attestation client. + #[cfg(unix)] + pub async fn attestation_report( + &self, + query: &str, + request: &AttestationRequest, + ) -> Result { + let socket = self.require_runtime_socket(query, RuntimeSocket::Attest)?; + Ok(a3s_box_runtime::AttestationClient::connect(&socket) + .await? + .get_report(request) + .await?) + } + + fn load_state(&self) -> Result { + Ok(StateFile::load(&self.paths.boxes_file)?) + } + + fn signal_box_status_transition( + &self, + query: &str, + transition: LifecycleTransition, + ) -> Result { + let state = self.load_state()?; + let record = resolve_required_record(&state, query)?.clone(); + transition.validate_status(&record)?; + let pid = require_live_pid(&record, transition.action())?; + send_host_signal(pid, transition.signal()) + .map_err(|error| ClientError::Validation(error.to_string()))?; + + let record_id = record.id.clone(); + let updated = StateFile::modify(&self.paths.boxes_file, |state| { + let Some(record) = state.find_by_id_mut(&record_id) else { + return Ok(None); + }; + record.status = transition.target_status().to_string(); + Ok(Some(BoxSummary::from_record(record))) + })?; + + updated.ok_or(ClientError::BoxNotFound(record_id)) + } + + /// Open the runtime image store rooted at this client's state paths. + pub fn open_image_store(&self) -> Result { + Ok(ImageStore::new( + &self.paths.images_dir, + self.image_cache_size, + )?) + } + + /// Open the runtime volume store rooted at this client's state paths. + pub fn volume_store(&self) -> VolumeStore { + VolumeStore::new(&self.paths.volumes_file, &self.paths.volumes_dir) + } + + /// Open the runtime network store rooted at this client's state paths. + pub fn network_store(&self) -> NetworkStore { + NetworkStore::new(&self.paths.networks_file) + } + + /// Open the runtime snapshot store rooted at this client's state paths. + pub fn snapshot_store(&self) -> Result { + Ok(SnapshotStore::new(&self.paths.snapshots_dir)?) + } + + #[cfg(unix)] + fn require_runtime_socket(&self, query: &str, socket: RuntimeSocket) -> Result { + let state = self.load_state()?; + let record = resolve_required_record(&state, query)?; + require_running(record, socket.action())?; + let path = runtime_socket(record, socket); + if path.exists() { + return Ok(path); + } + + Err(ClientError::Validation(format!( + "{} socket is missing for running box {} at {}. The box state may be stale or the guest control channel is not ready; reconcile state and restart the box if the socket is still missing.", + socket.label(), + record.name, + path.display(), + ))) + } +} + +impl Default for A3sBoxClient { + fn default() -> Self { + Self::with_paths(A3sBoxPaths::default()) + } +} diff --git a/src/sdk/src/client/lifecycle.rs b/src/sdk/src/client/lifecycle.rs new file mode 100644 index 00000000..e1fcb954 --- /dev/null +++ b/src/sdk/src/client/lifecycle.rs @@ -0,0 +1,98 @@ +impl A3sBoxClient { + /// Persist an unstarted execution through the canonical lifecycle facade. + pub async fn create_box( + &self, + request: CreateExecutionRequest, + operation_id: &OperationId, + ) -> Result { + Ok(self.execution_manager.create(request, operation_id).await?) + } + + /// Start a previously created execution with generation fencing. + pub async fn start_box( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> Result { + Ok(self + .execution_manager + .start(execution_id, generation) + .await?) + } + + /// Atomically create or recover and then start an execution. + pub async fn run_box( + &self, + request: CreateExecutionRequest, + operation_id: &OperationId, + ) -> Result { + Ok(self + .execution_manager + .create_and_start(request, operation_id) + .await?) + } + + /// Inspect the generation-fenced state of a managed execution. + pub async fn inspect_execution(&self, execution_id: &ExecutionId) -> Result { + Ok(self.execution_manager.inspect(execution_id).await?) + } + + /// Pause a managed execution through its resolved backend. + pub async fn pause_execution( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + keep_memory: bool, + ) -> Result { + Ok(self + .execution_manager + .pause(execution_id, generation, keep_memory) + .await?) + } + + /// Resume a managed execution through its resolved backend. + pub async fn resume_execution( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> Result { + Ok(self + .execution_manager + .resume(execution_id, generation) + .await?) + } + + /// Restart a managed execution under an idempotent operation identity. + pub async fn restart_execution( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + operation_id: &OperationId, + options: RestartExecutionOptions, + ) -> Result { + Ok(self + .execution_manager + .restart_with_options(execution_id, generation, operation_id, options) + .await?) + } + + /// Kill a managed execution and release runtime-owned resources. + pub async fn kill_execution( + &self, + execution_id: &ExecutionId, + generation: ExecutionGeneration, + ) -> Result { + Ok(self + .execution_manager + .kill(execution_id, generation) + .await?) + } + + /// Reconcile one idempotent create operation after caller or service restart. + pub async fn reconcile_operation( + &self, + operation_id: &OperationId, + ) -> Result { + Ok(self.execution_manager.reconcile(operation_id).await?) + } +} diff --git a/src/sdk/src/client/mod.rs b/src/sdk/src/client/mod.rs new file mode 100644 index 00000000..3d7548c4 --- /dev/null +++ b/src/sdk/src/client/mod.rs @@ -0,0 +1,49 @@ +//! Direct runtime-backed management API for a3s-box. +//! +//! This module intentionally returns typed Rust data instead of parsing CLI +//! tables or JSON text. Container metadata is read from the shared `boxes.json` +//! state model, while image, volume, network, and snapshot operations call +//! `a3s-box-runtime` stores directly. + +use std::collections::{HashMap, HashSet}; +use std::io::BufRead; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Instant; + +use a3s_box_core::log::{is_runtime_console_noise, json_log_path, LogDriver, LogEntry}; +use a3s_box_core::network::{IsolationMode, NetworkConfig, NetworkEndpoint, NetworkMode}; +use a3s_box_core::platform::Platform; +use a3s_box_core::snapshot::SnapshotMetadata; +use a3s_box_core::vmm::parse_signal_name; +use a3s_box_core::volume::VolumeConfig; +use a3s_box_core::{ + CreateExecutionRequest, ExecOutput, ExecRequest, ExecutionGeneration, ExecutionId, + ExecutionLease, ExecutionManager, ExecutionReservation, ExecutionStatus, FileRequest, + FileResponse, KillOutcome, OperationId, ReconcileOutcome, RestartExecutionOptions, StoredImage, +}; +use a3s_box_runtime::oci::BuildResult as RuntimeBuildResult; +use a3s_box_runtime::{ + is_process_alive, is_process_alive_with_identity, load_resolved_image_config, + BuildConfig as RuntimeBuildConfig, ImagePuller, ImageReference, ImageStore, NetworkStore, + OciImage, PushResult, RegistryAuth, RegistryProtocol, RegistryPusher, SignaturePolicy, + SnapshotStore, VolumeStore, +}; +use serde::{Deserialize, Serialize}; +use sysinfo::{Pid, System}; + +#[cfg(all(test, unix))] +use a3s_box_runtime::pid_start_time; +#[cfg(unix)] +use a3s_box_runtime::{AttestationReport, AttestationRequest, ExecClient, PtyClient}; + +use crate::box_state::{BoxRecord, StateFile}; + +include!("types.rs"); +include!("summaries.rs"); +include!("core.rs"); +include!("lifecycle.rs"); +include!("support.rs"); + +#[cfg(test)] +mod tests; diff --git a/src/sdk/src/client/summaries.rs b/src/sdk/src/client/summaries.rs new file mode 100644 index 00000000..06c58218 --- /dev/null +++ b/src/sdk/src/client/summaries.rs @@ -0,0 +1,529 @@ +/// Typed box summary for management UIs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoxSummary { + pub id: String, + pub short_id: String, + pub name: String, + pub image: String, + pub isolation: a3s_box_core::ExecutionIsolation, + pub status: String, + pub status_summary: String, + pub active: bool, + pub pid: Option, + pub cpus: u32, + pub memory_mb: u32, + pub ports: Vec, + pub command: Vec, + pub health: String, + pub labels: HashMap, + pub created_at: String, + pub started_at: Option, + pub network_name: Option, + pub volume_names: Vec, +} + +impl BoxSummary { + fn from_record(record: &BoxRecord) -> Self { + Self { + id: record.id.clone(), + short_id: record.short_id.clone(), + name: record.name.clone(), + image: record.image.clone(), + isolation: record.isolation, + status: record.status.clone(), + status_summary: record.status_summary(), + active: record.is_active(), + pid: record.pid, + cpus: record.cpus, + memory_mb: record.memory_mb, + ports: record.port_map.clone(), + command: record.cmd.clone(), + health: record.health_status.clone(), + labels: record.labels.clone(), + created_at: record.created_at.to_rfc3339(), + started_at: record.started_at.map(|ts| ts.to_rfc3339()), + network_name: record.network_name.clone(), + volume_names: record.volume_names.clone(), + } + } +} + +/// One decoded box log line. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoxLogLine { + pub stream: String, + pub timestamp: Option, + pub message: String, +} + +/// Host-side resource usage snapshot for one active box. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BoxStatsSummary { + pub id: String, + pub short_id: String, + pub name: String, + pub status: String, + pub pid: u32, + pub cpus: u32, + pub cpu_percent: f32, + pub cpu_percent_scaled: f64, + pub memory_bytes: u64, + pub memory_limit_bytes: u64, + pub memory_percent: f64, + pub network_rx_bytes: u64, + pub network_tx_bytes: u64, + pub block_read_bytes: u64, + pub block_write_bytes: u64, +} + +/// Local runtime diagnostics suitable for status bars and diagnostics panes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeDiagnostics { + pub core_version: String, + pub runtime_version: String, + pub sdk_version: String, + pub home: PathBuf, + pub virtualization: RuntimeVirtualizationSummary, +} + +impl RuntimeDiagnostics { + fn collect(paths: &A3sBoxPaths) -> Self { + Self { + core_version: a3s_box_core::VERSION.to_string(), + runtime_version: a3s_box_runtime::VERSION.to_string(), + sdk_version: env!("CARGO_PKG_VERSION").to_string(), + home: paths.home.clone(), + virtualization: RuntimeVirtualizationSummary::collect(), + } + } +} + +/// Local disk usage grouped by runtime-owned state areas. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeDiskUsage { + pub home: PathBuf, + pub total_bytes: u64, + pub boxes_bytes: u64, + pub images_bytes: u64, + pub volumes_bytes: u64, + pub snapshots_bytes: u64, + pub state_bytes: u64, + pub other_bytes: u64, +} + +impl RuntimeDiskUsage { + fn collect(paths: &A3sBoxPaths) -> Result { + let boxes_dir = paths.home.join("boxes"); + let boxes_bytes = disk_usage_path(&boxes_dir)?; + let images_bytes = disk_usage_path(&paths.images_dir)?; + let volumes_bytes = disk_usage_path(&paths.volumes_dir)?; + let snapshots_bytes = disk_usage_path(&paths.snapshots_dir)?; + let state_bytes = disk_usage_paths(&[ + paths.boxes_file.as_path(), + paths.volumes_file.as_path(), + paths.networks_file.as_path(), + ])?; + + let known_bytes = boxes_bytes + .saturating_add(images_bytes) + .saturating_add(volumes_bytes) + .saturating_add(snapshots_bytes) + .saturating_add(state_bytes); + let known_home_bytes = [ + (boxes_dir.as_path(), boxes_bytes), + (paths.images_dir.as_path(), images_bytes), + (paths.volumes_dir.as_path(), volumes_bytes), + (paths.snapshots_dir.as_path(), snapshots_bytes), + ( + paths.boxes_file.as_path(), + file_size_or_zero(&paths.boxes_file)?, + ), + ( + paths.volumes_file.as_path(), + file_size_or_zero(&paths.volumes_file)?, + ), + ( + paths.networks_file.as_path(), + file_size_or_zero(&paths.networks_file)?, + ), + ] + .into_iter() + .filter(|(path, _)| path.starts_with(&paths.home)) + .map(|(_, bytes)| bytes) + .fold(0u64, u64::saturating_add); + let home_bytes = disk_usage_path(&paths.home)?; + let other_bytes = home_bytes.saturating_sub(known_home_bytes); + + Ok(Self { + home: paths.home.clone(), + total_bytes: known_bytes.saturating_add(other_bytes), + boxes_bytes, + images_bytes, + volumes_bytes, + snapshots_bytes, + state_bytes, + other_bytes, + }) + } +} + +/// Host virtualization status reported by the runtime support checker. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeVirtualizationSummary { + pub available: bool, + pub backend: Option, + pub details: String, +} + +impl RuntimeVirtualizationSummary { + fn collect() -> Self { + match a3s_box_runtime::check_virtualization_support() { + Ok(support) => Self { + available: true, + backend: Some(support.backend), + details: support.details, + }, + Err(error) => Self { + available: false, + backend: None, + details: error.to_string(), + }, + } + } +} + +/// Typed image summary for management UIs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageSummary { + pub reference: String, + pub digest: String, + pub size_bytes: u64, + pub pulled_at: String, + pub last_used: String, + pub path: PathBuf, +} + +impl From for ImageSummary { + fn from(image: StoredImage) -> Self { + Self { + reference: image.reference, + digest: image.digest, + size_bytes: image.size_bytes, + pulled_at: image.pulled_at.to_rfc3339(), + last_used: image.last_used.to_rfc3339(), + path: image.path, + } + } +} + +/// Detailed local OCI image metadata for management UIs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageInspectSummary { + pub reference: String, + pub digest: String, + pub size_bytes: u64, + pub pulled_at: String, + pub last_used: String, + pub path: PathBuf, + pub manifest_digest: String, + pub layer_count: usize, + pub entrypoint: Option>, + pub command: Option>, + pub env: HashMap, + pub working_dir: Option, + pub user: Option, + pub exposed_ports: Vec, + pub volumes: Vec, + pub stop_signal: Option, + pub health_check: Option, + pub onbuild: Vec, + pub labels: HashMap, +} + +impl ImageInspectSummary { + fn from_stored_image(image: StoredImage) -> Result { + let oci = OciImage::from_path(&image.path)?; + let config = oci.config(); + Ok(Self { + reference: image.reference, + digest: image.digest, + size_bytes: image.size_bytes, + pulled_at: image.pulled_at.to_rfc3339(), + last_used: image.last_used.to_rfc3339(), + path: image.path, + manifest_digest: oci.manifest_digest().to_string(), + layer_count: oci.layer_paths().len(), + entrypoint: config.entrypoint.clone(), + command: config.cmd.clone(), + env: config.env.iter().cloned().collect(), + working_dir: config.working_dir.clone(), + user: config.user.clone(), + exposed_ports: config.exposed_ports.clone(), + volumes: config.volumes.clone(), + stop_signal: config.stop_signal.clone(), + health_check: config + .health_check + .clone() + .map(ImageHealthCheckSummary::from), + onbuild: config.onbuild.clone(), + labels: config.labels.clone(), + }) + } +} + +/// Docker-compatible image health check metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageHealthCheckSummary { + pub test: Vec, + pub interval: Option, + pub timeout: Option, + pub retries: Option, + pub start_period: Option, +} + +impl From for ImageHealthCheckSummary { + fn from(health_check: a3s_box_runtime::oci::OciHealthCheck) -> Self { + Self { + test: health_check.test, + interval: health_check.interval, + timeout: health_check.timeout, + retries: health_check.retries, + start_period: health_check.start_period, + } + } +} + +/// One OCI image history entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageHistoryEntry { + pub created: Option, + pub created_by: String, + pub size_bytes: u64, + pub comment: String, + pub empty_layer: bool, +} + +/// Request to create a named volume. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateVolume { + pub name: String, + pub driver: String, + pub labels: HashMap, + pub size_limit: u64, +} + +impl CreateVolume { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + driver: "local".to_string(), + labels: HashMap::new(), + size_limit: 0, + } + } + + pub fn driver(mut self, driver: impl Into) -> Self { + self.driver = driver.into(); + self + } + + pub fn label(mut self, key: impl Into, value: impl Into) -> Self { + self.labels.insert(key.into(), value.into()); + self + } + + pub fn size_limit(mut self, bytes: u64) -> Self { + self.size_limit = bytes; + self + } + + fn validate(&self) -> Result<()> { + validate_name("volume", &self.name)?; + if self.driver != "local" { + return Err(ClientError::Validation(format!( + "unsupported volume driver '{}'; only 'local' is supported", + self.driver + ))); + } + Ok(()) + } +} + +/// Typed volume summary for management UIs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VolumeSummary { + pub name: String, + pub driver: String, + pub mount_point: String, + pub labels: HashMap, + pub in_use_by: Vec, + pub in_use: bool, + pub size_limit: u64, + pub created_at: String, +} + +impl From for VolumeSummary { + fn from(volume: VolumeConfig) -> Self { + Self { + in_use: volume.is_in_use(), + name: volume.name, + driver: volume.driver, + mount_point: volume.mount_point, + labels: volume.labels, + in_use_by: volume.in_use_by, + size_limit: volume.size_limit, + created_at: volume.created_at, + } + } +} + +/// Request to create a network. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateNetwork { + pub name: String, + pub subnet: String, + pub driver: String, + pub labels: HashMap, + pub isolation: IsolationMode, +} + +impl CreateNetwork { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + subnet: "10.89.0.0/24".to_string(), + driver: "bridge".to_string(), + labels: HashMap::new(), + isolation: IsolationMode::None, + } + } + + pub fn subnet(mut self, subnet: impl Into) -> Self { + self.subnet = subnet.into(); + self + } + + pub fn driver(mut self, driver: impl Into) -> Self { + self.driver = driver.into(); + self + } + + pub fn label(mut self, key: impl Into, value: impl Into) -> Self { + self.labels.insert(key.into(), value.into()); + self + } + + pub fn isolation(mut self, isolation: IsolationMode) -> Self { + self.isolation = isolation; + self + } + + fn validate(&self) -> Result<()> { + validate_name("network", &self.name)?; + if self.driver != "bridge" { + return Err(ClientError::Validation(format!( + "unsupported network driver '{}'; only 'bridge' is supported", + self.driver + ))); + } + Ok(()) + } +} + +/// Typed network summary for management UIs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NetworkSummary { + pub name: String, + pub driver: String, + pub subnet: String, + pub gateway: String, + pub labels: HashMap, + pub endpoints: Vec, + pub endpoint_count: usize, + pub isolation: String, + pub created_at: String, +} + +impl From for NetworkSummary { + fn from(network: NetworkConfig) -> Self { + let mut endpoints = network + .endpoints + .into_values() + .map(NetworkEndpointSummary::from) + .collect::>(); + endpoints.sort_by(|a, b| a.box_name.cmp(&b.box_name)); + Self { + name: network.name, + driver: network.driver, + subnet: network.subnet, + gateway: network.gateway.to_string(), + labels: network.labels, + endpoint_count: endpoints.len(), + endpoints, + isolation: format!("{:?}", network.policy.isolation).to_lowercase(), + created_at: network.created_at, + } + } +} + +/// Typed network endpoint summary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NetworkEndpointSummary { + pub box_id: String, + pub box_name: String, + pub aliases: Vec, + pub ip_address: String, + pub mac_address: String, +} + +impl From for NetworkEndpointSummary { + fn from(endpoint: NetworkEndpoint) -> Self { + Self { + box_id: endpoint.box_id, + box_name: endpoint.box_name, + aliases: endpoint.aliases, + ip_address: endpoint.ip_address.to_string(), + mac_address: endpoint.mac_address, + } + } +} + +/// Typed snapshot summary for management UIs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SnapshotSummary { + pub id: String, + pub name: String, + pub source_box_id: String, + pub image: String, + pub vcpus: u32, + pub memory_mb: u32, + pub volumes: Vec, + pub command: Vec, + pub port_map: Vec, + pub labels: HashMap, + pub network_mode: Option, + pub size_bytes: u64, + pub created_at: String, + pub description: String, +} + +impl From for SnapshotSummary { + fn from(snapshot: SnapshotMetadata) -> Self { + Self { + id: snapshot.id, + name: snapshot.name, + source_box_id: snapshot.source_box_id, + image: snapshot.image, + vcpus: snapshot.vcpus, + memory_mb: snapshot.memory_mb, + volumes: snapshot.volumes, + command: snapshot.cmd, + port_map: snapshot.port_map, + labels: snapshot.labels, + network_mode: snapshot.network_mode, + size_bytes: snapshot.size_bytes, + created_at: snapshot.created_at.to_rfc3339(), + description: snapshot.description, + } + } +} diff --git a/src/sdk/src/client/support.rs b/src/sdk/src/client/support.rs new file mode 100644 index 00000000..5a559fed --- /dev/null +++ b/src/sdk/src/client/support.rs @@ -0,0 +1,910 @@ +fn resolve_stored_image(images: &[StoredImage], query: &str) -> Result> { + let query = query.trim(); + if query.is_empty() { + return Ok(None); + } + + for mode in [ + ImageMatchMode::Exact, + ImageMatchMode::Alias, + ImageMatchMode::Digest, + ] { + let matches = matching_images(images, query, mode); + match matches.as_slice() { + [] => {} + [image] => return Ok(Some(image.clone())), + _ => { + return Err(ClientError::Validation(ambiguous_image_error( + query, &matches, + ))) + } + } + } + + Ok(None) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ImageMatchMode { + Exact, + Alias, + Digest, +} + +fn matching_images(images: &[StoredImage], query: &str, mode: ImageMatchMode) -> Vec { + let query_aliases = image_reference_aliases(query); + let mut matches = Vec::new(); + + for image in images { + let matched = match mode { + ImageMatchMode::Exact => image.reference == query, + ImageMatchMode::Alias => { + let image_aliases = image_reference_aliases(&image.reference); + !image_aliases.is_disjoint(&query_aliases) + } + ImageMatchMode::Digest => { + is_digest_reference(query) && digest_matches(&image.digest, query) + } + }; + + if matched + && !matches + .iter() + .any(|candidate: &StoredImage| candidate.reference == image.reference) + { + matches.push(image.clone()); + } + } + + matches +} + +fn ambiguous_image_error(query: &str, matches: &[StoredImage]) -> String { + let mut references = matches + .iter() + .map(|image| image.reference.as_str()) + .collect::>(); + references.sort_unstable(); + format!( + "image reference '{query}' is ambiguous; it matches: {}", + references.join(", ") + ) +} + +fn image_reference_aliases(reference: &str) -> HashSet { + let mut aliases = HashSet::new(); + let reference = reference.trim(); + if reference.is_empty() { + return aliases; + } + + aliases.insert(reference.to_string()); + if !is_digest_reference(reference) { + if let Ok(parsed) = ImageReference::parse(reference) { + aliases.insert(parsed.full_reference()); + } + } + aliases +} + +fn is_digest_reference(reference: &str) -> bool { + if reference.starts_with("sha256:") { + return true; + } + let len = reference.len(); + (12..=64).contains(&len) + && reference + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn digest_matches(stored_digest: &str, query: &str) -> bool { + if stored_digest == query { + return true; + } + let query_hex = query.strip_prefix("sha256:").unwrap_or(query); + if query_hex.is_empty() { + return false; + } + let stored_hex = stored_digest + .strip_prefix("sha256:") + .unwrap_or(stored_digest); + stored_hex.starts_with(query_hex) +} + +fn validate_tag_target(target: &str) -> Result<()> { + let target = target.trim(); + if target.is_empty() { + return Err(ClientError::Validation( + "target image reference cannot be empty".to_string(), + )); + } + + let without_digest = target.split('@').next().unwrap_or(target); + let last_slash = without_digest.rfind('/'); + let repo = match without_digest.rfind(':') { + Some(colon) if last_slash.is_none_or(|slash| colon > slash) => &without_digest[..colon], + _ => without_digest, + }; + if repo.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(ClientError::Validation(format!( + "invalid reference format: repository name must be lowercase: '{target}'" + ))); + } + + ImageReference::parse(target).map_err(ClientError::Runtime)?; + Ok(()) +} + +fn load_image_history(image_dir: &Path) -> Result> { + let config = load_image_config_json(image_dir)?; + let layer_sizes = load_image_layer_sizes(image_dir)?; + let history = config + .get("history") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + let mut layer_index = 0usize; + + Ok(history + .into_iter() + .map(|entry| { + let empty_layer = entry + .get("empty_layer") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let size_bytes = if empty_layer { + 0 + } else { + let size = layer_sizes.get(layer_index).copied().unwrap_or(0); + layer_index += 1; + size + }; + ImageHistoryEntry { + created: entry + .get("created") + .and_then(|value| value.as_str()) + .map(str::to_string), + created_by: entry + .get("created_by") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(), + size_bytes, + comment: entry + .get("comment") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(), + empty_layer, + } + }) + .collect()) +} + +fn load_image_layer_sizes(image_dir: &Path) -> Result> { + let manifest = load_image_manifest_json(image_dir)?; + Ok(manifest + .get("layers") + .and_then(|value| value.as_array()) + .map(|layers| { + layers + .iter() + .map(|layer| { + layer + .get("size") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + }) + .collect() + }) + .unwrap_or_default()) +} + +fn load_image_config_json(image_dir: &Path) -> Result { + let manifest = load_image_manifest_json(image_dir)?; + let config_digest = manifest + .get("config") + .and_then(|config| config.get("digest")) + .and_then(|value| value.as_str()) + .ok_or_else(|| image_layout_error("No config digest in image manifest"))?; + read_json_file(&blob_path(image_dir, config_digest)) +} + +fn load_image_manifest_json(image_dir: &Path) -> Result { + let index = read_json_file(&image_dir.join("index.json"))?; + let manifest_digest = index + .get("manifests") + .and_then(|value| value.as_array()) + .and_then(|manifests| manifests.first()) + .and_then(|manifest| manifest.get("digest")) + .and_then(|value| value.as_str()) + .ok_or_else(|| image_layout_error("No manifest digest in image index"))?; + read_json_file(&blob_path(image_dir, manifest_digest)) +} + +fn blob_path(root_dir: &Path, digest: &str) -> PathBuf { + let (algorithm, hash) = digest.split_once(':').unwrap_or(("sha256", digest)); + root_dir.join("blobs").join(algorithm).join(hash) +} + +fn read_json_file(path: &Path) -> Result { + let data = std::fs::read_to_string(path).map_err(|error| { + image_layout_error(format!( + "failed to read image JSON {}: {error}", + path.display() + )) + })?; + serde_json::from_str(&data).map_err(|error| { + image_layout_error(format!( + "failed to parse image JSON {}: {error}", + path.display() + )) + }) +} + +fn image_layout_error(message: impl Into) -> ClientError { + ClientError::Runtime(a3s_box_core::error::BoxError::OciImageError(message.into())) +} + +fn resolve_record<'a>(state: &'a StateFile, query: &str) -> Option> { + if let Some(record) = state + .find_by_id(query) + .or_else(|| state.find_by_name(query)) + { + return Some(Ok(record)); + } + + let matches = state.find_by_id_prefix(query); + match matches.as_slice() { + [] => None, + [record] => Some(Ok(*record)), + records => Some(Err(ClientError::AmbiguousBoxQuery { + query: query.to_string(), + matches: records.iter().map(|record| record.name.clone()).collect(), + })), + } +} + +fn resolve_required_record<'a>(state: &'a StateFile, query: &str) -> Result<&'a BoxRecord> { + resolve_record(state, query) + .transpose()? + .ok_or_else(|| ClientError::BoxNotFound(query.to_string())) +} + +fn resolve_snapshot_metadata(store: &SnapshotStore, query: &str) -> Result { + if let Some(metadata) = store.get(query)? { + return Ok(metadata); + } + + let matches = store + .list()? + .into_iter() + .filter(|snapshot| snapshot.name == query) + .collect::>(); + + match matches.as_slice() { + [] => Err(ClientError::Validation(format!( + "snapshot {query:?} was not found" + ))), + [metadata] => Ok(metadata.clone()), + snapshots => Err(ClientError::Validation(format!( + "snapshot query {query:?} matched multiple snapshots: {:?}", + snapshots + .iter() + .map(|snapshot| snapshot.id.clone()) + .collect::>() + ))), + } +} + +fn default_restored_box_name(state: &StateFile, metadata: &SnapshotMetadata) -> String { + let base = format!("{}-restore", metadata.name); + if state.find_by_name(&base).is_none() { + return base; + } + + for index in 2.. { + let candidate = format!("{base}-{index}"); + if state.find_by_name(&candidate).is_none() { + return candidate; + } + } + + unreachable!("unbounded restored box name search should always find a free suffix") +} + +fn require_inactive_for_network_change(record: &BoxRecord, action: &str) -> Result<()> { + if !record.is_active() { + return Ok(()); + } + + Err(ClientError::Validation(format!( + "cannot {action} box {} while it is {}; stop it first", + record.name, record.status + ))) +} + +fn is_prunable_box_record(record: &BoxRecord) -> bool { + matches!(record.status.as_str(), "created" | "stopped" | "dead") +} + +fn require_active(record: &BoxRecord, action: &str) -> Result<()> { + if record.is_active() { + return Ok(()); + } + + Err(ClientError::Validation(format!( + "cannot {action} box {} because it is {}", + record.name, record.status + ))) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LifecycleTransition { + Pause, + Unpause, +} + +impl LifecycleTransition { + fn action(self) -> &'static str { + match self { + Self::Pause => "pause", + Self::Unpause => "unpause", + } + } + + fn source_status(self) -> &'static str { + match self { + Self::Pause => "running", + Self::Unpause => "paused", + } + } + + fn target_status(self) -> &'static str { + match self { + Self::Pause => "paused", + Self::Unpause => "running", + } + } + + #[cfg(unix)] + fn signal(self) -> i32 { + match self { + Self::Pause => libc::SIGSTOP, + Self::Unpause => libc::SIGCONT, + } + } + + #[cfg(not(unix))] + fn signal(self) -> i32 { + let _ = self; + 0 + } + + fn validate_status(self, record: &BoxRecord) -> Result<()> { + if record.status == self.source_status() { + return Ok(()); + } + + Err(ClientError::Validation(format!( + "cannot {} box {} because it is {}", + self.action(), + record.name, + record.status + ))) + } +} + +fn require_live_pid(record: &BoxRecord, action: &str) -> Result { + match record.pid { + Some(pid) if is_process_alive_with_identity(pid, record.pid_start_time) => Ok(pid), + Some(pid) => Err(ClientError::Validation(format!( + "cannot {action} box {} because its recorded PID {pid} is not running", + record.name + ))), + None => Err(ClientError::Validation(format!( + "cannot {action} box {} because it has no recorded PID", + record.name + ))), + } +} + +#[cfg(unix)] +fn send_host_signal(pid: u32, signal: i32) -> std::io::Result<()> { + let result = unsafe { libc::kill(pid as i32, signal) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(not(unix))] +fn send_host_signal(_pid: u32, _signal: i32) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "host process signals are not supported on this platform", + )) +} + +#[cfg(unix)] +fn terminate_recorded_process(record: &BoxRecord) { + if let Some(pid) = record.pid { + if is_process_alive_with_identity(pid, record.pid_start_time) { + let _ = send_host_signal(pid, libc::SIGKILL); + } + } +} + +#[cfg(not(unix))] +fn terminate_recorded_process(_record: &BoxRecord) {} + +fn stopped_exit_code( + previous_exit_code: Option, + outcome: StopOutcome, + stop_signal: i32, +) -> Option { + outcome + .inferred_exit_code(stop_signal) + .or(previous_exit_code) +} + +#[cfg(unix)] +async fn graceful_stop(pid: u32, signal: i32, timeout_secs: u64) -> StopOutcome { + if !is_process_alive(pid) { + return StopOutcome::AlreadyExited; + } + + if send_host_signal(pid, signal).is_err() && !is_process_alive(pid) { + return StopOutcome::AlreadyExited; + } + + wait_for_exit_or_kill(pid, timeout_secs).await +} + +#[cfg(unix)] +async fn graceful_stop_via_guest( + pid: u32, + exec_socket: &Path, + signal: i32, + timeout_secs: u64, +) -> StopOutcome { + if !is_process_alive(pid) { + return StopOutcome::AlreadyExited; + } + + let delivered = match ExecClient::connect(exec_socket).await { + Ok(client) => client.signal_main(signal).await.unwrap_or(false), + Err(_) => false, + }; + if !delivered { + return graceful_stop(pid, signal, timeout_secs).await; + } + + wait_for_exit_or_kill(pid, timeout_secs).await +} + +#[cfg(unix)] +async fn wait_for_exit_or_kill(pid: u32, timeout_secs: u64) -> StopOutcome { + let start = Instant::now(); + let timeout_ms = timeout_secs.saturating_mul(1000); + loop { + if !is_process_alive(pid) { + return StopOutcome::GracefulExit; + } + if start.elapsed().as_millis() >= timeout_ms as u128 { + let _ = send_host_signal(pid, libc::SIGKILL); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + return StopOutcome::ForceKilled; + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } +} + +fn cleanup_stopped_box(paths: &A3sBoxPaths, record: &BoxRecord) { + detach_volumes(paths, &record.volume_names, &record.id); + a3s_box_runtime::rootfs::unmount_box_overlay(&record.box_dir.join("merged")); + cleanup_external_socket_dir(&record.box_dir, &record.exec_socket_path); + remove_host_cgroup(&record.id); +} + +fn cleanup_removed_box(paths: &A3sBoxPaths, record: &BoxRecord) { + detach_volumes(paths, &record.volume_names, &record.id); + cleanup_network_endpoint(paths, record); + cleanup_anonymous_volumes(paths, &record.anonymous_volumes); + remove_host_cgroup(&record.id); + if record.box_dir.exists() { + a3s_box_runtime::rootfs::unmount_box_overlay(&record.box_dir.join("merged")); + let _ = std::fs::remove_dir_all(&record.box_dir); + } + cleanup_external_socket_dir(&record.box_dir, &record.exec_socket_path); + + let fs_mount_dir = std::env::temp_dir().join(format!("a3s-fs-mount-{}", record.id)); + if fs_mount_dir.exists() { + let _ = std::fs::remove_dir_all(fs_mount_dir); + } +} + +fn detach_volumes(paths: &A3sBoxPaths, volume_names: &[String], box_id: &str) { + let store = VolumeStore::new(&paths.volumes_file, &paths.volumes_dir); + for volume_name in volume_names { + let _ = store.modify(volume_name, |config| { + config.in_use_by.retain(|id| id != box_id); + }); + } +} + +fn cleanup_anonymous_volumes(paths: &A3sBoxPaths, volume_names: &[String]) { + let store = VolumeStore::new(&paths.volumes_file, &paths.volumes_dir); + for volume_name in volume_names { + let _ = store.remove(volume_name, true); + } +} + +fn cleanup_network_endpoint(paths: &A3sBoxPaths, record: &BoxRecord) { + let Some(network_name) = record_network_name(record).map(str::to_string) else { + return; + }; + let store = NetworkStore::new(&paths.networks_file); + let _ = store.with_write_lock( + |networks| -> std::result::Result<(), a3s_box_core::error::BoxError> { + if let Some(network) = networks.get_mut(&network_name) { + let _ = network.disconnect(&record.id); + } + Ok(()) + }, + ); +} + +fn cleanup_external_socket_dir(box_dir: &Path, exec_socket_path: &Path) { + let Some(socket_dir) = exec_socket_path.parent() else { + return; + }; + #[cfg(target_os = "linux")] + a3s_box_runtime::network::terminate_passt(socket_dir); + if socket_dir.starts_with(box_dir) { + return; + } + let _ = std::fs::remove_dir_all(socket_dir); +} + +fn remove_host_cgroup(box_id: &str) { + #[cfg(target_os = "linux")] + { + let _ = std::fs::remove_dir(format!("/sys/fs/cgroup/a3s-box/{box_id}")); + } + #[cfg(not(target_os = "linux"))] + let _ = box_id; +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RuntimeSocket { + Exec, + Pty, + Attest, +} + +#[cfg(unix)] +impl RuntimeSocket { + fn file_name(self) -> &'static str { + match self { + Self::Exec => "exec.sock", + Self::Pty => "pty.sock", + Self::Attest => "attest.sock", + } + } + + fn label(self) -> &'static str { + match self { + Self::Exec => "exec", + Self::Pty => "PTY", + Self::Attest => "attestation", + } + } + + fn action(self) -> &'static str { + match self { + Self::Exec => "exec in", + Self::Pty => "open a PTY in", + Self::Attest => "request attestation from", + } + } +} + +#[cfg(unix)] +fn require_running(record: &BoxRecord, action: &str) -> Result<()> { + if record.status == "running" { + return Ok(()); + } + + Err(ClientError::Validation(format!( + "cannot {action} box {} because it is {}", + record.name, record.status + ))) +} + +#[cfg(unix)] +fn sibling_socket(record: &BoxRecord, socket_name: &str) -> PathBuf { + if let Some(parent) = record.exec_socket_path.parent() { + return parent.join(socket_name); + } + record.box_dir.join("sockets").join(socket_name) +} + +#[cfg(unix)] +fn exec_socket(record: &BoxRecord) -> PathBuf { + if !record.exec_socket_path.as_os_str().is_empty() { + return record.exec_socket_path.clone(); + } + record.box_dir.join("sockets").join("exec.sock") +} + +#[cfg(unix)] +fn runtime_socket(record: &BoxRecord, socket: RuntimeSocket) -> PathBuf { + match socket { + RuntimeSocket::Exec => exec_socket(record), + RuntimeSocket::Pty | RuntimeSocket::Attest => sibling_socket(record, socket.file_name()), + } +} + +fn record_network_name(record: &BoxRecord) -> Option<&str> { + record + .network_name + .as_deref() + .or(match &record.network_mode { + NetworkMode::Bridge { network } => Some(network.as_str()), + _ => None, + }) +} + +fn is_predefined_network(name: &str) -> bool { + matches!(name, "bridge" | "host" | "none") +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct NetworkStats { + rx_bytes: u64, + tx_bytes: u64, +} + +fn collect_box_stats(records: &[&BoxRecord]) -> Vec { + let pids = records + .iter() + .filter_map(|record| record.pid) + .map(Pid::from_u32) + .collect::>(); + + if pids.is_empty() { + return Vec::new(); + } + + let mut system = System::new(); + for pid in &pids { + system.refresh_process(*pid); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + for pid in &pids { + system.refresh_process(*pid); + } + + records + .iter() + .filter_map(|record| build_box_stats(&system, record)) + .collect() +} + +fn build_box_stats(system: &System, record: &BoxRecord) -> Option { + let pid = record.pid?; + let process = system.process(Pid::from_u32(pid))?; + let disk = process.disk_usage(); + let memory_limit_bytes = record.memory_mb as u64 * 1024 * 1024; + let memory_bytes = process.memory(); + let cpu_percent = process.cpu_usage(); + let cpu_percent_scaled = cpu_percent as f64 / record.cpus.max(1) as f64; + let memory_percent = if memory_limit_bytes > 0 { + memory_bytes as f64 / memory_limit_bytes as f64 * 100.0 + } else { + 0.0 + }; + let network = collect_network_stats(record); + + Some(BoxStatsSummary { + id: record.id.clone(), + short_id: record.short_id.clone(), + name: record.name.clone(), + status: record.status.clone(), + pid, + cpus: record.cpus, + cpu_percent, + cpu_percent_scaled, + memory_bytes, + memory_limit_bytes, + memory_percent, + network_rx_bytes: network.rx_bytes, + network_tx_bytes: network.tx_bytes, + block_read_bytes: disk.total_read_bytes, + block_write_bytes: disk.total_written_bytes, + }) +} + +fn collect_network_stats(record: &BoxRecord) -> NetworkStats { + read_network_stats_file(&record.box_dir.join("sockets").join("net.stats.json")) + .unwrap_or_default() +} + +fn read_network_stats_file(path: &std::path::Path) -> Option { + let data = std::fs::read_to_string(path).ok()?; + let json: serde_json::Value = serde_json::from_str(&data).ok()?; + Some(NetworkStats { + rx_bytes: json.get("rx_bytes")?.as_u64()?, + tx_bytes: json.get("tx_bytes")?.as_u64()?, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LogSource { + path: PathBuf, + structured: bool, +} + +fn resolve_log_source(record: &BoxRecord) -> Option { + let log_dir = record.box_dir.join("logs"); + let structured_log = json_log_path(&log_dir); + if structured_log.exists() { + return Some(LogSource { + path: structured_log, + structured: true, + }); + } + + if record.console_log.exists() { + return Some(LogSource { + path: record.console_log.clone(), + structured: false, + }); + } + + None +} + +fn read_log_source(source: LogSource, tail: usize) -> Result> { + if tail == 0 { + return Ok(Vec::new()); + } + + let file = std::fs::File::open(source.path)?; + let reader = std::io::BufReader::new(file); + let mut lines = Vec::new(); + for line in reader.lines() { + let line = line?; + let Some(decoded) = decode_log_line(&line, source.structured) else { + continue; + }; + lines.push(decoded); + } + + let start = lines.len().saturating_sub(tail); + Ok(lines[start..].to_vec()) +} + +fn decode_log_line(line: &str, structured: bool) -> Option { + if structured { + return match serde_json::from_str::(line) { + Ok(entry) => Some(BoxLogLine { + stream: entry.stream, + timestamp: Some(entry.time), + message: entry.log.trim_end_matches(['\n', '\r']).to_string(), + }), + Err(_) => Some(BoxLogLine { + stream: "stdout".to_string(), + timestamp: None, + message: line.to_string(), + }), + }; + } + + if is_runtime_console_noise(line) { + return None; + } + + Some(BoxLogLine { + stream: "stdout".to_string(), + timestamp: None, + message: line.trim_end_matches(['\n', '\r']).to_string(), + }) +} + +fn validate_name(kind: &str, name: &str) -> Result<()> { + if name.trim().is_empty() { + return Err(ClientError::Validation(format!( + "{kind} name cannot be empty" + ))); + } + if name.contains('/') || name.contains('\\') { + return Err(ClientError::Validation(format!( + "{kind} name cannot contain path separators" + ))); + } + Ok(()) +} + +struct BoxDirGuard { + path: PathBuf, + armed: bool, +} + +impl BoxDirGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for BoxDirGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_dir_all(&self.path); + } + } +} + +fn resolve_box_rootfs(box_dir: &Path) -> Option { + let merged = box_dir.join("merged"); + if is_populated_dir(&merged) { + return Some(merged); + } + let rootfs = box_dir.join("rootfs"); + if rootfs.is_dir() { + return Some(rootfs); + } + None +} + +fn is_populated_dir(path: &Path) -> bool { + path.is_dir() + && std::fs::read_dir(path) + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false) +} + +fn disk_usage_paths(paths: &[&Path]) -> Result { + paths.iter().try_fold(0u64, |total, path| { + Ok(total.saturating_add(disk_usage_path(path)?)) + }) +} + +fn disk_usage_path(path: &Path) -> Result { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(ClientError::State(error)), + }; + + if !metadata.is_dir() { + return Ok(metadata.len()); + } + + let mut entries = std::fs::read_dir(path).map_err(ClientError::State)?; + entries.try_fold(0u64, |total, entry| { + let entry = entry.map_err(ClientError::State)?; + Ok(total.saturating_add(disk_usage_path(&entry.path())?)) + }) +} + +fn file_size_or_zero(path: &Path) -> Result { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(ClientError::State(error)), + }; + if metadata.is_dir() { + Ok(0) + } else { + Ok(metadata.len()) + } +} diff --git a/src/sdk/src/client/tests/common.rs b/src/sdk/src/client/tests/common.rs new file mode 100644 index 00000000..40e02847 --- /dev/null +++ b/src/sdk/src/client/tests/common.rs @@ -0,0 +1,187 @@ + fn client_for(dir: &tempfile::TempDir) -> A3sBoxClient { + A3sBoxClient::from_home(dir.path()).with_image_cache_size(1024 * 1024 * 1024) + } + + fn write_boxes(client: &A3sBoxClient, records: &[BoxRecord]) { + std::fs::create_dir_all(&client.paths().home).unwrap(); + std::fs::write( + &client.paths().boxes_file, + serde_json::to_vec_pretty(records).unwrap(), + ) + .unwrap(); + } + + fn write_resolved_image_config(record: &BoxRecord) { + let config = a3s_box_core::SnapshotImageConfig { + entrypoint: Some(vec!["/usr/local/bin/envd".to_string()]), + cmd: Some(vec!["--port".to_string(), "49983".to_string()]), + env: vec![("IMAGE_ENV".to_string(), "preserved".to_string())], + working_dir: Some("/home/user".to_string()), + user: Some("1000:1000".to_string()), + ..Default::default() + }; + std::fs::create_dir_all(&record.box_dir).unwrap(); + std::fs::write( + record + .box_dir + .join(a3s_box_runtime::RESOLVED_IMAGE_CONFIG_FILE), + serde_json::to_vec_pretty(&config).unwrap(), + ) + .unwrap(); + } + + fn write_minimal_oci_layout(path: &Path) { + let blobs = path.join("blobs").join("sha256"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(path.join("oci-layout"), r#"{"imageLayoutVersion":"1.0.0"}"#).unwrap(); + + let config_digest = "c".repeat(64); + let manifest_digest = "d".repeat(64); + let layer_digest = "e".repeat(64); + let diff_id = "f".repeat(64); + let layer = b"layer-bytes"; + std::fs::write(blobs.join(&layer_digest), layer).unwrap(); + + let config = serde_json::json!({ + "architecture": "amd64", + "os": "linux", + "config": { + "Entrypoint": ["/init"], + "Cmd": ["serve"], + "Env": ["A=1", "B=two"], + "WorkingDir": "/srv/app", + "User": "1000", + "ExposedPorts": {"8080/tcp": {}}, + "Volumes": {"/data": {}}, + "Labels": {"org.opencontainers.image.title": "fixture"}, + "StopSignal": "SIGTERM", + "Healthcheck": { + "Test": ["CMD-SHELL", "true"], + "Interval": 1000000000u64, + "Timeout": 2000000000u64, + "Retries": 3, + "StartPeriod": 3000000000u64 + }, + "OnBuild": ["RUN echo later"] + }, + "rootfs": { + "type": "layers", + "diff_ids": [format!("sha256:{diff_id}")] + }, + "history": [ + { + "created": "2026-07-08T00:00:00Z", + "created_by": "COPY app /srv/app", + "comment": "fixture layer" + }, + { + "created": "2026-07-08T00:00:01Z", + "created_by": "CMD [\"serve\"]", + "empty_layer": true + } + ] + }); + std::fs::write( + blobs.join(&config_digest), + serde_json::to_vec(&config).unwrap(), + ) + .unwrap(); + + let manifest = serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": format!("sha256:{config_digest}"), + "size": 1 + }, + "layers": [{ + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": format!("sha256:{layer_digest}"), + "size": layer.len() + }] + }); + std::fs::write( + blobs.join(&manifest_digest), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + let index = serde_json::json!({ + "schemaVersion": 2, + "manifests": [{ + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": format!("sha256:{manifest_digest}"), + "size": 1 + }] + }); + std::fs::write(path.join("index.json"), serde_json::to_vec(&index).unwrap()).unwrap(); + } + + fn box_record(id: &str, name: &str, status: &str) -> BoxRecord { + BoxRecord { + id: id.to_string(), + short_id: BoxRecord::make_short_id(id), + name: name.to_string(), + image: "alpine:latest".to_string(), + isolation: Default::default(), + managed_execution: None, + status: status.to_string(), + pid: if matches!(status, "running" | "paused") { + Some(std::process::id()) + } else { + None + }, + pid_start_time: None, + cpus: 2, + memory_mb: 512, + volumes: vec![], + virtiofs_cache: None, + env: HashMap::new(), + cmd: vec!["sh".to_string()], + entrypoint: None, + box_dir: Path::new("/tmp").join(id), + exec_socket_path: Path::new("/tmp").join(id).join("exec.sock"), + console_log: Path::new("/tmp").join(id).join("console.log"), + created_at: Utc::now(), + started_at: None, + auto_remove: false, + hostname: None, + user: None, + workdir: None, + restart_policy: "no".to_string(), + port_map: vec!["8080:80".to_string()], + labels: HashMap::new(), + stopped_by_user: false, + restart_count: 0, + max_restart_count: 0, + exit_code: None, + health_check: None, + healthcheck_disabled: false, + health_status: "none".to_string(), + health_retries: 0, + health_last_check: None, + network_mode: NetworkMode::default(), + network_name: None, + volume_names: vec![], + tmpfs: vec![], + anonymous_volumes: vec![], + resource_limits: a3s_box_core::config::ResourceLimits::default(), + log_config: a3s_box_core::log::LogConfig::default(), + add_host: vec![], + platform: None, + init: false, + read_only: false, + cap_add: vec![], + cap_drop: vec![], + security_opt: vec![], + privileged: false, + devices: vec![], + gpus: None, + shm_size: None, + stop_signal: None, + stop_timeout: None, + oom_kill_disable: false, + oom_score_adj: None, + } + } diff --git a/src/sdk/src/client/tests/images.rs b/src/sdk/src/client/tests/images.rs new file mode 100644 index 00000000..dca570d6 --- /dev/null +++ b/src/sdk/src/client/tests/images.rs @@ -0,0 +1,297 @@ + #[test] + fn creates_snapshot_from_box_rootfs_without_cli() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut record = box_record("17171717-1717-4171-8171-171717171717", "api", "stopped"); + record.box_dir = dir.path().join("boxes").join(&record.id); + record.cmd = vec!["sh".to_string(), "-lc".to_string(), "echo ok".to_string()]; + record.env.insert("ENV".to_string(), "test".to_string()); + let rootfs = record.box_dir.join("rootfs"); + std::fs::create_dir_all(rootfs.join("etc")).unwrap(); + std::fs::write(rootfs.join("etc").join("hostname"), "api").unwrap(); + write_resolved_image_config(&record); + write_boxes(&client, &[record.clone()]); + + let snapshot = client + .create_snapshot( + "api", + CreateSnapshot::new() + .name("before-upgrade") + .description("Created by SDK test"), + ) + .unwrap(); + + assert_eq!(snapshot.name, "before-upgrade"); + assert_eq!(snapshot.source_box_id, record.id); + assert_eq!(snapshot.image, record.image); + assert_eq!(snapshot.command, record.cmd); + assert_eq!(snapshot.description, "Created by SDK test"); + assert!(snapshot.size_bytes > 0); + assert_eq!( + std::fs::read_to_string( + client + .snapshot_store() + .unwrap() + .rootfs_path(&snapshot.id) + .join("etc") + .join("hostname") + ) + .unwrap(), + "api" + ); + assert!(client.get_snapshot(&snapshot.id).unwrap().is_some()); + let metadata = client + .snapshot_store() + .unwrap() + .get(&snapshot.id) + .unwrap() + .unwrap(); + let image_config = metadata.image_config.unwrap(); + assert_eq!( + image_config.entrypoint, + Some(vec!["/usr/local/bin/envd".to_string()]) + ); + assert_eq!(image_config.working_dir.as_deref(), Some("/home/user")); + } + + #[tokio::test] + async fn lists_images_from_runtime_store_index() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let image_path = client.paths().images_dir.join("sha256-test"); + std::fs::create_dir_all(&image_path).unwrap(); + std::fs::create_dir_all(&client.paths().images_dir).unwrap(); + let now = Utc::now(); + let index = serde_json::json!({ + "images": [{ + "reference": "docker.io/library/alpine:latest", + "digest": "sha256:test", + "size_bytes": 42, + "pulled_at": now, + "last_used": now, + "path": image_path + }] + }); + std::fs::write( + client.paths().images_dir.join("index.json"), + serde_json::to_vec_pretty(&index).unwrap(), + ) + .unwrap(); + + let images = client.list_images().await.unwrap(); + + assert_eq!(images.len(), 1); + assert_eq!(images[0].reference, "docker.io/library/alpine:latest"); + assert_eq!(images[0].size_bytes, 42); + } + + #[tokio::test] + async fn inspects_image_metadata_and_history_from_runtime_store() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let store = client.open_image_store().unwrap(); + let source = dir.path().join("image-source"); + write_minimal_oci_layout(&source); + store + .put( + "docker.io/library/alpine:latest", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + &source, + ) + .await + .unwrap(); + + let inspect = client + .inspect_image("alpine:latest") + .await + .unwrap() + .unwrap(); + let history = client + .image_history("alpine:latest") + .await + .unwrap() + .unwrap(); + + assert_eq!(inspect.reference, "docker.io/library/alpine:latest"); + assert_eq!(inspect.entrypoint, Some(vec!["/init".to_string()])); + assert_eq!(inspect.command, Some(vec!["serve".to_string()])); + assert_eq!(inspect.env.get("A").map(String::as_str), Some("1")); + assert_eq!(inspect.working_dir.as_deref(), Some("/srv/app")); + assert_eq!(inspect.user.as_deref(), Some("1000")); + assert_eq!(inspect.exposed_ports, vec!["8080/tcp"]); + assert_eq!(inspect.volumes, vec!["/data"]); + assert_eq!(inspect.stop_signal.as_deref(), Some("SIGTERM")); + assert_eq!( + inspect + .labels + .get("org.opencontainers.image.title") + .map(String::as_str), + Some("fixture") + ); + assert_eq!( + inspect.health_check.as_ref().map(|health| health.retries), + Some(Some(3)) + ); + assert_eq!(inspect.layer_count, 1); + assert_eq!(history.len(), 2); + assert_eq!(history[0].created_by, "COPY app /srv/app"); + assert_eq!(history[0].size_bytes, "layer-bytes".len() as u64); + assert_eq!(history[0].comment, "fixture layer"); + assert!(history[1].empty_layer); + assert_eq!(history[1].size_bytes, 0); + } + + #[tokio::test] + async fn tags_image_via_runtime_store_without_copying_layout() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let store = client.open_image_store().unwrap(); + let source = dir.path().join("image-source"); + write_minimal_oci_layout(&source); + let original = store + .put( + "docker.io/library/alpine:latest", + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + &source, + ) + .await + .unwrap(); + + let tagged = client + .tag_image(TagImage::new("alpine:latest", "local/alpine:desktop")) + .await + .unwrap(); + let images = client.list_images().await.unwrap(); + + assert_eq!(tagged.reference, "local/alpine:desktop"); + assert_eq!(tagged.digest, original.digest); + assert_eq!(tagged.path, original.path); + assert!(images + .iter() + .any(|image| image.reference == "docker.io/library/alpine:latest")); + assert!(images + .iter() + .any(|image| image.reference == "local/alpine:desktop")); + } + + #[tokio::test] + async fn removes_and_evicts_images_via_runtime_store() { + let dir = tempfile::tempdir().unwrap(); + let client = A3sBoxClient::from_home(dir.path()).with_image_cache_size(1); + let store = client.open_image_store().unwrap(); + let source = dir.path().join("image-source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("layer"), "image-data").unwrap(); + + store + .put("docker.io/library/alpine:latest", "sha256:one", &source) + .await + .unwrap(); + assert_eq!(client.list_images().await.unwrap().len(), 1); + + client + .remove_image("docker.io/library/alpine:latest") + .await + .unwrap(); + assert!(client.list_images().await.unwrap().is_empty()); + + let store = client.open_image_store().unwrap(); + store + .put("docker.io/library/busybox:latest", "sha256:two", &source) + .await + .unwrap(); + let evicted = client.evict_images().await.unwrap(); + + assert_eq!(evicted, vec!["docker.io/library/busybox:latest"]); + assert!(client.list_images().await.unwrap().is_empty()); + } + + #[test] + fn image_operation_requests_validate_without_cli() { + let dir = tempfile::tempdir().unwrap(); + let dockerfile = dir.path().join("Dockerfile"); + std::fs::write(&dockerfile, "FROM scratch\n").unwrap(); + + let pull = PullImage::new("alpine:latest") + .force(true) + .platform("linux/amd64") + .credentials(RegistryCredentials::basic("user", "secret")); + let build = BuildImage::new(dir.path()) + .tag("local/test:latest") + .build_arg("MODE", "test") + .platform(Platform::linux_amd64()) + .no_cache(true); + let push = PushImage::new("local/test:latest", "example.com/acme/test:latest") + .credentials(RegistryCredentials::basic("user", "secret")) + .plain_http(true); + + assert!(pull.validate().is_ok()); + assert!(build.validate().is_ok()); + assert!(push.validate().is_ok()); + assert_eq!(build.dockerfile_path, dockerfile); + assert_eq!(build.platforms, vec![Platform::linux_amd64()]); + assert_eq!(push.registry_protocol, RegistryProtocol::Http); + } + + #[cfg(unix)] + #[test] + fn resolves_runtime_socket_paths_without_cli_helpers() { + let mut record = box_record("11111111-1111-4111-8111-111111111111", "api", "running"); + record.exec_socket_path = Path::new("/tmp/custom-sockets").join("exec.sock"); + + assert_eq!( + runtime_socket(&record, RuntimeSocket::Exec), + Path::new("/tmp/custom-sockets").join("exec.sock") + ); + assert_eq!( + runtime_socket(&record, RuntimeSocket::Pty), + Path::new("/tmp/custom-sockets").join("pty.sock") + ); + assert_eq!( + runtime_socket(&record, RuntimeSocket::Attest), + Path::new("/tmp/custom-sockets").join("attest.sock") + ); + } + + #[cfg(unix)] + #[test] + fn runtime_socket_requires_running_box_and_existing_socket() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut record = box_record("11111111-1111-4111-8111-111111111111", "api", "stopped"); + record.exec_socket_path = dir.path().join("sockets").join("exec.sock"); + write_boxes(&client, &[record.clone()]); + + let stopped = client + .require_runtime_socket("api", RuntimeSocket::Exec) + .unwrap_err(); + assert!(format!("{stopped}").contains("because it is stopped")); + + record.status = "running".to_string(); + record.pid = Some(std::process::id()); + write_boxes(&client, &[record]); + let missing = client + .require_runtime_socket("api", RuntimeSocket::Exec) + .unwrap_err(); + assert!(format!("{missing}").contains("socket is missing")); + } + + #[cfg(unix)] + #[tokio::test] + async fn opens_exec_client_directly_against_runtime_socket() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let socket = dir.path().join("exec.sock"); + let listener = tokio::net::UnixListener::bind(&socket).unwrap(); + let accept = tokio::spawn(async move { + let _ = listener.accept().await.unwrap(); + }); + + let mut record = box_record("11111111-1111-4111-8111-111111111111", "api", "running"); + record.exec_socket_path = socket.clone(); + write_boxes(&client, &[record]); + + let exec = client.exec_client("api").await.unwrap(); + assert_eq!(exec.socket_path(), socket); + accept.await.unwrap(); + } diff --git a/src/sdk/src/client/tests/lifecycle.rs b/src/sdk/src/client/tests/lifecycle.rs new file mode 100644 index 00000000..62a2c4c8 --- /dev/null +++ b/src/sdk/src/client/tests/lifecycle.rs @@ -0,0 +1,226 @@ +#[derive(Debug)] +enum LifecycleCall { + Create { + request: serde_json::Value, + operation_id: String, + }, + Start { + execution_id: String, + generation: u64, + }, + Run { + request: serde_json::Value, + operation_id: String, + }, +} + +struct RecordingExecutionManager { + calls: std::sync::Mutex>, + reservation: a3s_box_core::ExecutionReservation, + lease: a3s_box_core::ExecutionLease, +} + +#[async_trait::async_trait] +impl a3s_box_core::ExecutionManager for RecordingExecutionManager { + async fn create( + &self, + request: a3s_box_core::CreateExecutionRequest, + operation_id: &a3s_box_core::OperationId, + ) -> a3s_box_core::ExecutionManagerResult { + self.calls.lock().unwrap().push(LifecycleCall::Create { + request: serde_json::to_value(request).unwrap(), + operation_id: operation_id.to_string(), + }); + Ok(self.reservation.clone()) + } + + async fn start( + &self, + execution_id: &a3s_box_core::ExecutionId, + generation: a3s_box_core::ExecutionGeneration, + ) -> a3s_box_core::ExecutionManagerResult { + self.calls.lock().unwrap().push(LifecycleCall::Start { + execution_id: execution_id.to_string(), + generation: generation.get(), + }); + Ok(self.lease.clone()) + } + + async fn create_and_start( + &self, + request: a3s_box_core::CreateExecutionRequest, + operation_id: &a3s_box_core::OperationId, + ) -> a3s_box_core::ExecutionManagerResult { + self.calls.lock().unwrap().push(LifecycleCall::Run { + request: serde_json::to_value(request).unwrap(), + operation_id: operation_id.to_string(), + }); + Ok(self.lease.clone()) + } + + async fn inspect( + &self, + execution_id: &a3s_box_core::ExecutionId, + ) -> a3s_box_core::ExecutionManagerResult { + Err(a3s_box_core::ExecutionManagerError::NotFound( + execution_id.clone(), + )) + } + + async fn pause( + &self, + execution_id: &a3s_box_core::ExecutionId, + _generation: a3s_box_core::ExecutionGeneration, + _keep_memory: bool, + ) -> a3s_box_core::ExecutionManagerResult { + Err(a3s_box_core::ExecutionManagerError::NotFound( + execution_id.clone(), + )) + } + + async fn resume( + &self, + execution_id: &a3s_box_core::ExecutionId, + _generation: a3s_box_core::ExecutionGeneration, + ) -> a3s_box_core::ExecutionManagerResult { + Err(a3s_box_core::ExecutionManagerError::NotFound( + execution_id.clone(), + )) + } + + async fn kill( + &self, + execution_id: &a3s_box_core::ExecutionId, + _generation: a3s_box_core::ExecutionGeneration, + ) -> a3s_box_core::ExecutionManagerResult { + Err(a3s_box_core::ExecutionManagerError::NotFound( + execution_id.clone(), + )) + } + + async fn reconcile( + &self, + _operation_id: &a3s_box_core::OperationId, + ) -> a3s_box_core::ExecutionManagerResult { + Ok(a3s_box_core::ReconcileOutcome::Absent) + } +} + +#[tokio::test] +async fn lifecycle_calls_preserve_complete_request_and_fencing_identity() { + use std::collections::BTreeMap; + use std::sync::Arc; + + use a3s_box_core::{ + resolve_execution, BoxConfig, CreateExecutionRequest, ExecutionGeneration, + ExecutionHealthCheck, ExecutionId, ExecutionLease, ExecutionRecordPolicy, + ExecutionReservation, ExecutionRestartPolicy, OperationId, ResourceLimits, + }; + use chrono::Utc; + + let temp = tempfile::tempdir().unwrap(); + let config = BoxConfig { + image: "registry.example/sdk:latest".to_string(), + isolation: a3s_box_core::ExecutionIsolation::Sandbox, + extra_env: vec![("SDK_CALLER".to_string(), "preserved".to_string())], + dns: vec!["1.1.1.1".to_string()], + read_only: true, + resource_limits: ResourceLimits { + pids_limit: Some(64), + ..ResourceLimits::default() + }, + ..BoxConfig::default() + }; + let request = CreateExecutionRequest { + external_sandbox_id: "sdk-external-id".to_string(), + config: config.clone(), + labels: BTreeMap::from([("caller".to_string(), "rust-sdk".to_string())]), + policy: ExecutionRecordPolicy { + name: Some("sdk-box".to_string()), + auto_remove: true, + restart_policy: ExecutionRestartPolicy::OnFailure, + max_restart_count: 4, + health_check: Some(ExecutionHealthCheck { + cmd: vec!["true".to_string()], + interval_secs: 7, + timeout_secs: 3, + retries: 2, + start_period_secs: 1, + }), + healthcheck_disabled: false, + log_config: a3s_box_core::log::LogConfig::default(), + volume_names: vec!["sdk-data".to_string()], + platform: Some("linux/amd64".to_string()), + init: true, + devices: vec!["/dev/null".to_string()], + gpus: Some("none".to_string()), + shm_size: Some(16 * 1024 * 1024), + stop_signal: Some("SIGTERM".to_string()), + stop_timeout: Some(9), + oom_kill_disable: true, + oom_score_adj: Some(100), + }, + rootfs_snapshot_id: None, + }; + let request_json = serde_json::to_value(&request).unwrap(); + let execution_id = ExecutionId::new("sdk-execution-id").unwrap(); + let operation_id = OperationId::new("sdk-operation-id").unwrap(); + let plan = resolve_execution(&config).unwrap(); + let reservation = ExecutionReservation { + execution_id: execution_id.clone(), + generation: ExecutionGeneration::INITIAL, + plan: plan.clone(), + resources: config.resources.clone(), + created_at: Utc::now(), + }; + let lease = ExecutionLease { + execution_id: execution_id.clone(), + generation: ExecutionGeneration::INITIAL, + plan, + resources: config.resources, + started_at: Utc::now(), + }; + let manager = Arc::new(RecordingExecutionManager { + calls: std::sync::Mutex::new(Vec::new()), + reservation, + lease, + }); + let client = + A3sBoxClient::with_execution_manager(A3sBoxPaths::from_home(temp.path()), manager.clone()); + + let created = client + .create_box(request.clone(), &operation_id) + .await + .unwrap(); + assert_eq!(created.execution_id, execution_id); + let started = client + .start_box(&created.execution_id, created.generation) + .await + .unwrap(); + assert_eq!(started.execution_id, execution_id); + let running = client.run_box(request, &operation_id).await.unwrap(); + assert_eq!(running.execution_id, execution_id); + + let calls = manager.calls.lock().unwrap(); + assert!(matches!( + &calls[0], + LifecycleCall::Create { + request, + operation_id + } if request == &request_json && operation_id == "sdk-operation-id" + )); + assert!(matches!( + &calls[1], + LifecycleCall::Start { + execution_id, + generation: 1 + } if execution_id == "sdk-execution-id" + )); + assert!(matches!( + &calls[2], + LifecycleCall::Run { + request, + operation_id + } if request == &request_json && operation_id == "sdk-operation-id" + )); +} diff --git a/src/sdk/src/client/tests/mod.rs b/src/sdk/src/client/tests/mod.rs new file mode 100644 index 00000000..5877409d --- /dev/null +++ b/src/sdk/src/client/tests/mod.rs @@ -0,0 +1,9 @@ +use super::*; +use chrono::Utc; +use std::path::Path; + +include!("common.rs"); +include!("state.rs"); +include!("images.rs"); +include!("lifecycle.rs"); +include!("resources.rs"); diff --git a/src/sdk/src/client/tests/resources.rs b/src/sdk/src/client/tests/resources.rs new file mode 100644 index 00000000..622108b6 --- /dev/null +++ b/src/sdk/src/client/tests/resources.rs @@ -0,0 +1,369 @@ + #[test] + fn creates_lists_and_removes_volumes_via_runtime_store() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + + let created = client + .create_volume(CreateVolume::new("cache").label("role", "build")) + .unwrap(); + + assert_eq!(created.name, "cache"); + assert!(Path::new(&created.mount_point).exists()); + assert_eq!(client.list_volumes().unwrap().len(), 1); + assert_eq!( + client.get_volume("cache").unwrap().unwrap().labels["role"], + "build" + ); + assert_eq!(client.remove_volume("cache", false).unwrap().name, "cache"); + assert!(client.list_volumes().unwrap().is_empty()); + } + + #[test] + fn creates_network_and_connects_inactive_box_via_runtime_store() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + write_boxes( + &client, + &[box_record( + "33333333-3333-4333-8333-333333333333", + "api", + "stopped", + )], + ); + + let network = client + .create_network(CreateNetwork::new("dev").subnet("10.89.44.0/24")) + .unwrap(); + let endpoint = client.connect_network("dev", "api").unwrap(); + let updated = client.get_network("dev").unwrap().unwrap(); + let box_after = client.get_box("api").unwrap().unwrap(); + + assert_eq!(network.name, "dev"); + assert_eq!(endpoint.box_name, "api"); + assert_eq!(updated.endpoint_count, 1); + assert_eq!(box_after.network_name.as_deref(), Some("dev")); + + let endpoint = client.disconnect_network("dev", "api").unwrap(); + let updated = client.get_network("dev").unwrap().unwrap(); + let box_after = client.get_box("api").unwrap().unwrap(); + + assert_eq!(endpoint.box_name, "api"); + assert_eq!(updated.endpoint_count, 0); + assert_eq!(box_after.network_name, None); + } + + #[test] + fn prunes_only_unused_non_predefined_networks_via_runtime_store() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let referenced = box_record("33333333-3333-4333-8333-333333333333", "api", "stopped"); + let mut referenced = referenced; + referenced.network_name = Some("referenced".to_string()); + referenced.network_mode = NetworkMode::Bridge { + network: "referenced".to_string(), + }; + write_boxes(&client, &[referenced]); + + client + .create_network(CreateNetwork::new("orphan").subnet("10.89.10.0/24")) + .unwrap(); + client + .create_network(CreateNetwork::new("referenced").subnet("10.89.11.0/24")) + .unwrap(); + client + .create_network(CreateNetwork::new("attached").subnet("10.89.12.0/24")) + .unwrap(); + client + .network_store() + .with_write_lock(|networks| { + networks + .get_mut("attached") + .unwrap() + .connect("box-2", "worker") + .map_err(a3s_box_core::error::BoxError::NetworkError) + }) + .unwrap(); + + let removed = client.prune_networks().unwrap(); + let remaining = client + .list_networks() + .unwrap() + .into_iter() + .map(|network| network.name) + .collect::>(); + + assert_eq!(removed, vec!["orphan"]); + assert_eq!(remaining, vec!["attached", "referenced"]); + } + + #[test] + fn lists_removes_and_prunes_snapshots_via_runtime_store() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let source = dir.path().join("rootfs-source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("file"), "snapshot-data").unwrap(); + let store = SnapshotStore::new(&client.paths().snapshots_dir).unwrap(); + + let first = SnapshotMetadata::new( + "snap-1".to_string(), + "before-upgrade".to_string(), + "box-1".to_string(), + "alpine:latest".to_string(), + ) + .with_description("Before upgrade"); + let second = SnapshotMetadata::new( + "snap-2".to_string(), + "after-upgrade".to_string(), + "box-1".to_string(), + "alpine:latest".to_string(), + ); + store.save(first, &source).unwrap(); + store.save(second, &source).unwrap(); + + let snapshots = client.list_snapshots().unwrap(); + assert_eq!(snapshots.len(), 2); + assert_eq!( + client.get_snapshot("snap-1").unwrap().unwrap().name, + "before-upgrade" + ); + assert!(client.remove_snapshot("snap-1").unwrap()); + + let removed = client.prune_snapshots(0, 1).unwrap(); + + assert_eq!(removed, vec!["snap-2"]); + assert!(client.list_snapshots().unwrap().is_empty()); + } + + #[test] + fn restore_rejects_snapshot_without_resolved_image_config() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let source = dir.path().join("rootfs-source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("app.txt"), "snapshot-data").unwrap(); + SnapshotStore::new(&client.paths().snapshots_dir) + .unwrap() + .save( + SnapshotMetadata::new( + "legacy-snapshot".to_string(), + "legacy-snapshot".to_string(), + "source-box".to_string(), + "alpine:3.20".to_string(), + ), + &source, + ) + .unwrap(); + + let error = client + .restore_snapshot("legacy-snapshot", RestoreSnapshot::new()) + .unwrap_err(); + + assert!(matches!( + &error, + ClientError::Validation(message) + if message.contains("resolved OCI image configuration") + )); + assert!(client.list_boxes(ListBoxesOptions::all()).unwrap().is_empty()); + } + + #[test] + fn restores_snapshot_into_created_box_record_via_runtime_store() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let source = dir.path().join("rootfs-source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("app.txt"), "snapshot-data").unwrap(); + let store = SnapshotStore::new(&client.paths().snapshots_dir).unwrap(); + + let mut metadata = SnapshotMetadata::new( + "snap-restore".to_string(), + "after-migration".to_string(), + "source-box".to_string(), + "alpine:3.20".to_string(), + ) + .with_resources(4, 2048) + .with_description("After migration"); + metadata.volumes = vec!["data:/data".to_string()]; + metadata + .env + .insert("APP_ENV".to_string(), "test".to_string()); + metadata.cmd = vec!["sleep".to_string(), "infinity".to_string()]; + metadata.entrypoint = Some(vec!["/entrypoint.sh".to_string()]); + metadata.workdir = Some("/srv/app".to_string()); + metadata.port_map = vec!["8080:80".to_string()]; + metadata + .labels + .insert("tier".to_string(), "api".to_string()); + metadata.network_mode = Some("bridge".to_string()); + metadata.image_config = Some(a3s_box_core::SnapshotImageConfig::default()); + store.save(metadata, &source).unwrap(); + + let restored = client + .restore_snapshot("snap-restore", RestoreSnapshot::new().name("restored-api")) + .unwrap(); + + assert_eq!(restored.name, "restored-api"); + assert_eq!(restored.image, "alpine:3.20"); + assert_eq!(restored.status, "created"); + assert!(!restored.active); + assert_eq!(restored.cpus, 4); + assert_eq!(restored.memory_mb, 2048); + assert_eq!(restored.ports, vec!["8080:80"]); + + let state = StateFile::load(&client.paths().boxes_file).unwrap(); + let record = state.find_by_name("restored-api").unwrap(); + assert_eq!(record.id, restored.id); + assert_eq!(record.volumes, vec!["data:/data".to_string()]); + assert_eq!(record.env.get("APP_ENV").map(String::as_str), Some("test")); + assert_eq!( + record.cmd, + vec!["sleep".to_string(), "infinity".to_string()] + ); + assert_eq!(record.entrypoint, Some(vec!["/entrypoint.sh".to_string()])); + assert_eq!(record.workdir.as_deref(), Some("/srv/app")); + assert_eq!(record.labels.get("tier").map(String::as_str), Some("api")); + assert_eq!(record.pid, None); + assert_eq!(record.started_at, None); + assert!(record.exec_socket_path.ends_with("sockets/exec.sock")); + assert!(record.console_log.ends_with("logs/console.log")); + assert!(record.box_dir.join("sockets").is_dir()); + assert!(record.box_dir.join("logs").is_dir()); + assert_eq!( + std::fs::read_to_string(record.box_dir.join(".snapshot-lower")).unwrap(), + store + .rootfs_path("snap-restore") + .to_string_lossy() + .to_string() + ); + assert_eq!( + client.get_box("restored-api").unwrap().unwrap().id, + restored.id + ); + } + + #[test] + fn restores_snapshot_by_name_and_chooses_available_default_box_name() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + write_boxes( + &client, + &[box_record( + "44444444-4444-4444-8444-444444444444", + "after-migration-restore", + "stopped", + )], + ); + let source = dir.path().join("rootfs-source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("file"), "snapshot-data").unwrap(); + let store = SnapshotStore::new(&client.paths().snapshots_dir).unwrap(); + let mut metadata = SnapshotMetadata::new( + "snap-by-name".to_string(), + "after-migration".to_string(), + "box-1".to_string(), + "alpine:latest".to_string(), + ); + metadata.image_config = Some(a3s_box_core::SnapshotImageConfig::default()); + store + .save(metadata, &source) + .unwrap(); + + let restored = client + .restore_snapshot("after-migration", RestoreSnapshot::new()) + .unwrap(); + + assert_eq!(restored.name, "after-migration-restore-2"); + assert_eq!(client.list_boxes(ListBoxesOptions::all()).unwrap().len(), 2); + } + + #[test] + fn restore_rejects_ambiguous_snapshot_name() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let source = dir.path().join("rootfs-source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("file"), "snapshot-data").unwrap(); + let store = SnapshotStore::new(&client.paths().snapshots_dir).unwrap(); + store + .save( + SnapshotMetadata::new( + "snap-1".to_string(), + "same".to_string(), + "box-1".to_string(), + "alpine:latest".to_string(), + ), + &source, + ) + .unwrap(); + store + .save( + SnapshotMetadata::new( + "snap-2".to_string(), + "same".to_string(), + "box-2".to_string(), + "alpine:latest".to_string(), + ), + &source, + ) + .unwrap(); + + let error = client + .restore_snapshot("same", RestoreSnapshot::new()) + .unwrap_err(); + + assert!(format!("{error}").contains("matched multiple snapshots")); + } + + #[test] + fn prunes_only_created_stopped_and_dead_boxes_without_cli() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut created = box_record("51515151-5151-4151-8151-515151515151", "created", "created"); + let mut stopped = box_record("52525252-5252-4252-8252-525252525252", "stopped", "stopped"); + let mut dead = box_record("53535353-5353-4353-8353-535353535353", "dead", "dead"); + let mut running = box_record("54545454-5454-4454-8454-545454545454", "running", "running"); + let mut paused = box_record("55555555-5555-4555-8555-555555555555", "paused", "paused"); + for record in [ + &mut created, + &mut stopped, + &mut dead, + &mut running, + &mut paused, + ] { + record.box_dir = client.paths().home.join("boxes").join(&record.id); + record.exec_socket_path = record.box_dir.join("sockets").join("exec.sock"); + record.console_log = record.box_dir.join("logs").join("console.log"); + std::fs::create_dir_all(record.box_dir.join("logs")).unwrap(); + } + write_boxes( + &client, + &[ + created.clone(), + stopped.clone(), + dead.clone(), + running.clone(), + paused.clone(), + ], + ); + + let removed = client.prune_boxes().unwrap(); + let removed_names = removed + .iter() + .map(|summary| summary.name.as_str()) + .collect::>(); + let remaining = client + .list_boxes(ListBoxesOptions::all()) + .unwrap() + .into_iter() + .map(|summary| summary.name) + .collect::>(); + + assert_eq!(removed_names, vec!["created", "stopped", "dead"]); + assert_eq!(remaining, vec!["running", "paused"]); + assert!(!created.box_dir.exists()); + assert!(!stopped.box_dir.exists()); + assert!(!dead.box_dir.exists()); + assert!(running.box_dir.exists()); + assert!(paused.box_dir.exists()); + } diff --git a/src/sdk/src/client/tests/state.rs b/src/sdk/src/client/tests/state.rs new file mode 100644 index 00000000..2af50d54 --- /dev/null +++ b/src/sdk/src/client/tests/state.rs @@ -0,0 +1,397 @@ + #[test] + fn lists_boxes_from_state_without_spawning_cli() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + write_boxes( + &client, + &[ + box_record("11111111-1111-4111-8111-111111111111", "web", "running"), + box_record("22222222-2222-4222-8222-222222222222", "db", "stopped"), + ], + ); + + let all = client.list_boxes(ListBoxesOptions::all()).unwrap(); + let active = client.list_boxes(ListBoxesOptions::active()).unwrap(); + + assert_eq!(all.len(), 2); + assert_eq!(active.len(), 1); + assert_eq!(all[0].name, "web"); + assert_eq!(all[0].ports, vec!["8080:80"]); + assert_eq!(client.get_box("db").unwrap().unwrap().status, "stopped"); + } + + #[test] + fn old_state_defaults_to_microvm_and_sandbox_state_is_visible() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let old_record = box_record("old-id", "old", "created"); + let mut old_json = serde_json::to_value(old_record).unwrap(); + old_json.as_object_mut().unwrap().remove("isolation"); + let mut sandbox_record = box_record("sandbox-id", "sandbox", "created"); + sandbox_record.isolation = a3s_box_core::ExecutionIsolation::Sandbox; + std::fs::create_dir_all(&client.paths().home).unwrap(); + std::fs::write( + &client.paths().boxes_file, + serde_json::to_vec_pretty(&serde_json::json!([ + old_json, + serde_json::to_value(sandbox_record).unwrap() + ])) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + client.get_box("old").unwrap().unwrap().isolation, + a3s_box_core::ExecutionIsolation::Microvm + ); + assert_eq!( + client.get_box("sandbox").unwrap().unwrap().isolation, + a3s_box_core::ExecutionIsolation::Sandbox + ); + } + + #[test] + fn collects_runtime_diagnostics_without_spawning_cli() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + + let diagnostics = client.runtime_diagnostics(); + + assert_eq!(diagnostics.home, dir.path()); + assert!(!diagnostics.core_version.is_empty()); + assert!(!diagnostics.runtime_version.is_empty()); + assert!(!diagnostics.sdk_version.is_empty()); + assert!(!diagnostics.virtualization.details.is_empty()); + if diagnostics.virtualization.available { + assert!(diagnostics.virtualization.backend.is_some()); + } else { + assert!(diagnostics.virtualization.backend.is_none()); + } + } + + #[test] + fn collects_runtime_disk_usage_without_spawning_cli() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + + std::fs::create_dir_all(client.paths().home.join("boxes").join("box-1")).unwrap(); + std::fs::create_dir_all(client.paths().images_dir.join("sha256-test")).unwrap(); + std::fs::create_dir_all(client.paths().volumes_dir.join("data")).unwrap(); + std::fs::create_dir_all(client.paths().snapshots_dir.join("snap-1")).unwrap(); + std::fs::write( + client + .paths() + .home + .join("boxes") + .join("box-1") + .join("rootfs"), + b"box", + ) + .unwrap(); + std::fs::write( + client.paths().images_dir.join("sha256-test").join("layer"), + b"image", + ) + .unwrap(); + std::fs::write( + client.paths().volumes_dir.join("data").join("file"), + b"volume", + ) + .unwrap(); + std::fs::write( + client.paths().snapshots_dir.join("snap-1").join("rootfs"), + b"snapshot", + ) + .unwrap(); + std::fs::write(&client.paths().boxes_file, b"[]").unwrap(); + std::fs::write(&client.paths().volumes_file, b"{}").unwrap(); + std::fs::write(&client.paths().networks_file, b"{}").unwrap(); + std::fs::write(client.paths().home.join("audit.log"), b"other").unwrap(); + + let usage = client.runtime_disk_usage().unwrap(); + + assert_eq!(usage.home, dir.path()); + assert_eq!(usage.boxes_bytes, 3); + assert_eq!(usage.images_bytes, 5); + assert_eq!(usage.volumes_bytes, 6); + assert_eq!(usage.snapshots_bytes, 8); + assert_eq!(usage.state_bytes, 6); + assert_eq!(usage.other_bytes, 5); + assert_eq!(usage.total_bytes, 33); + } + + #[cfg(unix)] + #[test] + fn pauses_and_unpauses_box_with_host_signal_and_locked_state_update() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut child = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + let pid = child.id(); + let mut record = box_record("12121212-1212-4121-8121-121212121212", "api", "running"); + record.pid = Some(pid); + record.pid_start_time = pid_start_time(pid); + record.virtiofs_cache = Some("always".to_string()); + write_boxes(&client, &[record]); + + let paused = client.pause_box("api").unwrap(); + assert_eq!(paused.status, "paused"); + assert_eq!( + client.get_box("api").unwrap().unwrap().status, + "paused", + "pause should persist the status transition through the SDK state writer" + ); + + let running = client.unpause_box("api").unwrap(); + assert_eq!(running.status, "running"); + assert_eq!(client.get_box("api").unwrap().unwrap().status, "running"); + let persisted: serde_json::Value = serde_json::from_slice( + &std::fs::read(&client.paths().boxes_file).unwrap(), + ) + .unwrap(); + assert_eq!(persisted[0]["virtiofs_cache"], "always"); + + let _ = child.kill(); + let _ = child.wait(); + } + + #[cfg(unix)] + #[test] + fn pause_rejects_stale_pid_identity_without_mutating_state() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut record = box_record("13131313-1313-4131-8131-131313131313", "api", "running"); + record.pid = Some(std::process::id()); + record.pid_start_time = Some(u64::MAX); + write_boxes(&client, &[record]); + + let error = client.pause_box("api").unwrap_err(); + assert!(format!("{error}").contains("recorded PID")); + assert_eq!(client.get_box("api").unwrap().unwrap().status, "running"); + } + + #[cfg(unix)] + #[tokio::test] + async fn stop_box_falls_back_to_host_signal_and_updates_state() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut child = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + let pid = child.id(); + let mut record = box_record("14141414-1414-4141-8141-141414141414", "api", "running"); + record.pid = Some(pid); + record.pid_start_time = pid_start_time(pid); + record.box_dir = dir.path().join("boxes").join(&record.id); + record.exec_socket_path = record.box_dir.join("sockets").join("missing.sock"); + record.volume_names = vec!["data".to_string()]; + std::fs::create_dir_all(record.box_dir.join("merged")).unwrap(); + let mut volume = VolumeConfig::new("data", ""); + volume.in_use_by = vec![record.id.clone()]; + client.volume_store().create(volume).unwrap(); + write_boxes(&client, &[record.clone()]); + + let stopped = client + .stop_box("api", StopBox::new().timeout_secs(0)) + .await + .unwrap(); + + assert_eq!(stopped.id, record.id); + assert_eq!(stopped.outcome, StopOutcome::ForceKilled); + assert_eq!(stopped.exit_code, Some(137)); + assert_eq!( + stopped + .box_summary + .as_ref() + .map(|summary| summary.status.as_str()), + Some("stopped") + ); + let stored = client.get_box("api").unwrap().unwrap(); + assert_eq!(stored.status, "stopped"); + assert_eq!(stored.pid, None); + assert_eq!(stored.health, "none"); + assert_eq!(stored.status_summary, "stopped (Exit 137)"); + assert!(client + .get_volume("data") + .unwrap() + .unwrap() + .in_use_by + .is_empty()); + + let _ = child.wait(); + } + + #[test] + fn removes_inactive_box_and_runtime_resources_without_cli() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut record = box_record("15151515-1515-4151-8151-151515151515", "api", "stopped"); + record.box_dir = dir.path().join("boxes").join(&record.id); + record.exec_socket_path = dir.path().join("external-sockets").join("exec.sock"); + record.volume_names = vec!["data".to_string()]; + record.anonymous_volumes = vec!["anon".to_string()]; + std::fs::create_dir_all(record.box_dir.join("merged")).unwrap(); + std::fs::create_dir_all(record.exec_socket_path.parent().unwrap()).unwrap(); + write_boxes(&client, &[record.clone()]); + + client.create_volume(CreateVolume::new("data")).unwrap(); + client.create_volume(CreateVolume::new("anon")).unwrap(); + client + .volume_store() + .modify("data", |volume| { + volume.in_use_by = vec![record.id.clone()]; + }) + .unwrap(); + client + .volume_store() + .modify("anon", |volume| { + volume.in_use_by = vec![record.id.clone()]; + }) + .unwrap(); + client + .create_network(CreateNetwork::new("dev").subnet("10.89.55.0/24")) + .unwrap(); + client.connect_network("dev", "api").unwrap(); + + let removed = client.remove_box("api", RemoveBox::new()).unwrap(); + + assert_eq!(removed.id, record.id); + assert_eq!(removed.name, "api"); + assert!(client.get_box("api").unwrap().is_none()); + assert!(!record.box_dir.exists()); + assert!(!record.exec_socket_path.parent().unwrap().exists()); + assert!(client + .get_volume("data") + .unwrap() + .unwrap() + .in_use_by + .is_empty()); + assert!(client.get_volume("anon").unwrap().is_none()); + assert_eq!( + client.get_network("dev").unwrap().unwrap().endpoint_count, + 0 + ); + } + + #[test] + fn remove_box_rejects_active_box_without_force() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let record = box_record("16161616-1616-4161-8161-161616161616", "api", "running"); + write_boxes(&client, &[record]); + + let error = client.remove_box("api", RemoveBox::new()).unwrap_err(); + + assert!(format!("{error}").contains("Stop it before removing it")); + assert!(client.get_box("api").unwrap().is_some()); + } + + #[test] + fn reads_recent_structured_box_logs_without_spawning_cli() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut record = box_record("11111111-1111-4111-8111-111111111111", "api", "running"); + record.box_dir = dir.path().join("boxes").join(&record.id); + record.console_log = record.box_dir.join("logs").join("console.log"); + write_boxes(&client, &[record.clone()]); + + let log_dir = record.box_dir.join("logs"); + std::fs::create_dir_all(&log_dir).unwrap(); + std::fs::write( + json_log_path(&log_dir), + [ + r#"{"log":"first line\n","stream":"stdout","time":"2026-07-08T00:00:00Z"}"#, + r#"{"log":"second line\n","stream":"stderr","time":"2026-07-08T00:00:01Z"}"#, + ] + .join("\n"), + ) + .unwrap(); + + let logs = client + .read_box_logs("api", ReadBoxLogsOptions::tail(1)) + .unwrap(); + + assert_eq!( + logs, + vec![BoxLogLine { + stream: "stderr".to_string(), + timestamp: Some("2026-07-08T00:00:01Z".to_string()), + message: "second line".to_string(), + }] + ); + } + + #[test] + fn reads_console_log_fallback_and_filters_runtime_noise() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut record = box_record("11111111-1111-4111-8111-111111111111", "api", "stopped"); + record.box_dir = dir.path().join("boxes").join(&record.id); + record.console_log = record.box_dir.join("logs").join("console.log"); + write_boxes(&client, &[record.clone()]); + + std::fs::create_dir_all(record.console_log.parent().unwrap()).unwrap(); + std::fs::write( + &record.console_log, + "init.krun: boot internals\ncontainer line\n", + ) + .unwrap(); + + let logs = client + .read_box_logs(&record.id, ReadBoxLogsOptions::default()) + .unwrap(); + + assert_eq!( + logs, + vec![BoxLogLine { + stream: "stdout".to_string(), + timestamp: None, + message: "container line".to_string(), + }] + ); + } + + #[test] + fn collects_active_box_stats_without_spawning_cli() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + let mut record = box_record("11111111-1111-4111-8111-111111111111", "api", "running"); + record.box_dir = dir.path().join("boxes").join(&record.id); + std::fs::create_dir_all(record.box_dir.join("sockets")).unwrap(); + std::fs::write( + record.box_dir.join("sockets").join("net.stats.json"), + r#"{"schema":"a3s-box.netproxy.stats.v1","rx_bytes":1024,"tx_bytes":2048}"#, + ) + .unwrap(); + write_boxes(&client, &[record.clone()]); + + let stats = client.list_box_stats().unwrap(); + let one = client.get_box_stats("api").unwrap().unwrap(); + + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].id, record.id); + assert_eq!(stats[0].network_rx_bytes, 1024); + assert_eq!(stats[0].network_tx_bytes, 2048); + assert_eq!(stats[0].memory_limit_bytes, 512 * 1024 * 1024); + assert_eq!(one.id, stats[0].id); + } + + #[test] + fn inactive_box_stats_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let client = client_for(&dir); + write_boxes( + &client, + &[box_record( + "11111111-1111-4111-8111-111111111111", + "api", + "stopped", + )], + ); + + assert!(client.get_box_stats("api").unwrap().is_none()); + assert!(client.list_box_stats().unwrap().is_empty()); + } diff --git a/src/sdk/src/client/types.rs b/src/sdk/src/client/types.rs new file mode 100644 index 00000000..670c51ef --- /dev/null +++ b/src/sdk/src/client/types.rs @@ -0,0 +1,496 @@ +/// Result type used by the direct SDK client. +pub type Result = std::result::Result; + +/// Errors returned by the direct SDK client. +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("state error: {0}")] + State(#[from] std::io::Error), + #[error("runtime error: {0}")] + Runtime(#[from] a3s_box_core::error::BoxError), + #[error("execution lifecycle error: {0}")] + Execution(#[from] a3s_box_core::ExecutionManagerError), + #[error("validation error: {0}")] + Validation(String), + #[error("box not found: {0}")] + BoxNotFound(String), + #[error("box query {query:?} matched multiple boxes: {matches:?}")] + AmbiguousBoxQuery { query: String, matches: Vec }, +} + +/// Filesystem locations used by [`A3sBoxClient`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct A3sBoxPaths { + pub home: PathBuf, + pub boxes_file: PathBuf, + pub images_dir: PathBuf, + pub volumes_file: PathBuf, + pub volumes_dir: PathBuf, + pub networks_file: PathBuf, + pub snapshots_dir: PathBuf, +} + +impl A3sBoxPaths { + /// Build paths under an a3s-box home directory. + pub fn from_home(home: impl Into) -> Self { + let home = home.into(); + Self { + boxes_file: home.join("boxes.json"), + images_dir: home.join("images"), + volumes_file: home.join("volumes.json"), + volumes_dir: home.join("volumes"), + networks_file: home.join("networks.json"), + snapshots_dir: home.join("snapshots"), + home, + } + } +} + +impl Default for A3sBoxPaths { + fn default() -> Self { + Self::from_home(a3s_box_core::dirs_home()) + } +} + +/// Options for listing boxes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ListBoxesOptions { + /// Include stopped, dead, and created boxes. + pub all: bool, +} + +impl ListBoxesOptions { + pub const fn all() -> Self { + Self { all: true } + } + + pub const fn active() -> Self { + Self { all: false } + } +} + +impl Default for ListBoxesOptions { + fn default() -> Self { + Self::all() + } +} + +/// Options for reading a bounded box log snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReadBoxLogsOptions { + /// Number of lines to return from the end of the log source. + pub tail: usize, +} + +impl ReadBoxLogsOptions { + pub const fn tail(tail: usize) -> Self { + Self { tail } + } +} + +impl Default for ReadBoxLogsOptions { + fn default() -> Self { + Self { tail: 100 } + } +} + +/// Optional registry credentials for pull and push operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryCredentials { + pub username: String, + pub password: String, +} + +impl RegistryCredentials { + pub fn basic(username: impl Into, password: impl Into) -> Self { + Self { + username: username.into(), + password: password.into(), + } + } + + fn into_auth(self) -> RegistryAuth { + RegistryAuth::basic(self.username, self.password) + } +} + +/// Request to pull an OCI image through the runtime image puller. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PullImage { + pub reference: String, + pub force: bool, + pub platform: Option, + pub signature_policy: SignaturePolicy, + pub credentials: Option, +} + +impl PullImage { + pub fn new(reference: impl Into) -> Self { + Self { + reference: reference.into(), + force: false, + platform: None, + signature_policy: SignaturePolicy::default(), + credentials: None, + } + } + + pub fn force(mut self, force: bool) -> Self { + self.force = force; + self + } + + pub fn platform(mut self, platform: impl Into) -> Self { + self.platform = Some(platform.into()); + self + } + + pub fn signature_policy(mut self, policy: SignaturePolicy) -> Self { + self.signature_policy = policy; + self + } + + pub fn credentials(mut self, credentials: RegistryCredentials) -> Self { + self.credentials = Some(credentials); + self + } + + fn validate(&self) -> Result<()> { + ImageReference::parse(&self.reference).map_err(ClientError::Runtime)?; + Ok(()) + } + + fn registry_auth(&self) -> Result { + match self.credentials.clone() { + Some(credentials) => Ok(credentials.into_auth()), + None => { + let parsed = + ImageReference::parse(&self.reference).map_err(ClientError::Runtime)?; + Ok(RegistryAuth::from_credential_store(&parsed.registry)) + } + } + } +} + +/// Request to build an OCI image through the runtime Dockerfile build engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuildImage { + pub context_dir: PathBuf, + pub dockerfile_path: PathBuf, + pub tag: Option, + pub build_args: HashMap, + pub quiet: bool, + pub platforms: Vec, + pub target: Option, + pub no_cache: bool, +} + +impl BuildImage { + pub fn new(context_dir: impl Into) -> Self { + let context_dir = context_dir.into(); + Self { + dockerfile_path: context_dir.join("Dockerfile"), + context_dir, + tag: None, + build_args: HashMap::new(), + quiet: false, + platforms: Vec::new(), + target: None, + no_cache: false, + } + } + + pub fn dockerfile_path(mut self, path: impl Into) -> Self { + self.dockerfile_path = path.into(); + self + } + + pub fn tag(mut self, tag: impl Into) -> Self { + self.tag = Some(tag.into()); + self + } + + pub fn build_arg(mut self, key: impl Into, value: impl Into) -> Self { + self.build_args.insert(key.into(), value.into()); + self + } + + pub fn quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + self + } + + pub fn platform(mut self, platform: Platform) -> Self { + self.platforms.push(platform); + self + } + + pub fn target(mut self, target: impl Into) -> Self { + self.target = Some(target.into()); + self + } + + pub fn no_cache(mut self, no_cache: bool) -> Self { + self.no_cache = no_cache; + self + } + + fn validate(&self) -> Result<()> { + if !self.context_dir.exists() { + return Err(ClientError::Validation(format!( + "build context does not exist: {}", + self.context_dir.display() + ))); + } + if !self.dockerfile_path.exists() { + return Err(ClientError::Validation(format!( + "Dockerfile does not exist: {}", + self.dockerfile_path.display() + ))); + } + Ok(()) + } +} + +/// Result of an image build. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildImageSummary { + pub reference: String, + pub digest: String, + pub size_bytes: u64, + pub layer_count: usize, +} + +impl From for BuildImageSummary { + fn from(result: RuntimeBuildResult) -> Self { + Self { + reference: result.reference, + digest: result.digest, + size_bytes: result.size, + layer_count: result.layer_count, + } + } +} + +/// Request to push a locally cached image through the runtime registry pusher. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PushImage { + pub source: String, + pub target: String, + pub credentials: Option, + pub registry_protocol: RegistryProtocol, +} + +impl PushImage { + pub fn new(source: impl Into, target: impl Into) -> Self { + Self { + source: source.into(), + target: target.into(), + credentials: None, + registry_protocol: RegistryProtocol::from_env(), + } + } + + pub fn credentials(mut self, credentials: RegistryCredentials) -> Self { + self.credentials = Some(credentials); + self + } + + pub fn registry_protocol(mut self, protocol: RegistryProtocol) -> Self { + self.registry_protocol = protocol; + self + } + + pub fn plain_http(mut self, enabled: bool) -> Self { + if enabled { + self.registry_protocol = RegistryProtocol::Http; + } + self + } + + fn validate(&self) -> Result<()> { + if self.source.trim().is_empty() { + return Err(ClientError::Validation( + "source image reference cannot be empty".to_string(), + )); + } + ImageReference::parse(&self.target).map_err(ClientError::Runtime)?; + Ok(()) + } + + fn registry_auth(&self, target: &ImageReference) -> RegistryAuth { + match self.credentials.clone() { + Some(credentials) => credentials.into_auth(), + None => RegistryAuth::from_credential_store(&target.registry), + } + } +} + +/// Result of an image push. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PushImageSummary { + pub reference: String, + pub manifest_digest: String, + pub config_url: String, + pub manifest_url: String, +} + +impl PushImageSummary { + fn from_push_result(reference: String, result: PushResult) -> Self { + Self { + reference, + manifest_digest: result.manifest_digest, + config_url: result.config_url, + manifest_url: result.manifest_url, + } + } +} + +/// Request to add a tag to a cached image. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TagImage { + pub source: String, + pub target: String, +} + +impl TagImage { + pub fn new(source: impl Into, target: impl Into) -> Self { + Self { + source: source.into(), + target: target.into(), + } + } + + fn validate(&self) -> Result<()> { + if self.source.trim().is_empty() { + return Err(ClientError::Validation( + "source image reference cannot be empty".to_string(), + )); + } + validate_tag_target(&self.target) + } +} + +/// Options for stopping a running or paused box. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct StopBox { + pub timeout_secs: Option, +} + +impl StopBox { + pub fn new() -> Self { + Self::default() + } + + pub fn timeout_secs(mut self, timeout_secs: u64) -> Self { + self.timeout_secs = Some(timeout_secs); + self + } +} + +/// Options for removing a box. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoveBox { + pub force: bool, +} + +impl RemoveBox { + pub fn new() -> Self { + Self::default() + } + + pub fn force(mut self, force: bool) -> Self { + self.force = force; + self + } +} + +/// Result of removing a box. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoveBoxSummary { + pub id: String, + pub name: String, +} + +/// Request to create a snapshot from a box. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreateSnapshot { + pub name: Option, + pub description: Option, +} + +impl CreateSnapshot { + pub fn new() -> Self { + Self::default() + } + + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + fn validate(&self) -> Result<()> { + if let Some(name) = &self.name { + validate_name("snapshot", name)?; + } + Ok(()) + } +} + +/// Request to restore a snapshot into a new box record. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RestoreSnapshot { + pub name: Option, +} + +impl RestoreSnapshot { + pub fn new() -> Self { + Self::default() + } + + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + fn validate(&self) -> Result<()> { + if let Some(name) = &self.name { + validate_name("box", name)?; + } + Ok(()) + } +} + +/// How a stop request completed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StopOutcome { + AlreadyExited, + GracefulExit, + ForceKilled, +} + +impl StopOutcome { + fn inferred_exit_code(self, stop_signal: i32) -> Option { + match self { + Self::AlreadyExited => None, + Self::GracefulExit => Some(128 + stop_signal), + Self::ForceKilled => Some(137), + } + } +} + +/// Result of stopping a box. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StopBoxSummary { + pub id: String, + pub name: String, + pub outcome: StopOutcome, + pub exit_code: Option, + pub auto_removed: bool, + pub box_summary: Option, +} diff --git a/src/sdk/src/lib.rs b/src/sdk/src/lib.rs index 36eb27c8..7a774a20 100644 --- a/src/sdk/src/lib.rs +++ b/src/sdk/src/lib.rs @@ -1,8 +1,39 @@ //! a3s-box SDK — drive a3s-box from Rust. //! -//! Currently provides [`pipeline`]: programmable CI/CD where a pipeline is a Rust -//! program and a3s-box is the execution backend (one MicroVM kernel per step). -//! More capabilities will be added over time — this crate is intentionally not -//! limited to CI. +//! Provides [`client`]: typed, runtime-backed local management APIs for boxes, +//! pause/unpause/stop/remove/prune lifecycle transitions, images, volumes, +//! networks, snapshot create/restore/list/remove/prune, image build/pull/push, +//! and guest control sockets. +mod box_state; + +pub mod client; + +#[cfg(feature = "pipeline-cli")] pub mod pipeline; + +pub use client::{ + A3sBoxClient, A3sBoxPaths, BoxLogLine, BoxStatsSummary, BoxSummary, BuildImage, + BuildImageSummary, ClientError, CreateNetwork, CreateSnapshot, CreateVolume, + ImageHealthCheckSummary, ImageHistoryEntry, ImageInspectSummary, ImageSummary, + ListBoxesOptions, NetworkEndpointSummary, NetworkSummary, PullImage, PushImage, + PushImageSummary, ReadBoxLogsOptions, RegistryCredentials, RemoveBox, RemoveBoxSummary, + RestoreSnapshot, Result, RuntimeDiagnostics, RuntimeDiskUsage, RuntimeVirtualizationSummary, + SnapshotSummary, StopBox, StopBoxSummary, StopOutcome, TagImage, VolumeSummary, +}; + +pub use a3s_box_core::{ + BoxConfig, CreateExecutionRequest, ExecOutput, ExecRequest, ExecutionGeneration, + ExecutionHealthCheck, ExecutionId, ExecutionIsolation, ExecutionLease, ExecutionManager, + ExecutionManagerError, ExecutionRecordPolicy, ExecutionReservation, ExecutionRestartPolicy, + ExecutionState, ExecutionStatus, FileOp, FileRequest, FileResponse, KillOutcome, OperationId, + Platform, ReconcileOutcome, RestartExecutionOptions, +}; +pub use a3s_box_runtime::{RegistryAuth, RegistryProtocol, SignaturePolicy}; + +#[cfg(unix)] +pub use a3s_box_runtime::{ + AttestationPolicy, AttestationReport, AttestationRequest, ExecClient, PtyClient, + RaTlsAttestationClient, StreamingExec, StreamingExecInput, StreamingPty, StreamingPtyInput, + VerificationResult, +}; diff --git a/src/sdk/src/pipeline.rs b/src/sdk/src/pipeline.rs index db977701..bd5526f2 100644 --- a/src/sdk/src/pipeline.rs +++ b/src/sdk/src/pipeline.rs @@ -1,10 +1,10 @@ //! Programmable CI on a3s-box. A pipeline is a Rust program; a3s-box is the //! execution backend — one Linux kernel per step, exit code = pass/fail. //! -//! A thin, dependency-free wrapper over the `a3s-box` CLI (it owns the box -//! lifecycle + state, so we drive it rather than re-implementing that). It hides -//! the CLI footguns verified on a real KVM host: `run`/`exec` need `--` before -//! the command; `snapshot restore` yields a *created* box that must be `start`ed +//! This pipeline layer still drives lifecycle-heavy `a3s-box` CLI commands while +//! the lower-level runtime exposes those flows as stable Rust APIs. It hides the +//! CLI footguns verified on a real KVM host: `run`/`exec` need `--` before the +//! command; `snapshot restore` yields a *created* box that must be `start`ed //! before exec; `snapshot rm` keys on snapshot ID, not name. //! //! Model: warm a base box once (clone + install deps), `snapshot` it, then fork diff --git a/src/sdk/tests/managed_lifecycle.rs b/src/sdk/tests/managed_lifecycle.rs new file mode 100644 index 00000000..9b9ae8b0 --- /dev/null +++ b/src/sdk/tests/managed_lifecycle.rs @@ -0,0 +1,134 @@ +//! Opt-in real-runtime proof for the SDK managed lifecycle facade. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use a3s_box_sdk::{ + A3sBoxClient, BoxConfig, CreateExecutionRequest, ExecutionIsolation, ExecutionRecordPolicy, + ExecutionState, KillOutcome, OperationId, +}; + +#[tokio::test] +#[ignore = "requires a dedicated A3S OS home and certified Sandbox runtime"] +async fn sdk_create_start_run_and_kill_use_the_canonical_manager() { + let home = validated_home(); + let client = A3sBoxClient::from_home(&home); + let image = + std::env::var("A3S_BOX_SDK_SMOKE_IMAGE").unwrap_or_else(|_| "alpine:3.20".to_string()); + + let create_operation = operation("create"); + let reservation = client + .create_box(request(&image, "created"), &create_operation) + .await + .unwrap(); + assert!(!home + .join("boxes") + .join(reservation.execution_id.as_str()) + .exists()); + let lease = client + .start_box(&reservation.execution_id, reservation.generation) + .await + .unwrap(); + assert_eq!(lease.execution_id, reservation.execution_id); + assert_eq!( + client + .inspect_execution(&lease.execution_id) + .await + .unwrap() + .state, + ExecutionState::Running + ); + assert_eq!( + client + .kill_execution(&lease.execution_id, lease.generation) + .await + .unwrap(), + KillOutcome::Killed + ); + assert_runtime_removed(&home, lease.execution_id.as_str()); + + let run_operation = operation("run"); + let running = client + .run_box(request(&image, "running"), &run_operation) + .await + .unwrap(); + assert_eq!( + client + .inspect_execution(&running.execution_id) + .await + .unwrap() + .state, + ExecutionState::Running + ); + assert_eq!( + client + .kill_execution(&running.execution_id, running.generation) + .await + .unwrap(), + KillOutcome::Killed + ); + assert_runtime_removed(&home, running.execution_id.as_str()); +} + +fn validated_home() -> PathBuf { + assert_eq!( + std::env::var("A3S_BOX_SDK_MANAGED_SMOKE").as_deref(), + Ok("1"), + "set A3S_BOX_SDK_MANAGED_SMOKE=1 to acknowledge the destructive smoke test" + ); + let home = PathBuf::from(std::env::var_os("A3S_HOME").expect("A3S_HOME is required")); + assert!(home.is_absolute()); + assert!(home + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains("sdk-managed-smoke"))); + let configured_crun = PathBuf::from( + std::env::var_os("A3S_BOX_CRUN_PATH").expect("A3S_BOX_CRUN_PATH is required"), + ); + assert_eq!( + configured_crun.canonicalize().unwrap(), + home.join("bin/crun").canonicalize().unwrap() + ); + for binary in ["crun", "a3s-box-shim", "a3s-box-guest-init"] { + assert!(home.join("bin").join(binary).is_file()); + } + home +} + +fn request(image: &str, suffix: &str) -> CreateExecutionRequest { + CreateExecutionRequest { + external_sandbox_id: format!("sdk-smoke-{suffix}"), + config: BoxConfig { + image: image.to_string(), + isolation: ExecutionIsolation::Sandbox, + cmd: vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "while :; do sleep 60; done".to_string(), + ], + ..BoxConfig::default() + }, + labels: BTreeMap::from([("purpose".to_string(), "sdk-managed-smoke".to_string())]), + policy: ExecutionRecordPolicy { + name: Some(format!("sdk-managed-smoke-{suffix}")), + ..ExecutionRecordPolicy::default() + }, + rootfs_snapshot_id: None, + } +} + +fn operation(suffix: &str) -> OperationId { + OperationId::new(format!( + "sdk-managed-smoke-{suffix}-{}", + uuid::Uuid::new_v4() + )) + .unwrap() +} + +fn assert_runtime_removed(home: &Path, execution_id: &str) { + assert!(!home.join("boxes").join(execution_id).exists()); + assert!(!home.join("run/crun").join(execution_id).exists()); + assert!(!Path::new("/tmp/a3s-box-sockets") + .join(execution_id) + .exists()); +} diff --git a/src/shim/Cargo.toml b/src/shim/Cargo.toml index 5b8376c2..fad87426 100644 --- a/src/shim/Cargo.toml +++ b/src/shim/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [dependencies] a3s-box-core = { path = "../core" } -libkrun-sys = { version = "2.2.0", path = "../deps/libkrun-sys", package = "a3s-libkrun-sys" } +libkrun-sys = { version = "3.0", path = "../deps/libkrun-sys", package = "a3s-libkrun-sys" } # CLI clap = { workspace = true } diff --git a/src/shim/src/krun/context.rs b/src/shim/src/krun/context.rs index c39a0ed6..6dafef9e 100644 --- a/src/shim/src/krun/context.rs +++ b/src/shim/src/krun/context.rs @@ -20,9 +20,10 @@ use libkrun_sys::krun_set_port_map; use libkrun_sys::{krun_add_net_tcp, krun_add_vsock_port_windows, krun_set_kernel}; #[cfg(target_os = "linux")] use libkrun_sys::{krun_add_net_unixstream, krun_split_irqchip}; +#[cfg(unix)] +use libkrun_sys::{krun_add_virtio_console_default, krun_disable_implicit_console}; use libkrun_sys::{ - krun_add_virtio_console_default, krun_add_virtiofs, krun_create_ctx, - krun_disable_implicit_console, krun_free_ctx, krun_init_log, krun_set_console_output, + krun_add_virtiofs, krun_create_ctx, krun_free_ctx, krun_init_log, krun_set_console_output, krun_set_env, krun_set_exec, krun_set_rlimits, krun_set_root, krun_set_vm_config, krun_set_workdir, krun_setgid, krun_setuid, krun_start_enter, }; @@ -288,6 +289,7 @@ impl KrunContext { /// # Arguments /// * `port_map` - Slice of "host_port:guest_port" strings (e.g., ["8080:80", "3000:3000"]) #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] pub unsafe fn set_port_map(&self, port_map: &[String]) -> Result<()> { tracing::debug!(port_map = ?port_map, "Setting TSI port mappings"); let entries: Vec = port_map @@ -441,6 +443,7 @@ impl KrunContext { /// Replace the implicit console with a virtio-console whose stdout and /// stderr go to SEPARATE host fds (so the guest's stderr can be tagged). /// `input_fd` may be -1 (no stdin). Caller owns the fds for the VM lifetime. + #[cfg(unix)] pub unsafe fn add_split_console( &self, input_fd: i32, diff --git a/src/shim/src/main.rs b/src/shim/src/main.rs index 3c8256e9..3217287f 100644 --- a/src/shim/src/main.rs +++ b/src/shim/src/main.rs @@ -22,7 +22,7 @@ use a3s_box_core::PORT_FWD_VSOCK_PORT; #[cfg(not(target_os = "windows"))] use a3s_box_core::{ATTEST_VSOCK_PORT, PORT_FWD_VSOCK_PORT, PTY_VSOCK_PORT}; #[cfg(target_os = "macos")] -use a3s_box_netproxy::spawn_inherited_netproxy; +use a3s_box_netproxy::{spawn_inherited_netproxy, InheritedNetProxyConfig}; use clap::Parser; use krun::KrunContext; #[cfg(target_os = "windows")] @@ -41,6 +41,12 @@ struct Args { #[arg(long)] config: Option, + /// Internal: project one Sandbox generation's split console into its + /// configured log driver until the exact crun wrapper exits. + #[cfg(target_os = "linux")] + #[arg(long, hide = true)] + sandbox_log_worker_config: Option, + #[cfg(target_os = "windows")] #[arg(long, hide = true)] port_fwd_worker: bool, @@ -114,6 +120,11 @@ fn maybe_enable_ksm_merge() {} fn run() -> Result<()> { let args = Args::parse(); + #[cfg(target_os = "linux")] + if let Some(config) = args.sandbox_log_worker_config.as_deref() { + return run_sandbox_log_worker(config); + } + #[cfg(target_os = "windows")] if args.port_fwd_worker { let box_id = args.box_id.ok_or_else(|| BoxError::BoxBootError { @@ -204,6 +215,140 @@ fn run() -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +fn run_sandbox_log_worker(config: &str) -> Result<()> { + use a3s_box_core::log::{ + run_log_processor_with_ready_and_eof_policy, ConsoleEofPolicy, SandboxLogWorkerSpec, + }; + use std::io::Write; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + let spec: SandboxLogWorkerSpec = + serde_json::from_str(config).map_err(|error| BoxError::BoxBootError { + message: format!("Failed to parse Sandbox log worker config: {error}"), + hint: None, + })?; + validate_sandbox_log_worker_spec(&spec)?; + + let stop = Arc::new(AtomicBool::new(false)); + let ready = Arc::new(AtomicUsize::new(0)); + let console_log = spec.console_log.clone(); + let log_dir = console_log + .parent() + .ok_or_else(|| BoxError::BoxBootError { + message: format!( + "Sandbox console path has no parent: {}", + console_log.display() + ), + hint: None, + })? + .to_path_buf(); + let log_config = spec.log_config.clone(); + let processor_stop = Arc::clone(&stop); + let processor_ready = Arc::clone(&ready); + let processor = std::thread::spawn(move || { + run_log_processor_with_ready_and_eof_policy( + &console_log, + &log_dir, + &log_config, + &processor_stop, + Some(&processor_ready), + ConsoleEofPolicy::WriterClosed, + ); + }); + + let deadline = Instant::now() + Duration::from_secs(3); + while ready.load(Ordering::Acquire) < 2 { + if processor.is_finished() { + let _ = processor.join(); + return Err(BoxError::BoxBootError { + message: "Sandbox log processor exited before opening both console streams" + .to_string(), + hint: None, + }); + } + if Instant::now() >= deadline { + stop.store(true, Ordering::SeqCst); + let _ = processor.join(); + return Err(BoxError::BoxBootError { + message: "Sandbox log processor did not become ready before timeout".to_string(), + hint: None, + }); + } + std::thread::sleep(Duration::from_millis(5)); + } + + if let Some(parent) = spec.ready_file.parent() { + std::fs::create_dir_all(parent).map_err(BoxError::IoError)?; + } + let mut ready_file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&spec.ready_file) + .map_err(BoxError::IoError)?; + ready_file + .write_all(format!("{}\n", std::process::id()).as_bytes()) + .map_err(BoxError::IoError)?; + ready_file.sync_all().map_err(BoxError::IoError)?; + + while sandbox_watched_process_is_current(spec.watched_pid, spec.watched_pid_start_time) { + std::thread::sleep(Duration::from_millis(10)); + } + + // The exact wrapper identity is gone (or a zombie), so its stdout/stderr + // descriptors are closed. WriterClosed makes the next EOF final and still + // flushes a trailing partial line. + stop.store(true, Ordering::SeqCst); + processor.join().map_err(|_| BoxError::BoxBootError { + message: format!("Sandbox log processor panicked for {}", spec.box_id), + hint: None, + })?; + tracing::debug!(box_id = %spec.box_id, "Sandbox logs fully drained"); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn validate_sandbox_log_worker_spec(spec: &a3s_box_core::log::SandboxLogWorkerSpec) -> Result<()> { + if spec.schema != a3s_box_core::log::SANDBOX_LOG_WORKER_SCHEMA + || spec.box_id.is_empty() + || spec.watched_pid == 0 + || spec.watched_pid_start_time == 0 + || !spec.console_log.is_absolute() + || !spec.ready_file.is_absolute() + { + return Err(BoxError::BoxBootError { + message: "Invalid Sandbox log worker identity or path configuration".to_string(), + hint: None, + }); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn sandbox_watched_process_is_current(pid: u32, expected_start_time: u64) -> bool { + let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { + return false; + }; + linux_process_identity_from_stat(&stat) + .is_some_and(|(state, start_time)| state != 'Z' && start_time == expected_start_time) +} + +#[cfg(target_os = "linux")] +fn linux_process_identity_from_stat(stat: &str) -> Option<(char, u64)> { + // `comm` may contain spaces and parentheses. Field 3 (state) begins after + // the final `)`, and field 22 (starttime) is token 19 from that point. + let fields: Vec<&str> = stat + .get(stat.rfind(')')? + 1..)? + .split_whitespace() + .collect(); + let state = fields.first()?.chars().next()?; + let start_time = fields.get(19)?.parse().ok()?; + Some((state, start_time)) +} + /// Parse a Docker-style ulimit string into a krun rlimit string. /// /// Input format: "RESOURCE=SOFT:HARD" (e.g., "nofile=1024:4096") @@ -298,6 +443,7 @@ fn parse_cpuset_spec(spec: &str) -> std::result::Result, String> { } #[cfg(not(target_os = "windows"))] +#[cfg_attr(target_os = "macos", allow(dead_code))] fn tsi_port_map_for_spec(spec: &InstanceSpec) -> Vec { if native_bridge_port_forwarding_handles_spec(spec) { return Vec::new(); @@ -315,11 +461,13 @@ fn tsi_port_map_for_spec(spec: &InstanceSpec) -> Vec { // host_port_map once a virtio-net device is attached anyway, so feeding it the // port map is dead work; let the backend own forwarding instead. #[cfg(not(target_os = "windows"))] +#[cfg_attr(target_os = "macos", allow(dead_code))] fn native_bridge_port_forwarding_handles_spec(spec: &InstanceSpec) -> bool { spec.network.is_some() } #[cfg(not(target_os = "windows"))] +#[cfg_attr(target_os = "macos", allow(dead_code))] fn is_auto_assigned_host_port(mapping: &str) -> bool { mapping .split_once(':') @@ -477,7 +625,7 @@ unsafe fn configure_and_start_vm(spec: &InstanceSpec) -> Result<()> { // Must be called before add_vsock_port to avoid EINVAL from libkrun. // Skip entries handled by bridge-native forwarding or host_port=0 // auto-assignment, which would fail with EINVAL in libkrun's TSI. - #[cfg(not(target_os = "windows"))] + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] { let valid_port_map = tsi_port_map_for_spec(spec); @@ -661,12 +809,16 @@ unsafe fn configure_and_start_vm(spec: &InstanceSpec) -> Result<()> { if let Some(proxy_fd) = net_config.net_proxy_fd { spawn_inherited_netproxy( proxy_fd, - net_config.ip_address, - net_config.gateway, - net_config.prefix_len, - &net_config.dns_servers, - &spec.port_map, - net_config.net_stats_path.clone(), + InheritedNetProxyConfig { + guest_ip: net_config.ip_address, + gateway: net_config.gateway, + prefix_len: net_config.prefix_len, + dns_servers: &net_config.dns_servers, + port_map: &spec.port_map, + stats_path: net_config.net_stats_path.clone(), + bridge_socket_dir: net_config.bridge_socket_dir.clone(), + own_mac: net_config.mac_address, + }, )?; } log_inherited_net_fd(fd); @@ -698,6 +850,13 @@ unsafe fn configure_and_start_vm(spec: &InstanceSpec) -> Result<()> { apply_user_config(&ctx, user)?; } + // Keep split-console descriptors owned by the shim until start_enter + // returns. Closing them before the final log drain establishes a real EOF + // boundary; leaking them with mem::forget made short detached output race + // the processor indefinitely. + #[cfg(unix)] + let mut split_console_files: Option<(std::fs::File, std::fs::File)> = None; + // Configure console output if specified if let Some(console_path) = &spec.console_output { let console_str = console_path @@ -726,9 +885,7 @@ unsafe fn configure_and_start_vm(spec: &InstanceSpec) -> Result<()> { }; if let (Ok(out_f), Ok(err_f)) = (open(console_path), open(&err_path)) { ctx.add_split_console(-1, out_f.as_raw_fd(), err_f.as_raw_fd())?; - // Keep the fds open for the VM's lifetime. - std::mem::forget(out_f); - std::mem::forget(err_f); + split_console_files = Some((out_f, err_f)); tracing::debug!("split console enabled (stdout/stderr separated)"); split_done = true; } @@ -795,6 +952,7 @@ unsafe fn configure_and_start_vm(spec: &InstanceSpec) -> Result<()> { // daemonless home for log processing — a detached `run -d` box keeps logging // after the launching CLI exits (the processor used to die with that CLI). let log_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let log_ready = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let log_thread = spec.console_output.as_ref().map(|console| { let console = console.clone(); let log_dir = console @@ -803,16 +961,46 @@ unsafe fn configure_and_start_vm(spec: &InstanceSpec) -> Result<()> { .unwrap_or_else(|| std::path::PathBuf::from(".")); let config = spec.log_config.clone(); let stop = log_stop.clone(); + let ready = log_ready.clone(); std::thread::spawn(move || { - a3s_box_core::log::run_log_processor(&console, &log_dir, &config, &stop); + a3s_box_core::log::run_log_processor_with_ready( + &console, + &log_dir, + &config, + &stop, + Some(&ready), + ); }) }); + if log_thread.is_some() { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while log_ready.load(std::sync::atomic::Ordering::Acquire) < 2 { + if std::time::Instant::now() >= deadline { + log_stop.store(true, std::sync::atomic::Ordering::SeqCst); + if let Some(handle) = log_thread { + let _ = handle.join(); + } + return Err(BoxError::BoxBootError { + message: "log processor did not become ready before VM start".to_string(), + hint: None, + }); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + // Start VM. start_enter RETURNS with the guest exit status once the guest // exits (status >= 0) or on a start failure (status < 0). tracing::info!(box_id = %spec.box_id, "Starting VM (process takeover)"); let status = ctx.start_enter(); + // No guest writes are valid after start_enter returns. Close the shim's + // console descriptors before signaling the readers so their final EOF is + // authoritative and all kernel-buffered output is visible. + #[cfg(unix)] + drop(split_console_files); + // Guest has exited and console.log is fully flushed: signal the processor to // drain the remainder and stop, then join so the final lines reach // container.json before this process exits (no teardown race). @@ -820,6 +1008,38 @@ unsafe fn configure_and_start_vm(spec: &InstanceSpec) -> Result<()> { if let Some(handle) = log_thread { let _ = handle.join(); } + if let Some(console) = spec.console_output.as_ref() { + let structured = a3s_box_core::log::json_log_path( + console + .parent() + .unwrap_or_else(|| std::path::Path::new(".")), + ); + let stderr_console = a3s_box_core::log::stderr_console_path(console); + let structured_empty = structured.metadata().map(|m| m.len() == 0).unwrap_or(true); + let raw_has_output = console.metadata().map(|m| m.len() > 0).unwrap_or(false) + || stderr_console + .metadata() + .map(|m| m.len() > 0) + .unwrap_or(false); + if structured_empty + && raw_has_output + && spec.log_config.driver == a3s_box_core::log::LogDriver::JsonFile + { + // A very short VM can finish while the first processor is sitting + // on a provisional console EOF. Its raw files are authoritative at + // this point (start_enter returned and the write fds are closed), so + // repair the empty projection synchronously. The empty guard makes + // this idempotent and prevents duplicate records. + a3s_box_core::log::run_log_processor( + console, + console + .parent() + .unwrap_or_else(|| std::path::Path::new(".")), + &spec.log_config, + &log_stop, + ); + } + } // If we reach here, either: // 1. VM failed to start (negative status) @@ -924,6 +1144,33 @@ unsafe fn apply_user_config(ctx: &KrunContext, user: &str) -> Result<()> { mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn parses_sandbox_worker_pid_identity_after_complex_comm() { + let stat = + "123 (crun (sandbox) worker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 4242"; + assert_eq!(linux_process_identity_from_stat(stat), Some(('S', 4242))); + assert_eq!(linux_process_identity_from_stat("malformed"), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn validates_complete_sandbox_log_worker_identity() { + let mut spec = a3s_box_core::log::SandboxLogWorkerSpec { + schema: a3s_box_core::log::SANDBOX_LOG_WORKER_SCHEMA.to_string(), + box_id: "sandbox-id".to_string(), + console_log: std::path::PathBuf::from("/tmp/sandbox-id/console.log"), + log_config: a3s_box_core::log::LogConfig::default(), + watched_pid: 123, + watched_pid_start_time: 456, + ready_file: std::path::PathBuf::from("/tmp/sandbox-id/log-worker.ready"), + }; + validate_sandbox_log_worker_spec(&spec).unwrap(); + + spec.watched_pid_start_time = 0; + assert!(validate_sandbox_log_worker_spec(&spec).is_err()); + } + #[test] fn test_parse_ulimit_nofile() { assert_eq!( @@ -984,7 +1231,7 @@ mod tests { assert!(parse_ulimit("sigpending=100:200").is_some()); } - #[cfg(not(target_os = "windows"))] + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] #[test] fn test_tsi_port_map_for_spec_filters_auto_assigned_host_ports() { let spec = InstanceSpec { @@ -1036,6 +1283,8 @@ mod tests { net_socket_fd: Some(42), #[cfg(target_os = "macos")] net_proxy_fd: Some(43), + #[cfg(target_os = "macos")] + bridge_socket_dir: Some(std::path::PathBuf::from("/tmp/a3s-switch")), ip_address: "10.89.0.2".parse().unwrap(), gateway: "10.89.0.1".parse().unwrap(), prefix_len: 24,