diff --git a/.github/workflows/auto-generate-docs.yml b/.github/workflows/auto-generate-docs.yml new file mode 100644 index 00000000..b1577756 --- /dev/null +++ b/.github/workflows/auto-generate-docs.yml @@ -0,0 +1,356 @@ +# Auto-generate CLI/SDK reference documentation and publish to vast-ai/docs. +# +# Mirrors the OpenAPI auto-generation pipeline that lives in the vast (backend) +# repo's bitbucket-pipelines.yml — same pattern, same docs repo target: +# +# * On a PR to vast-cli, generate the docs into a preview branch on +# vast-ai/docs and open a PR there for Mintlify preview. +# * On merge to master, push the regenerated docs as a PR to vast-ai/docs +# so the on-merge auto-deploy ships them. +# * Manual dispatch lets us regenerate on demand. +# +# Required repo secrets (Settings → Secrets and variables → Actions): +# DOCS_DEPLOY_KEY ed25519 SSH private key with write access to +# vast-ai/docs. Same key the vast Bitbucket pipeline +# already uses; add the matching deploy key to the +# vast-cli repo too if it isn't already paired. +# DOCS_GITHUB_TOKEN classic PAT with `repo` scope for creating PRs in +# vast-ai/docs via the GitHub API. + +name: Auto-generate CLI/SDK Docs + +on: + pull_request: + paths: + - "vastai/**" + - "scripts/generate_cli_sdk_docs.py" + - "scripts/patch_docs_nav.py" + - "pyproject.toml" + - ".github/workflows/auto-generate-docs.yml" + push: + branches: [master, main] + paths: + - "vastai/**" + - "scripts/generate_cli_sdk_docs.py" + - "scripts/patch_docs_nav.py" + - "pyproject.toml" + - ".github/workflows/auto-generate-docs.yml" + workflow_dispatch: {} + +concurrency: + group: auto-generate-docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + generate: + runs-on: ubuntu-latest + env: + DOCS_REPO: vast-ai/docs + DOCS_DEFAULT_BRANCH: main + CLI_SUBDIR: cli/reference + SDK_SUBDIR: sdk/python/reference + steps: + - name: Checkout vast-cli + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install vast-cli (editable) + run: pip install -e . + + - name: Configure SSH for docs repo + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DOCS_DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan github.com >> ~/.ssh/known_hosts + + - name: Clone docs repo + run: | + git clone "git@github.com:${DOCS_REPO}.git" docs-repo + cd docs-repo + git config user.email "vast-cli-docs-bot@vast.ai" + git config user.name "vast-cli docs bot" + + - name: Wipe stale CLI/SDK MDX before regeneration + # Without this, files persist after their source command/method is + # removed (e.g. cluster/overlay commands disabled in vast-cli/main.py + # left 11 orphan MDX files on the preview branch as of 2026-05-20). + # Orphans are harmless visually (not in nav, not rendered) but they + # accumulate and confuse audits. Preserve hand-authored entries: the + # SDK overview at sdk/python/reference/vastai.mdx is the only one. + run: | + cd docs-repo + find "${CLI_SUBDIR}" -maxdepth 1 -name '*.mdx' -delete 2>/dev/null || true + find "${SDK_SUBDIR}" -maxdepth 1 -name '*.mdx' ! -name 'vastai.mdx' -delete 2>/dev/null || true + + - name: Generate docs into clone + run: | + python scripts/generate_cli_sdk_docs.py \ + --output-dir docs-repo \ + --cli-subdir "${CLI_SUBDIR}" \ + --sdk-subdir "${SDK_SUBDIR}" \ + --manifest manifest.json + + - name: Patch docs.json navigation + # Mintlify only renders pages registered in docs.json's nav. This + # step adds new generated pages to the right semantic groups and + # removes nav entries for deleted commands. Without it, every + # generated PR ships orphan pages and Mintlify skips the preview. + run: | + python scripts/patch_docs_nav.py \ + --docs-json docs-repo/docs.json \ + --manifest manifest.json + + - name: Show diff summary + id: diff + run: | + cd docs-repo + git add -A "${CLI_SUBDIR}" "${SDK_SUBDIR}" docs.json + if git diff --cached --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No CLI/SDK doc changes detected." + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + git diff --cached --stat | tee ../diff-stat.txt + fi + + - name: Upload manifest + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-sdk-docs-manifest + path: | + manifest.json + diff-stat.txt + + # ---------------------------------------------------------------- + # PR (preview): push to a preview branch and open a PR in docs repo. + # ---------------------------------------------------------------- + - name: Push preview branch + if: | + github.event_name == 'pull_request' && + steps.diff.outputs.has_changes == 'true' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_HTML_URL: ${{ github.event.pull_request.html_url }} + run: | + cd docs-repo + PREVIEW_BRANCH="auto/cli-sdk-preview-pr-${PR_NUMBER}" + git checkout -b "${PREVIEW_BRANCH}" + git commit -m "preview: CLI/SDK docs for vast-cli PR #${PR_NUMBER} + + Source PR: ${PR_HTML_URL} + Title: ${PR_TITLE} + Auto-generated by .github/workflows/auto-generate-docs.yml" + git push -f origin "${PREVIEW_BRANCH}" + echo "PREVIEW_BRANCH=${PREVIEW_BRANCH}" >> "$GITHUB_ENV" + + - name: Open or update preview PR in docs repo + if: | + github.event_name == 'pull_request' && + steps.diff.outputs.has_changes == 'true' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_HTML_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.DOCS_GITHUB_TOKEN }} + run: | + set -e + BODY=$(cat <> "$GITHUB_ENV" + + - name: Open deploy PR in docs repo + if: | + github.event_name == 'push' && + steps.diff.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.DOCS_GITHUB_TOKEN }} + run: | + BODY=$(cat < + # tokens in argparse epilogs parsed as JSX and failed the page build). + # This step waits for Mintlify to deploy the freshly-pushed commit, then + # curls every CLI/SDK reference URL in docs.json and fails the workflow + # on any non-200. Catches silent generator regressions before they merge. + # ---------------------------------------------------------------- + - name: Wait for Mintlify and sweep for 404s + if: | + steps.diff.outputs.has_changes == 'true' && + (github.event_name == 'pull_request' || github.event_name == 'push') + env: + GH_TOKEN: ${{ secrets.DOCS_GITHUB_TOKEN }} + run: | + set -e + cd docs-repo + HEAD_SHA=$(git rev-parse HEAD) + echo "Waiting for Mintlify deploy on docs commit ${HEAD_SHA}..." + + PREVIEW_URL="" + # Poll for up to ~10 minutes (40 * 15s). Mintlify usually deploys in + # 1-2 min, but cold builds on a fresh preview branch can take longer. + for i in $(seq 1 40); do + state=$(curl -fsSL \ + -H "Authorization: token ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${DOCS_REPO}/commits/${HEAD_SHA}/check-runs" 2>/dev/null \ + | jq -r '.check_runs[]? | select(.name=="Mintlify Deployment") | "\(.status) \(.conclusion // "null") \(.output.summary // "")"' || echo "") + + status=$(echo "$state" | awk '{print $1}') + conclusion=$(echo "$state" | awk '{print $2}') + + if [ "$status" = "completed" ]; then + if [ "$conclusion" = "success" ]; then + PREVIEW_URL=$(echo "$state" | grep -oE 'https://[^[:space:]!]+\.mintlify\.app' | head -1) + if [ -z "$PREVIEW_URL" ]; then + echo "::error::Mintlify reported success but no preview URL in summary: ${state}" + exit 1 + fi + echo "Mintlify deployed: ${PREVIEW_URL} (after ${i} polls)" + break + else + echo "::error::Mintlify deployment ${conclusion}" + exit 1 + fi + fi + sleep 15 + done + + if [ -z "$PREVIEW_URL" ]; then + echo "::error::Mintlify deploy did not complete on ${HEAD_SHA} within 10 minutes" + exit 1 + fi + + mapfile -t PAGES < <(jq -r '.. | strings? | select(startswith("cli/reference/") or startswith("sdk/python/reference/"))' docs.json | sort -u) + if [ "${#PAGES[@]}" -eq 0 ]; then + echo "::warning::No CLI/SDK pages found in docs.json — skipping sweep" + exit 0 + fi + echo "Sweeping ${#PAGES[@]} CLI/SDK reference pages..." + + FAIL_COUNT=0 + FAILED=() + for path in "${PAGES[@]}"; do + [ -z "$path" ] && continue + code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 20 "${PREVIEW_URL}/${path}" || echo "000") + if [ "$code" != "200" ]; then + echo "::error file=${path}.mdx::HTTP ${code} on ${PREVIEW_URL}/${path}" + FAILED+=("${path} (HTTP ${code})") + FAIL_COUNT=$((FAIL_COUNT+1)) + fi + done + + if [ $FAIL_COUNT -gt 0 ]; then + echo "::error::${FAIL_COUNT} of ${#PAGES[@]} pages failed the 404 sweep:" + printf ' - %s\n' "${FAILED[@]}" + exit 1 + fi + echo "All ${#PAGES[@]} pages returned HTTP 200." + + # ---------------------------------------------------------------- + # Cleanup: close orphan preview PRs whose source PR was merged/closed. + # Runs on master pushes, when we know which previews are obsolete. + # ---------------------------------------------------------------- + - name: Close stale preview PRs + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ secrets.DOCS_GITHUB_TOKEN }} + run: | + # Best-effort: list open auto/cli-sdk-preview-pr-* branches, check + # the source PR state in vast-cli, close + delete branches whose + # source is no longer open. + curl -fsSL \ + -H "Authorization: token ${GH_TOKEN}" \ + "https://api.github.com/repos/${DOCS_REPO}/pulls?state=open&per_page=100" \ + | python <<'PY' + import json, os, re, subprocess, sys, urllib.request + repo = os.environ["DOCS_REPO"] + tok = os.environ["GH_TOKEN"] + src = "${{ github.repository }}" + for pr in json.load(sys.stdin): + m = re.match(r"auto/cli-sdk-preview-pr-(\d+)$", pr["head"]["ref"]) + if not m: + continue + src_num = m.group(1) + req = urllib.request.Request( + f"https://api.github.com/repos/{src}/pulls/{src_num}", + headers={"Authorization": f"token {tok}"}, + ) + try: + src_pr = json.loads(urllib.request.urlopen(req).read()) + except Exception: + continue + if src_pr.get("state") == "closed": + print(f"Closing stale preview PR #{pr['number']} (source PR #{src_num} closed)") + close_req = urllib.request.Request( + f"https://api.github.com/repos/{repo}/pulls/{pr['number']}", + method="PATCH", + headers={"Authorization": f"token {tok}", + "Content-Type": "application/json"}, + data=json.dumps({"state": "closed"}).encode(), + ) + urllib.request.urlopen(close_req) + PY diff --git a/.github/workflows/verify-docs.yml b/.github/workflows/verify-docs.yml new file mode 100644 index 00000000..ce7b3f32 --- /dev/null +++ b/.github/workflows/verify-docs.yml @@ -0,0 +1,122 @@ +# GitHub Actions workflow for vast-ai/vast-cli or vast-ai/docs repo. +# Validates that CLI/SDK documentation stays in sync with the actual package. +# +# Place this file at .github/workflows/verify-docs.yml in whichever repo +# should own the check (typically vast-cli, since it's the source of truth). + +name: Verify CLI/SDK Docs + +on: + # Run on PRs that change CLI or SDK code + pull_request: + paths: + - "vastai/**" + - "vastai_sdk/**" + - "pyproject.toml" + + # Run on pushes to master (after merge) + push: + branches: [master, main] + + # Run weekly to catch drift even without code changes + schedule: + - cron: "0 9 * * 1" # Monday 9am UTC + + workflow_dispatch: {} + +jobs: + verify-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout vast-cli + uses: actions/checkout@v4 + + - name: Checkout docs repo + uses: actions/checkout@v4 + with: + repository: vast-ai/docs + path: docs-repo + # If docs repo is private, add a token: + # token: ${{ secrets.DOCS_REPO_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install vast-cli package + run: pip install -e . + + - name: Run inventory check + run: | + python scripts/verify_cli_sdk_docs.py \ + --docs-path docs-repo \ + --check-params \ + --json > drift-report.json + + - name: Display report + if: always() + run: | + python scripts/verify_cli_sdk_docs.py \ + --docs-path docs-repo \ + --check-params + + - name: Upload drift report + if: always() + uses: actions/upload-artifact@v4 + with: + name: drift-report + path: drift-report.json + + - name: Comment on PR if drift detected + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync('drift-report.json', 'utf8')); + let body = '## Documentation Drift Detected\n\n'; + if (report.cli.undocumented.length) { + body += `### CLI commands missing docs (${report.cli.undocumented.length})\n`; + report.cli.undocumented.forEach(c => body += `- \`${c}\`\n`); + } + if (report.cli.stale_docs.length) { + body += `### CLI docs for removed commands (${report.cli.stale_docs.length})\n`; + report.cli.stale_docs.forEach(c => body += `- \`${c}\`\n`); + } + if (report.sdk.undocumented.length) { + body += `### SDK methods missing docs (${report.sdk.undocumented.length})\n`; + report.sdk.undocumented.forEach(m => body += `- \`${m}\`\n`); + } + if (report.sdk.stale_docs.length) { + body += `### SDK docs for removed methods (${report.sdk.stale_docs.length})\n`; + report.sdk.stale_docs.forEach(m => body += `- \`${m}\`\n`); + } + body += '\n> Update docs in [vast-ai/docs](https://github.com/vast-ai/docs) to resolve.'; + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + + - name: Open issue on scheduled drift + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = JSON.parse(fs.readFileSync('drift-report.json', 'utf8')); + const counts = [ + report.cli.undocumented.length, + report.cli.stale_docs.length, + report.sdk.undocumented.length, + report.sdk.stale_docs.length, + ].reduce((a, b) => a + b, 0); + github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Docs drift: ${counts} items out of sync`, + body: '```json\n' + JSON.stringify(report, null, 2) + '\n```', + labels: ['documentation'], + }); diff --git a/docs/verify-cli-sdk-docs.md b/docs/verify-cli-sdk-docs.md new file mode 100644 index 00000000..a61f128f --- /dev/null +++ b/docs/verify-cli-sdk-docs.md @@ -0,0 +1,97 @@ +# CLI/SDK Documentation Verification + +This guide explains how to verify that the CLI/SDK documentation in +[vast-ai/docs](https://github.com/vast-ai/docs) matches the actual `vastai` +CLI commands and SDK methods. + +## Prerequisites + +- Python 3.10+ +- The `vastai` package installed (from this repo) +- A clone of the docs repo (or a specific branch/PR) + +## Quick Start + +```bash +# 1. Install the vastai package from this repo +pip install -e . + +# 2. Clone the docs repo (or a specific PR branch) +git clone https://github.com/vast-ai/docs.git /tmp/docs + +# To check a specific PR branch instead: +# git clone --branch --depth 1 https://github.com/vast-ai/docs.git /tmp/docs + +# 3. Run the inventory check +python3 scripts/verify_cli_sdk_docs.py --docs-path /tmp/docs + +# 4. Run with parameter-level validation +python3 scripts/verify_cli_sdk_docs.py --docs-path /tmp/docs --check-params + +# 5. Output as JSON (useful for CI or sharing) +python3 scripts/verify_cli_sdk_docs.py --docs-path /tmp/docs --check-params --json > drift-report.json +``` + +## What It Checks + +### Inventory (always runs) +- **CLI commands missing docs**: commands found in `vastai --help` with no + matching `cli/reference/.mdx` page +- **Stale CLI docs**: MDX pages for commands that no longer exist in the CLI +- **SDK methods missing docs**: public methods on the `VastAI` class with no + matching `sdk/python/reference/.mdx` page +- **Stale SDK docs**: MDX pages for methods that no longer exist in the SDK + +### Parameter validation (`--check-params`) +- **Undocumented flags/params**: flags in `--help` output or method signature + parameters not mentioned in the corresponding MDX page +- **Stale flags/params**: flags/params documented in the MDX page that no + longer exist in the CLI/SDK + +## Naming Conventions + +The script converts between naming conventions for matching: + +| Source | Convention | Example | +|--------|-----------|---------| +| CLI commands | kebab-case | `show-instances` | +| SDK methods | snake_case | `show_instances` | +| Doc filenames | kebab-case | `show-instances.mdx` | + +**Note**: If the CLI uses a two-level command structure (e.g., `vastai show +instances`), the script parses top-level subcommands from `vastai --help`. The +doc filenames should match the flattened kebab-case form (e.g., +`show-instances.mdx`). If this doesn't match, the script will report drift. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | No drift detected | +| 1 | Drift detected | +| 2 | Script error (missing files, import failure, etc.) | + +## Interpreting Results + +Not all drift is a bug: + +- **CLI command restructuring** (e.g., flat → two-level commands) will show + everything as "stale" under the old names and "undocumented" under the new + names. This means the verification script's name-matching logic may need + updating to match the new CLI structure. +- **`kwargs` as undocumented param** means the SDK method accepts flexible + keyword arguments. The docs should list the commonly used kwargs, but the + script can't extract individual kwargs from `**kwargs`. +- **Case differences** (e.g., `Id` vs `id`, `COMMAND` vs `command`) are + flagged as mismatches. These are usually cosmetic. + +## Automation + +This check runs automatically via GitHub Actions +(`.github/workflows/verify-docs.yml`): + +| Trigger | Behavior | +|---------|----------| +| PR that changes `vastai/` or `vastai_sdk/` | Runs check, comments on PR if drift found | +| Push to master | Runs check | +| Weekly (Monday 9am UTC) | Opens a GitHub issue if drift detected | diff --git a/docs/workflows/verify-docs-on-docs-pr.yml b/docs/workflows/verify-docs-on-docs-pr.yml new file mode 100644 index 00000000..eb0e4f70 --- /dev/null +++ b/docs/workflows/verify-docs-on-docs-pr.yml @@ -0,0 +1,57 @@ +# Place this file at vast-ai/docs/.github/workflows/verify-cli-sdk-docs.yml +# Runs the CLI/SDK verification script when doc pages change in the docs repo. +# +# This complements the workflow in vast-cli that runs when CLI/SDK code changes. +# Together they catch drift from both directions: +# - vast-cli changes → docs repo workflow detects stale docs +# - docs changes → this workflow detects incorrect docs + +name: Verify CLI/SDK Docs + +on: + pull_request: + paths: + - "cli/reference/**" + - "sdk/python/reference/**" + + workflow_dispatch: {} + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout docs repo + uses: actions/checkout@v4 + + - name: Checkout vast-cli (for verification script) + uses: actions/checkout@v4 + with: + repository: vast-ai/vast-cli + path: vast-cli + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install vastai package + run: pip install -e vast-cli + + - name: Run verification + run: | + python vast-cli/scripts/verify_cli_sdk_docs.py \ + --docs-path . \ + --check-params \ + --json > drift-report.json || true + + # Always show human-readable output + python vast-cli/scripts/verify_cli_sdk_docs.py \ + --docs-path . \ + --check-params + + - name: Upload drift report + if: always() + uses: actions/upload-artifact@v4 + with: + name: drift-report + path: drift-report.json diff --git a/scripts/data/api_scopes.json b/scripts/data/api_scopes.json new file mode 100644 index 00000000..185c3dd1 --- /dev/null +++ b/scripts/data/api_scopes.json @@ -0,0 +1,1167 @@ +{ + "_meta": { + "internal_scopes": [ + "admin_read_new", + "admin_write_new", + "lower_admin", + "legion_s3", + "autoscaler_master" + ], + "note": "Keyed by URL pattern (Pyramid route pattern). Methods map to the scope required for that method on that route.", + "source": "generated from vast/web/paths.py + vast/web/scope.json" + }, + "endpoints": { + "/api/0aea36/admin/report/template_earnings/": { + "GET": "admin_read_new" + }, + "/api/0bdb40/admin/apikey_info/": { + "POST": "lower_admin" + }, + "/api/119300/admin/clear_known_hosts/": { + "PUT": "admin_write_new" + }, + "/api/164ea6/admin/machines/create_cpu/": { + "GET": "admin_read_new", + "POST": "admin_write_new" + }, + "/api/2976c2/admin/data/": { + "GET": "admin_read_new", + "POST": "admin_write_new" + }, + "/api/2f8c94/admin/enable-user-payouts/": { + "POST": "lower_admin" + }, + "/api/30dc03/admin/user/{user_id}/verify-email/": { + "POST": "lower_admin" + }, + "/api/340589/admin/legion/tasks/": { + "GET": "admin_read_new" + }, + "/api/3453c1/admin/version/": { + "GET": "admin_read_new" + }, + "/api/34c169/admin/update_asks/": { + "POST": "admin_write_new" + }, + "/api/375eaa/admin/rate_limit_config/": { + "GET": "admin_read_new", + "PUT": "admin_write_new" + }, + "/api/3c7a91/admin/batch_commands/": {}, + "/api/3c7a91/admin/batch_commands/create/": { + "POST": "admin_write_new" + }, + "/api/3c7a91/admin/batch_commands/{id}/approve/": { + "POST": "admin_write_new" + }, + "/api/3c7a91/admin/batch_commands/{id}/reject/": { + "POST": "admin_write_new" + }, + "/api/3e3926/admin/bulk_insert_notifs/": { + "POST": "admin_write_new" + }, + "/api/3e8856/admin/apikeys/": { + "POST": "admin_write_new" + }, + "/api/3f43de/admin/ssh": { + "POST": "admin_write_new" + }, + "/api/41sq4s/admin/machines/rotate_key/": { + "POST": "admin_write_new" + }, + "/api/466418/admin/users/": { + "GET": "lower_admin" + }, + "/api/46e1ac/admin/refund_instance_dep/{instance_id}/": { + "POST": "admin_write_new" + }, + "/api/4a13d9/admin/set-user/": { + "PUT": "lower_admin" + }, + "/api/506fae/admin/debug_select_best_tag/": { + "GET": "admin_read_new" + }, + "/api/51fae5/admin/legion/task_status/": { + "GET": "admin_read_new" + }, + "/api/5d6874/admin/machines/terminate_cpu/": { + "POST": "admin_write_new" + }, + "/api/610455/admin/update_machines_reliability2/": { + "POST": "admin_write_new" + }, + "/api/66a3d7/admin/set_machines_datacenter_status/": { + "POST": "admin_write_new" + }, + "/api/68e496/admin/legion/create_task/": { + "POST": "admin_write_new" + }, + "/api/6cfa32/admin/webhooks/{id}/": { + "DELETE": "admin_write_new", + "PUT": "admin_write_new" + }, + "/api/6d8ee2/admin/coderbot/example_runs/{id}": { + "PUT": "admin_write_new" + }, + "/api/732fad/admin/machines/{machine_id}/volumes/": { + "GET": "lower_admin" + }, + "/api/7ad2c8/admin/disconnect-discord/": { + "DELETE": "admin_write_new" + }, + "/api/7c4b92/admin/block_machine_ips/": { + "POST": "admin_write_new" + }, + "/api/7d9a42/admin/legion/report/": { + "GET": "admin_read_new" + }, + "/api/7e8246/admin/s3/file/": { + "DELETE": "legion_s3", + "GET": "legion_s3", + "POST": "legion_s3", + "PUT": "legion_s3" + }, + "/api/823f00/admin/machines/suspend_cpu/": { + "POST": "admin_write_new" + }, + "/api/84052f/admin/machines/resume_cpu/": { + "POST": "admin_write_new" + }, + "/api/852599/admin/coderbot/example_runs/": { + "GET": "admin_read_new" + }, + "/api/8b556b/admin/user/{user_id}/email/": { + "POST": "lower_admin" + }, + "/api/8f3d2a/admin/charge-host/": { + "POST": "admin_write_new" + }, + "/api/8f9b21/admin/list-containers/": { + "GET": "lower_admin" + }, + "/api/90ea02/admin/web_server_version/": { + "GET": "admin_read_new" + }, + "/api/955eba/admin/google_drive/file/{filename}/": { + "GET": "admin_read_new" + }, + "/api/99a3d2/admin/user/{user_id}/verify/": { + "POST": "lower_admin" + }, + "/api/9b8c51/admin/verify_machines/": { + "POST": "admin_write_new" + }, + "/api/9be2ed/admin/notify/": { + "POST": "admin_write_new" + }, + "/api/9be6fa/admin/user/{id}/": { + "DELETE": "lower_admin", + "PUT": "admin_write_new" + }, + "/api/9d3f26/admin/user/{user_id}/set_user_verification_status/": { + "POST": "admin_write_new" + }, + "/api/9e3ef8/admin/machines/": { + "GET": "lower_admin" + }, + "/api/a043c6/admin/set_machines_verification_status/": { + "POST": "admin_write_new" + }, + "/api/a3f9c1/admin/redis/get/": { + "GET": "admin_read_new" + }, + "/api/a4c9e1/admin/sla_close_contract/": { + "POST": "admin_write_new" + }, + "/api/a4c9e1/admin/sla_override/{contract_id}/": { + "POST": "admin_write_new" + }, + "/api/a4c9e1/admin/sla_resolve_day/": { + "POST": "admin_write_new" + }, + "/api/a7f2b1/admin/gpu_supply/": { + "GET": "lower_admin" + }, + "/api/a884fe/admin/apikeys/testing": {}, + "/api/a98e1b/admin/refund/": { + "POST": "lower_admin" + }, + "/api/admin/metrics/{source}/api/v1/{prom_route:.*}": { + "GET": "admin_read_new" + }, + "/api/b26c43/admin/endptjobs/": { + "GET": "admin_read_new" + }, + "/api/b3da84/admin/template_sort/": { + "DELETE": "admin_write_new", + "PUT": "admin_write_new" + }, + "/api/b7d835/admin/machine/transfer": { + "POST": "admin_write_new" + }, + "/api/b992d7/admin/machines/{machine_id}/": { + "PUT": "lower_admin" + }, + "/api/be5cdb/admin/team/transfer_ownership/": { + "PUT": "lower_admin" + }, + "/api/bx401/admin/machines/sync_keys/": { + "POST": "admin_write_new" + }, + "/api/c3d8e4/admin/gpu_supply/data/": { + "GET": "lower_admin" + }, + "/api/c782ac/admin/machine_register/": { + "POST": "admin_write_new" + }, + "/api/c833fb/admin/google_drive/filenames/": { + "GET": "admin_read_new" + }, + "/api/ccfa7d/admin/machine_diagnostics/": { + "POST": "lower_admin" + }, + "/api/controller/containers/": { + "PUT": "misc" + }, + "/api/controller/cpus/": { + "PUT": "misc" + }, + "/api/controller/disks/": { + "PUT": "misc" + }, + "/api/controller/gpus/": { + "PUT": "misc" + }, + "/api/controller/inetworks/": { + "PUT": "misc" + }, + "/api/controller/machines/": { + "GET": "misc", + "PUT": "misc" + }, + "/api/d4e9f1/admin/machines/migrate/": { + "POST": "admin_write_new" + }, + "/api/d7c310/admin/reconcile-disk-contract/": { + "POST": "admin_write_new" + }, + "/api/d8fa27/admin/webhooks/": { + "GET": "admin_read_new", + "POST": "admin_write_new" + }, + "/api/daemon/test_task/": { + "GET": "admin_read_new" + }, + "/api/dv5hzi/admin/serverlessgroups/internal/": { + "GET": "autoscaler_master" + }, + "/api/e084ee/admin/serverless/metrics/": { + "POST": "admin_write_new" + }, + "/api/e58a57/admin/s3/dir/": { + "GET": "legion_s3" + }, + "/api/e5f2a3/admin/machines/migration_contract_shift/": { + "POST": "admin_write_new" + }, + "/api/ec8ac4/admin/set-multiplier/": { + "PUT": "admin_write_new" + }, + "/api/ecca55/admin/autojobs/": { + "GET": "admin_read_new" + }, + "/api/f1d130/admin/user/{user_id}/switch/": { + "POST": "lower_admin" + }, + "/api/fa0e17/admin/invoices/{invoice_id:\\\\d+}/unpay-invoice/": { + "PUT": "admin_write_new" + }, + "/api/fa0e17/admin/invoices/{invoice_id}/paid-invoice/": { + "PUT": "admin_write_new" + }, + "/api/fb4f73/admin/legion/auth/": { + "GET": "admin_write_new" + }, + "/api/lw31av/serverless/apikeys/": { + "POST": "autoscaler_master" + }, + "/api/v0/21931/admin/machine_key_update/": { + "PUT": "lower_admin" + }, + "/api/v0/admin/headers/": { + "GET": "admin_read_new" + }, + "/api/v0/admin/template/delete/docker/creds/": { + "DELETE": "admin_write_new" + }, + "/api/v0/admin/template/private/": { + "POST": "admin_write_new" + }, + "/api/v0/admin/test/": { + "GET": "admin_read_new" + }, + "/api/v0/asks/": {}, + "/api/v0/asks/bulk/": { + "POST": "instance_write" + }, + "/api/v0/asks/{id}/": { + "PUT": "instance_write" + }, + "/api/v0/audit/records/": { + "GET": "admin_read_new" + }, + "/api/v0/audit_log_filters/": { + "GET": "misc" + }, + "/api/v0/audit_logs/": { + "GET": "misc" + }, + "/api/v0/auth/apikeys/": { + "GET": "user_read", + "POST": "user_write" + }, + "/api/v0/auth/apikeys/{id}/": { + "DELETE": "user_write", + "GET": "user_read" + }, + "/api/v0/auth/machine_key/": { + "GET": "machine_read" + }, + "/api/v0/auth/sessions/": { + "DELETE": "user_write", + "GET": "user_read" + }, + "/api/v0/autojobs/": { + "GET": "misc", + "POST": "misc" + }, + "/api/v0/autojobs/{id}/": { + "DELETE": "misc", + "PUT": "misc" + }, + "/api/v0/benchmarks/": { + "GET": "misc", + "POST": "misc", + "PUT": "misc" + }, + "/api/v0/bitbucket/api-token": { + "POST": "user_write" + }, + "/api/v0/bitbucket/auth": { + "GET": "user_read" + }, + "/api/v0/bitbucket/refresh": { + "PUT": "agents_write" + }, + "/api/v0/bundles/": { + "GET": "misc", + "POST": "misc" + }, + "/api/v0/bundles/{id}/": { + "PUT": "misc" + }, + "/api/v0/bundles_bid_price/{id}/": { + "PUT": "misc" + }, + "/api/v0/charges/": { + "GET": "billing_read" + }, + "/api/v0/cluster/": { + "DELETE": "user_write", + "POST": "user_write", + "PUT": "user_write" + }, + "/api/v0/cluster/remove_machine/": { + "DELETE": "user_write" + }, + "/api/v0/cluster/worker_token": { + "PUT": "user_write" + }, + "/api/v0/clusters/": { + "GET": "user_read" + }, + "/api/v0/commands/copy_direct/": { + "DELETE": "instance_write", + "PUT": "instance_write" + }, + "/api/v0/commands/get_copy_size/": { + "POST": "instance_write" + }, + "/api/v0/commands/rclone/": { + "DELETE": "instance_write", + "POST": "instance_write" + }, + "/api/v0/commands/rclone/request/{token}/": { + "GET": "instance_read" + }, + "/api/v0/commands/rclone/{token}/": { + "POST": "instance_write" + }, + "/api/v0/commands/reset_apikey/": { + "PUT": "user_write" + }, + "/api/v0/commands/rsync/": { + "DELETE": "instance_write", + "PUT": "instance_write" + }, + "/api/v0/commands/schedule_job/": { + "GET": "instance_read", + "POST": "instance_write" + }, + "/api/v0/commands/schedule_job/{id}/": { + "DELETE": "instance_write", + "PUT": "instance_write" + }, + "/api/v0/commands/transfer_credit/": { + "PUT": "billing_write" + }, + "/api/v0/controllers/random/": { + "GET": "misc" + }, + "/api/v0/daemon/files/checksum/": { + "GET": "machine_read" + }, + "/api/v0/daemon/identify/": { + "POST": "machine_write" + }, + "/api/v0/daemon/machine_snapshot/": { + "POST": "machine_read" + }, + "/api/v0/daemon/report_self_test_results/": { + "POST": "machine_write" + }, + "/api/v0/daemon/volume_cleanup/": { + "PUT": "machine_write" + }, + "/api/v0/deployment/{id}/": { + "DELETE": "instance_write", + "GET": "instance_read" + }, + "/api/v0/deployment/{id}/download_url/": { + "GET": "instance_read" + }, + "/api/v0/deployment/{id}/heartbeat/": { + "POST": "instance_write" + }, + "/api/v0/deployment/{id}/start/": { + "POST": "instance_write" + }, + "/api/v0/deployment/{id}/stop/": { + "POST": "instance_write" + }, + "/api/v0/deployment/{id}/versions/": { + "GET": "instance_read" + }, + "/api/v0/deployments/": { + "DELETE": "instance_write", + "GET": "instance_read", + "PUT": "instance_write" + }, + "/api/v0/deployments/gc/": { + "POST": "autoscaler_master" + }, + "/api/v0/disks/update": { + "PUT": "machine_write" + }, + "/api/v0/docker/tags/": { + "GET": "misc" + }, + "/api/v0/endptjobs/": { + "GET": "misc", + "POST": "misc" + }, + "/api/v0/endptjobs/{id}/": { + "DELETE": "misc", + "GET": "misc", + "PUT": "misc" + }, + "/api/v0/images/": { + "POST": "misc" + }, + "/api/v0/instances/": { + "DELETE": "instance_write", + "GET": "instance_read", + "PUT": "instance_write" + }, + "/api/v0/instances/accept-price-increase/": { + "PUT": "instance_write" + }, + "/api/v0/instances/accept-price-increase/{token}/": { + "GET": "user_read", + "POST": "user_read" + }, + "/api/v0/instances/balance/{id}/": { + "GET": "instance_read" + }, + "/api/v0/instances/bid_price/{id}/": { + "PUT": "instance_write" + }, + "/api/v0/instances/command/{id}/": { + "PUT": "instance_write" + }, + "/api/v0/instances/count/": { + "GET": "instance_read" + }, + "/api/v0/instances/filters/": { + "GET": "instance_read" + }, + "/api/v0/instances/pending-price-increases/": { + "GET": "instance_read" + }, + "/api/v0/instances/prepay/{id}/": { + "PUT": "instance_write" + }, + "/api/v0/instances/reboot/{id}/": { + "PUT": "instance_write" + }, + "/api/v0/instances/recycle/{id}/": { + "PUT": "instance_write" + }, + "/api/v0/instances/request_logs/{id}/": { + "PUT": "instance_read" + }, + "/api/v0/instances/take_snapshot/{id}/": { + "POST": "instance_write" + }, + "/api/v0/instances/update_template/{id}/": { + "PUT": "instance_write" + }, + "/api/v0/instances/{id}/": { + "DELETE": "instance_write", + "GET": "instance_read", + "PUT": "instance_write" + }, + "/api/v0/instances/{id}/accept-price-increase/": { + "PUT": "instance_write" + }, + "/api/v0/instances/{id}/ssh/": { + "GET": "instance_read", + "POST": "instance_write", + "PUT": "instance_write" + }, + "/api/v0/instances/{id}/ssh/{key}/": { + "DELETE": "instance_write" + }, + "/api/v0/internet_distance/": { + "POST": "user_read" + }, + "/api/v0/invoices/": { + "GET": "billing_read" + }, + "/api/v0/jira/api-token": { + "POST": "user_write" + }, + "/api/v0/jira/auth": { + "GET": "user_read" + }, + "/api/v0/launch_instance/": { + "PUT": "misc" + }, + "/api/v0/legion/agent/": { + "DELETE": "agents_write", + "GET": "agents_read", + "POST": "agents_write", + "PUT": "agents_write" + }, + "/api/v0/legion/agents/": { + "GET": "agents_read" + }, + "/api/v0/legion/repos/": { + "GET": "agents_read" + }, + "/api/v0/legion/task/": { + "DELETE": "agents_write", + "GET": "agents_read", + "POST": "agents_write", + "PUT": "admin_write_new" + }, + "/api/v0/legion/tasks/": { + "GET": "agents_read" + }, + "/api/v0/machine/metrics/": { + "POST": "machine_write" + }, + "/api/v0/machine/volume_info/": { + "GET": "machine_read" + }, + "/api/v0/machine_up/set_info/": { + "PUT": "machine_write" + }, + "/api/v0/machines/": { + "GET": "machine_read", + "POST": "machine_write", + "PUT": "machine_write" + }, + "/api/v0/machines/create_asks/": { + "PUT": "machine_write" + }, + "/api/v0/machines/create_bids/": { + "PUT": "machine_write" + }, + "/api/v0/machines/defrag_offers/": { + "PUT": "machine_write" + }, + "/api/v0/machines/errors": { + "GET": "machine_read" + }, + "/api/v0/machines/errors/count": { + "GET": "machine_read" + }, + "/api/v0/machines/get_instance_secrets/{instance_uuid}/": { + "GET": "machine_read" + }, + "/api/v0/machines/maintenances": { + "DELETE": "machine_write", + "GET": "instance_read", + "POST": "instance_read" + }, + "/api/v0/machines/maintenances/batch": { + "DELETE": "machine_write", + "POST": "machine_write" + }, + "/api/v0/machines/report_new_key/": { + "PUT": "machine_write" + }, + "/api/v0/machines/subdomain/": { + "GET": "machine_read" + }, + "/api/v0/machines/transfer_ownership": { + "POST": "machine_write" + }, + "/api/v0/machines/{machine_id}/": { + "GET": "machine_read" + }, + "/api/v0/machines/{machine_id}/asks": { + "DELETE": "machine_write" + }, + "/api/v0/machines/{machine_id}/cancel_maint": { + "PUT": "machine_write" + }, + "/api/v0/machines/{machine_id}/cleanup": { + "PUT": "machine_write" + }, + "/api/v0/machines/{machine_id}/defjob": { + "DELETE": "machine_write" + }, + "/api/v0/machines/{machine_id}/dnotify": { + "PUT": "machine_write" + }, + "/api/v0/machines/{machine_id}/force_delete": { + "POST": "machine_write" + }, + "/api/v0/machines/{machine_id}/minbid": { + "PUT": "machine_write" + }, + "/api/v0/machines/{machine_id}/report": { + "PUT": "instance_write" + }, + "/api/v0/machines/{machine_id}/reports": { + "GET": "machine_read" + }, + "/api/v0/mailchimp/audience/{audienceID}/": { + "GET": "misc", + "POST": "misc", + "PUT": "misc" + }, + "/api/v0/metrics/gpu/current/": { + "GET": "machine_read" + }, + "/api/v0/metrics/gpu/history/": { + "GET": "machine_read" + }, + "/api/v0/metrics/gpu/locations/": { + "GET": "machine_read" + }, + "/api/v0/network_disk/": { + "GET": "machine_read", + "POST": "machine_write" + }, + "/api/v0/network_disks/": { + "GET": "machine_read", + "PUT": "machine_write" + }, + "/api/v0/network_volumes/": { + "POST": "instance_write", + "PUT": "instance_write" + }, + "/api/v0/network_volumes/search": { + "POST": "misc" + }, + "/api/v0/network_volumes/unlist": { + "POST": "machine_write" + }, + "/api/v0/notify/": {}, + "/api/v0/notify/verify_fail/": { + "POST": "misc" + }, + "/api/v0/oauth/bitbucket/callback": { + "GET": "user_read" + }, + "/api/v0/oauth/bitbucket/login": { + "GET": "user_read" + }, + "/api/v0/oauth/connect-discord/": { + "GET": "user_read" + }, + "/api/v0/oauth/jira/callback": { + "GET": "user_read" + }, + "/api/v0/oauth/jira/login": { + "GET": "user_read" + }, + "/api/v0/oauth/services": { + "DELETE": "user_write", + "GET": "user_read" + }, + "/api/v0/oauth/start-discord-oauth/": { + "GET": "user_read" + }, + "/api/v0/overlay/": { + "DELETE": "user_write", + "GET": "user_read", + "POST": "user_write", + "PUT": "user_write" + }, + "/api/v0/platform_fees/gpu/": { + "DELETE": "admin_write_new", + "POST": "admin_write_new", + "PUT": "admin_write_new" + }, + "/api/v0/referral/{id}/": { + "PUT": "misc" + }, + "/api/v0/request_cert/": { + "GET": "machine_read", + "POST": "machine_write" + }, + "/api/v0/s3/readme/": { + "POST": "misc" + }, + "/api/v0/search/asks/": { + "GET": "misc", + "PUT": "misc" + }, + "/api/v0/search/clusters/": { + "PUT": "misc" + }, + "/api/v0/secrets/": { + "DELETE": "user_write", + "GET": "user_read", + "POST": "user_write", + "PUT": "user_write" + }, + "/api/v0/serverless/endpoints/spend": { + "GET": "user_read" + }, + "/api/v0/serverless/metrics/": { + "POST": "misc" + }, + "/api/v0/serverless/workergroups/spend": { + "GET": "user_read" + }, + "/api/v0/sign_cert/": { + "POST": "admin_write_new" + }, + "/api/v0/ssh/": { + "GET": "user_read", + "POST": "user_write" + }, + "/api/v0/ssh/daemon/": { + "GET": "instance_read" + }, + "/api/v0/ssh/{id}/": { + "DELETE": "user_write", + "PUT": "user_write" + }, + "/api/v0/stripe-connect-disconnect/": { + "GET": "misc" + }, + "/api/v0/stripe-connect-redirect/": { + "GET": "misc" + }, + "/api/v0/stripe-connect-return/{token}/": { + "GET": "misc" + }, + "/api/v0/stripe-connect-start/": { + "GET": "misc" + }, + "/api/v0/stripe/attach-payment-method": { + "POST": "billing_write" + }, + "/api/v0/stripe/confirm-payment-status": {}, + "/api/v0/stripe/payment-intent": { + "POST": "billing_write" + }, + "/api/v0/stripe/payment-methods": {}, + "/api/v0/subaccounts/": { + "GET": "user_read" + }, + "/api/v0/subaccounts/regenerate_keys/": { + "PUT": "user_write" + }, + "/api/v0/team/": { + "DELETE": "user_write", + "POST": "user_write", + "PUT": "team_write" + }, + "/api/v0/team/apikeys/": { + "GET": "team_write" + }, + "/api/v0/team/balances": { + "GET": "user_read" + }, + "/api/v0/team/invite/": { + "POST": "team_write" + }, + "/api/v0/team/invite/{token}/": { + "GET": "user_read" + }, + "/api/v0/team/members/": { + "GET": "team_read" + }, + "/api/v0/team/members/{id}/": { + "DELETE": "team_write" + }, + "/api/v0/team/members/{id}/roles/": { + "DELETE": "team_write", + "POST": "team_write", + "PUT": "team_write" + }, + "/api/v0/team/roles-full/": { + "GET": "team_read" + }, + "/api/v0/team/roles/": { + "GET": "team_read", + "POST": "team_write" + }, + "/api/v0/team/roles/{id}/": { + "DELETE": "team_write", + "GET": "team_read", + "PUT": "team_write" + }, + "/api/v0/team/sessions/": { + "GET": "team_write" + }, + "/api/v0/team/transfer/": { + "PUT": "team_write" + }, + "/api/v0/template/": { + "DELETE": "user_write", + "GET": "user_read", + "POST": "user_write", + "PUT": "user_write" + }, + "/api/v0/template/params": { + "GET": "user_read" + }, + "/api/v0/template/recent/": { + "GET": "user_read" + }, + "/api/v0/tfa/": { + "DELETE": "user_write", + "POST": "user_write" + }, + "/api/v0/tfa/authorize-new-method/": { + "POST": "user_write", + "PUT": "user_write" + }, + "/api/v0/tfa/confirm-new/": { + "POST": "user_write" + }, + "/api/v0/tfa/email/": { + "POST": "user_write" + }, + "/api/v0/tfa/regen-backup-codes/": { + "PUT": "user_write" + }, + "/api/v0/tfa/resend/": { + "POST": "user_write" + }, + "/api/v0/tfa/sms/": { + "POST": "user_write" + }, + "/api/v0/tfa/sms/resend/": { + "POST": "user_write" + }, + "/api/v0/tfa/status/": { + "GET": "user_read" + }, + "/api/v0/tfa/test-submit/": { + "POST": "user_write" + }, + "/api/v0/tfa/test/": { + "POST": "user_write" + }, + "/api/v0/tfa/totp-setup/": { + "POST": "user_write" + }, + "/api/v0/tfa/update/": { + "PUT": "user_write" + }, + "/api/v0/upload_logs/": { + "POST": "instance_write" + }, + "/api/v0/user/earnings": { + "GET": "billing_read" + }, + "/api/v0/user/earnings/data": { + "GET": "billing_read" + }, + "/api/v0/user/template_performance": { + "GET": "user_read" + }, + "/api/v0/users/": { + "POST": "user_write", + "PUT": "user_write" + }, + "/api/v0/users/cloud/": { + "DELETE": "user_write", + "GET": "user_read", + "POST": "user_write" + }, + "/api/v0/users/cloud/dropbox/start/": { + "GET": "user_write" + }, + "/api/v0/users/cloud_integrations/": { + "GET": "user_read" + }, + "/api/v0/users/current/": { + "DELETE": "user_read", + "GET": "user_read", + "PUT": "user_read" + }, + "/api/v0/users/current/change-email/": { + "POST": "user_write" + }, + "/api/v0/users/current/password-resets/": { + "POST": "user_write" + }, + "/api/v0/users/root/": { + "GET": "user_read" + }, + "/api/v0/users/save-context/": { + "PUT": "user_read" + }, + "/api/v0/users/{user_id}": { + "PUT": "user_write" + }, + "/api/v0/users/{user_id}/created-templates/": { + "GET": "user_read" + }, + "/api/v0/users/{user_id}/credit-pay/": { + "POST": "billing_write" + }, + "/api/v0/users/{user_id}/credit/": { + "POST": "billing_write" + }, + "/api/v0/users/{user_id}/invoices/": { + "GET": "billing_read" + }, + "/api/v0/users/{user_id}/ipaddrs/": { + "GET": "user_read" + }, + "/api/v0/users/{user_id}/machine-earnings/": { + "GET": "billing_read" + }, + "/api/v0/users/{user_id}/machine-reports/": { + "GET": "machine_read" + }, + "/api/v0/users/{user_id}/machine-reports/acknowledge": { + "POST": "machine_write" + }, + "/api/v0/users/{user_id}/notification-prefs/": { + "GET": "user_read", + "PUT": "user_write" + }, + "/api/v0/users/{user_id}/payment-bitpay/": { + "POST": "user_write" + }, + "/api/v0/users/{user_id}/payment-checkout/": { + "POST": "user_write" + }, + "/api/v0/users/{user_id}/payment-coinbase/": { + "POST": "user_write" + }, + "/api/v0/users/{user_id}/payment-crypto-com/": { + "POST": "user_write" + }, + "/api/v0/users/{user_id}/payment-methods/": { + "GET": "user_read" + }, + "/api/v0/users/{user_id}/payment-methods/{id}/": { + "DELETE": "user_write", + "PUT": "user_write" + }, + "/api/v0/users/{user_id}/promo/{code}/": { + "POST": "user_write" + }, + "/api/v0/users/{user_id}/referral-count/": { + "GET": "user_read" + }, + "/api/v0/users/{user_id}/settings/": { + "GET": "user_read" + }, + "/api/v0/users/{user_id}/template/": { + "POST": "instance_write" + }, + "/api/v0/users/{user_id}/templates/": { + "DELETE": "instance_write", + "GET": "instance_read", + "POST": "instance_write" + }, + "/api/v0/users/{user_id}/templates/autoscaler/": { + "GET": "instance_read" + }, + "/api/v0/users/{user_id}/templates/popular/": { + "GET": "instance_read" + }, + "/api/v0/users/{user_id}/templates/referral/": { + "GET": "instance_read" + }, + "/api/v0/users/{user_id}/templates/{hash_id}/": { + "GET": "instance_read" + }, + "/api/v0/users/{user_id}/verify-email/": { + "POST": "user_write" + }, + "/api/v0/volumes/": { + "DELETE": "instance_write", + "GET": "instance_read", + "POST": "instance_write", + "PUT": "instance_write" + }, + "/api/v0/volumes/copy/": { + "POST": "instance_write" + }, + "/api/v0/volumes/search/": { + "POST": "misc" + }, + "/api/v0/volumes/unlist": { + "POST": "machine_write" + }, + "/api/v0/webhooks/": { + "GET": "user_read", + "POST": "user_write" + }, + "/api/v0/webhooks/{id}/": { + "DELETE": "user_write", + "PUT": "user_write" + }, + "/api/v0/workergroups/": { + "GET": "misc", + "POST": "misc" + }, + "/api/v0/workergroups/{id}/": { + "DELETE": "misc", + "PUT": "misc" + }, + "/api/v0/workhorse/email/": { + "GET": "admin_read_new" + }, + "/api/v0/workhorse/expired_contracts/": { + "GET": "admin_read_new" + }, + "/api/v0/workhorse/expired_machines/": { + "GET": "admin_read_new" + }, + "/api/v1/audit_logs/": { + "GET": "misc" + }, + "/api/v1/counts/": { + "GET": "instance_read" + }, + "/api/v1/hubspot/contacts/": { + "POST": "user_write", + "PUT": "user_write" + }, + "/api/v1/hubspot/newsletters/": { + "DELETE": "user_write", + "GET": "user_read", + "POST": "user_write" + }, + "/api/v1/hubspot/subscriptions/marketing/": { + "DELETE": "user_write", + "GET": "user_read", + "POST": "user_write" + }, + "/api/v1/instances/": { + "GET": "instance_read" + }, + "/api/v1/instances/lite/": { + "GET": "instance_read" + }, + "/api/v1/invoices/": { + "GET": "billing_read" + }, + "/api/v1/template/": { + "GET": "user_read", + "PUT": "user_write" + }, + "/api/v1/templates/fetch-tags": { + "POST": "user_read" + }, + "/api/v1/templates/validate-auth": { + "POST": "user_read" + }, + "/bitpay-webhook/": { + "POST": "misc" + }, + "/careers/": { + "GET": "misc" + }, + "/coinbase-webhook/": { + "GET": "misc" + }, + "/crypto-com-webhook/": { + "GET": "misc" + }, + "/faq/": { + "GET": "misc" + }, + "/fulls_webdev/": { + "GET": "misc" + }, + "/gesys_gpupro/": { + "GET": "misc" + }, + "/get_proxy/": { + "GET": "misc" + }, + "/grab_commands/": { + "GET": "misc" + }, + "/health/": { + "GET": "misc" + }, + "/hosting/": { + "GET": "misc" + }, + "/landing/": { + "GET": "misc" + }, + "/my_bouncer/": { + "GET": "misc" + }, + "/privacy/": { + "GET": "misc" + }, + "/stripe-connect-redirect/": { + "GET": "misc" + }, + "/stripe-connect-return/": { + "GET": "misc" + }, + "/stripe-webhook/connect/": { + "GET": "misc" + }, + "/stripe-webhook/normal/": { + "GET": "misc" + }, + "/terms/": { + "GET": "misc" + }, + "/upload_logs/": { + "POST": "user_write" + } + } +} \ No newline at end of file diff --git a/scripts/generate_cli_sdk_docs.py b/scripts/generate_cli_sdk_docs.py new file mode 100644 index 00000000..1386c79c --- /dev/null +++ b/scripts/generate_cli_sdk_docs.py @@ -0,0 +1,1148 @@ +#!/usr/bin/env python3 +""" +Auto-generate CLI and SDK reference MDX pages for vast-ai/docs. + +Mirrors the API docs auto-generation pipeline in the vast (backend) repo: +the source of truth is the installed vastai package, and the generator +walks the argparse subparsers and the VastAI class to produce one MDX +file per command/method in the format expected by the Mintlify docs site. + +Output layout: + + / + cli/reference/.mdx + sdk/python/reference/.mdx + +Usage: + + # Generate to ./generated-docs/ (default) + python scripts/generate_cli_sdk_docs.py + + # Generate into a clone of vast-ai/docs for diffing/cutover + python scripts/generate_cli_sdk_docs.py --output-dir /path/to/docs + + # JSON manifest of what was generated (for CI) + python scripts/generate_cli_sdk_docs.py --manifest manifest.json +""" + +from __future__ import annotations + +import argparse +import ast +import inspect +import json +import re +import sys +import textwrap +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + + +# Global options added by vastai/cli/main.py to the top-level parser. We +# render these once in a static "Global Options" section per CLI page rather +# than repeating them inside every command's Options list. +GLOBAL_CLI_OPTIONS = { + "url", "retry", "explain", "raw", "full", "curl", "api_key", + "version", "no_color", "help", +} + +# Commands/methods we never publish to public docs, regardless of scope. +# These are CLI/SDK surface that exists in code but isn't part of the +# publicly-supported product. Public stance is that vast.ai does not offer +# network volumes — the network-volume / network-disk commands were written +# for a single customer and aren't verified to work for general users. +# +# Names are doc_names (kebab-case, no extension). The same set applies to +# both CLI and SDK because the kebab-case is identical across surfaces. +EXCLUDED_NAMES = { + "add-network-disk", + "create-network-volume", + "list-network-volume", + "search-network-volumes", + "show-network-disks", + "unlist-network-volume", +} + +# Hardcoded markdown block to render at the bottom of every CLI page so the +# table of universal flags stays consistent. Matches the format guthrie-vast +# established in docs PR #99. +GLOBAL_OPTIONS_MD = """## Global Options + +The following options are available for all commands: + +| Option | Description | +| --- | --- | +| `--url URL` | Server REST API URL | +| `--retry N` | Retry limit | +| `--raw` | Output machine-readable JSON | +| `--explain` | Verbose explanation of API calls | +| `--api-key KEY` | API key (defaults to `~/.config/vastai/vast_api_key`) | +""" + + +def _escape_mdx_prose(text: str) -> str: + """Escape ``<`` and ``>`` outside inline backticks so MDX doesn't parse + placeholder text like ```` or ```` as JSX. + + Mintlify renders ``<`` / ``>`` identically to ``<`` / ``>`` in prose, + so this is visually transparent. Text inside inline backticks is left + alone so it stays literal in monospace. + """ + if not text or ("<" not in text and ">" not in text): + return text + out: list[str] = [] + in_code = False + for chunk in re.split(r"(`[^`\n]*`)", text): + if chunk.startswith("`") and chunk.endswith("`") and len(chunk) >= 2: + out.append(chunk) + else: + out.append(chunk.replace("<", "<").replace(">", ">")) + return "".join(out) + + +# --------------------------------------------------------------------------- +# CLI introspection +# --------------------------------------------------------------------------- + +@dataclass +class CliArg: + name: str # original arg name, e.g. "--onstart-cmd" or "id" + dest: str # argparse dest, e.g. "onstart_cmd" + help: str + type_label: str # rendered type for MDX, e.g. "string", "boolean" + default: Any + required: bool + is_positional: bool + choices: Optional[list] = None + + +@dataclass +class CliCommand: + name: str # space-form, e.g. "create instance" + doc_name: str # filename stem, e.g. "create-instance" + summary: str # one-line help, used as title-level description + usage: str # cleaned usage line + description: str # prose body from epilog + examples: str # block from epilog after "Examples:" / "Example:" + arguments: list[CliArg] = field(default_factory=list) + options: list[CliArg] = field(default_factory=list) + func_name: str = "" # Python name of the handler — used for scope lookup + + +def load_cli_parser(): + """Import the CLI and return its top-level apwrap parser fully wired.""" + # register_all_commands() imports every enabled command module, and the + # import itself triggers @parser.command decorator registration. Reusing + # the CLI's canonical registration helper (rather than a local copy of the + # import list) keeps the generator in lockstep with the real command set, + # so newly added command modules are documented automatically with no + # drift. main() in vastai/cli/main.py performs the same imports at runtime. + from vastai.cli.commands import register_all_commands + from vastai.cli.main import parser + register_all_commands(parser) + return parser + + +def _action_type_label(action: argparse.Action) -> str: + """Map an argparse Action back to the type label the docs use.""" + if isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction)): + return "boolean" + t = action.type + if t is int: + return "integer" + if t is float: + return "number" + if t is str or t is None: + return "string" + name = getattr(t, "__name__", str(t)) + return name + + +def _normalize_dest(action: argparse.Action) -> str: + return action.dest + + +def extract_command(subparser: argparse.ArgumentParser, command_name: str, + func) -> CliCommand: + """Pull all the metadata we need from one subparser into a CliCommand.""" + summary = (getattr(func, "mysignature_help", None) or "").strip() + + usage_line = subparser.format_usage().strip() + # argparse prints "usage: vastai create instance ID [OPTIONS] ..." + usage_line = re.sub(r"^usage:\s*", "", usage_line, flags=re.IGNORECASE) + # argparse may wrap long usage across lines — collapse runs of whitespace + usage_line = re.sub(r"\s+", " ", usage_line).strip() + + description, examples = _split_epilog(subparser.epilog or "") + + arguments: list[CliArg] = [] + options: list[CliArg] = [] + + for a in subparser._actions: + if isinstance(a, (argparse._HelpAction, argparse._VersionAction)): + continue + if a.help == argparse.SUPPRESS: + continue + # Skip subparsers action itself (the "command" placeholder) + if isinstance(a, argparse._SubParsersAction): + continue + if a.dest in GLOBAL_CLI_OPTIONS: + continue + + is_positional = not bool(a.option_strings) + # For positionals, use dest as the displayed name; for flags, prefer + # the long form (longest option string). + if is_positional: + name = a.dest + else: + name = max(a.option_strings, key=len) + + carg = CliArg( + name=name, + dest=a.dest, + help=(a.help or "").strip(), + type_label=_action_type_label(a), + default=a.default, + required=bool(a.required) if not is_positional else True, + is_positional=is_positional, + choices=list(a.choices) if a.choices else None, + ) + if is_positional: + arguments.append(carg) + else: + options.append(carg) + + return CliCommand( + name=command_name, + doc_name=command_name.replace(" ", "-"), + summary=summary, + usage=usage_line, + description=description, + examples=examples, + arguments=arguments, + options=options, + func_name=getattr(func, "__name__", ""), + ) + + +def _split_epilog(epilog: str) -> tuple[str, str]: + """Split an argparse epilog into (description prose, examples block). + + The CLI's epilog convention (see vastai/cli/commands/instances.py) is: + + ____________ <- horizontal rule of underscores + + + Examples: + + # comment + vastai ... + + ____________ + + We strip the rule lines, then take everything before the first + "Examples:" / "Example:" header as the description and everything after + as the examples block. + """ + if not epilog: + return "", "" + + lines = epilog.splitlines() + # Drop leading/trailing pure-underscore separator lines and blanks + def is_rule(ln: str) -> bool: + s = ln.strip() + return bool(s) and set(s) == {"_"} + + while lines and (is_rule(lines[0]) or not lines[0].strip()): + lines.pop(0) + while lines and (is_rule(lines[-1]) or not lines[-1].strip()): + lines.pop() + + # Find Examples: header + desc_lines: list[str] = [] + example_lines: list[str] = [] + found = False + for ln in lines: + if not found and re.match(r"^\s*examples?:\s*$", ln, re.IGNORECASE): + found = True + continue + if found: + example_lines.append(ln) + else: + desc_lines.append(ln) + + description = "\n".join(desc_lines).strip() + examples = "\n".join(example_lines).strip() + return description, examples + + +# --------------------------------------------------------------------------- +# CLI MDX rendering +# --------------------------------------------------------------------------- + +def render_cli_mdx(cmd: CliCommand) -> str: + title = f"vastai {cmd.name}" + sidebar = cmd.name + + out: list[str] = [] + out.append("---") + out.append(f'title: "{title}"') + out.append(f'sidebarTitle: "{sidebar}"') + out.append("---") + out.append("") + if cmd.summary: + out.append(_escape_mdx_prose(cmd.summary)) + out.append("") + + out.append("## Usage") + out.append("") + out.append("```bash") + out.append(cmd.usage if cmd.usage else f"vastai {cmd.name}") + out.append("```") + out.append("") + + if cmd.arguments: + out.append("## Arguments") + out.append("") + for a in cmd.arguments: + out.append(_render_param_field(a, is_arg=True)) + out.append("") + + if cmd.options: + out.append("## Options") + out.append("") + for a in cmd.options: + out.append(_render_param_field(a, is_arg=False)) + out.append("") + + if cmd.description: + out.append("## Description") + out.append("") + out.append(_escape_mdx_prose(cmd.description)) + out.append("") + + if cmd.examples: + out.append("## Examples") + out.append("") + out.append("```bash") + out.append(cmd.examples) + out.append("```") + out.append("") + + out.append(GLOBAL_OPTIONS_MD) + + return "\n".join(out).rstrip() + "\n" + + +def _render_param_field(a: CliArg, is_arg: bool) -> str: + attrs = [f'path="{a.name}"', f'type="{a.type_label}"'] + if a.required and is_arg: + attrs.append("required") + if a.default not in (None, False, [], "", argparse.SUPPRESS): + attrs.append(f'default="{a.default}"') + if a.choices: + choices_str = ", ".join(str(c) for c in a.choices) + # Mintlify ParamField doesn't support choices natively; embed in help + # text instead via the body. + body_lines = [] + if a.help: + body_lines.append(a.help) + body_lines.append(f"Allowed values: {choices_str}") + body = "\n ".join(body_lines) + else: + body = a.help or "" + + body = _escape_mdx_prose(body) + open_tag = f"" + return f"{open_tag}\n {body}\n" + + +# --------------------------------------------------------------------------- +# SDK introspection + rendering +# --------------------------------------------------------------------------- + +@dataclass +class SdkParam: + name: str + type_label: str + default_repr: Optional[str] + required: bool + help: str = "" + + +@dataclass +class SdkMethod: + name: str + doc_name: str + summary: str + signature_str: str + params: list[SdkParam] + returns_label: str + has_kwargs: bool + cli_doc_name: Optional[str] = None # matched CLI command, if any + + +def load_sdk_class(): + try: + from vastai.sdk import VastAI + except ImportError: + from vastai_sdk import VastAI # type: ignore + return VastAI + + +def extract_sdk_methods(cli_commands: dict[str, CliCommand]) -> list[SdkMethod]: + VastAI = load_sdk_class() + methods: list[SdkMethod] = [] + + for name, func in inspect.getmembers(VastAI, predicate=inspect.isfunction): + if name.startswith("_"): + continue + + sig = inspect.signature(func) + params: list[SdkParam] = [] + has_kwargs = False + for p in sig.parameters.values(): + if p.name == "self": + continue + if p.kind == inspect.Parameter.VAR_KEYWORD: + has_kwargs = True + continue + if p.kind == inspect.Parameter.VAR_POSITIONAL: + continue + params.append(SdkParam( + name=p.name, + type_label=_annotation_label(p.annotation), + default_repr=( + None if p.default is inspect.Parameter.empty + else repr(p.default) + ), + required=(p.default is inspect.Parameter.empty), + )) + + # Cross-reference CLI command to enrich **kwargs methods + doc_name = name.replace("_", "-") + cli_doc_name = doc_name if doc_name in cli_commands else None + if has_kwargs and cli_doc_name: + cli_cmd = cli_commands[cli_doc_name] + existing = {p.name for p in params} + for opt in cli_cmd.options: + kw = opt.dest + if kw in existing or kw in GLOBAL_CLI_OPTIONS: + continue + # CLI-derived kwargs are always optional in the SDK (they + # flow through **kwargs). Wrap non-bool types in Optional + # and surface the CLI default — falling back to None. + base_t = _cli_type_to_sdk(opt.type_label) + if base_t == "bool": + type_label = "bool" + default_repr = repr(bool(opt.default)) if opt.default is not None else "False" + else: + type_label = f"Optional[{base_t}]" + default_repr = ( + repr(opt.default) + if opt.default not in (None, argparse.SUPPRESS) + else "None" + ) + params.append(SdkParam( + name=kw, + type_label=type_label, + default_repr=default_repr, + required=False, + help=opt.help, + )) + + # Pull docstring help into params we don't have help for + doc = inspect.getdoc(func) or "" + summary = doc.splitlines()[0].strip() if doc else "" + + # If the method had no `help` text (came from raw signature) pull + # what we can from the matching CLI option list. + if cli_doc_name: + cli_cmd = cli_commands[cli_doc_name] + cli_help_by_dest = {o.dest: o.help for o in cli_cmd.options} + cli_help_by_dest.update({a.dest: a.help for a in cli_cmd.arguments}) + for sp in params: + if not sp.help and sp.name in cli_help_by_dest: + sp.help = cli_help_by_dest[sp.name] + + ret = sig.return_annotation + ret_label = _annotation_label(ret) if ret is not inspect.Parameter.empty else "Any" + + methods.append(SdkMethod( + name=name, + doc_name=doc_name, + summary=summary, + signature_str=_render_signature(name, params, ret_label), + params=params, + returns_label=ret_label, + has_kwargs=has_kwargs, + cli_doc_name=cli_doc_name, + )) + + return methods + + +def _annotation_label(ann) -> str: + if ann is inspect.Parameter.empty or ann is None: + return "Any" + if isinstance(ann, str): + return ann + # typing types stringify well; fall back to __name__ + name = getattr(ann, "__name__", None) + if name: + return name + return str(ann).replace("typing.", "") + + +def _cli_type_to_sdk(t: str) -> str: + return { + "boolean": "bool", + "integer": "int", + "number": "float", + "string": "str", + }.get(t, t) + + +def _render_signature(name: str, params: list[SdkParam], + ret_label: str) -> str: + """Render a multi-line Python signature for the docs page. + + For methods with **kwargs we expand the merged param list (CLI flags + folded in) so the signature reflects the full effective surface. + """ + suffix = f" -> {ret_label}" if ret_label and ret_label != "Any" else "" + if not params: + return f"VastAI.{name}(){suffix}" + parts = [] + for p in params: + annot = p.type_label + if p.default_repr is None: + parts.append(f" {p.name}: {annot}") + else: + parts.append(f" {p.name}: {annot} = {p.default_repr}") + body = ",\n".join(parts) + return f"VastAI.{name}(\n{body}\n){suffix}" + + +def render_sdk_mdx(method: SdkMethod) -> str: + title = f"VastAI.{method.name}" + sidebar = method.name + + out: list[str] = [] + out.append("---") + out.append(f'title: "{title}"') + out.append(f'sidebarTitle: "{sidebar}"') + out.append("---") + out.append("") + if method.summary: + out.append(_escape_mdx_prose(method.summary)) + out.append("") + + out.append("## Signature") + out.append("") + out.append("```python") + out.append(method.signature_str) + out.append("```") + out.append("") + + if method.params: + out.append("## Parameters") + out.append("") + for p in method.params: + attrs = [f'path="{p.name}"', f'type="{p.type_label}"'] + if p.required: + attrs.append("required") + if p.default_repr is not None and p.default_repr not in ("None", "False"): + attrs.append(f'default="{p.default_repr.strip(chr(39))}"') + out.append(f"") + out.append(f" {_escape_mdx_prose(p.help or '')}") + out.append("") + out.append("") + + out.append("## Returns") + out.append("") + out.append(f"`{method.returns_label}`") + out.append("") + + out.append("## Example") + out.append("") + out.append("```python") + out.append("from vastai import VastAI") + out.append("") + out.append('client = VastAI(api_key="YOUR_API_KEY")') + args_demo = ", ".join( + f"{p.name}={_demo_value(p)}" + for p in method.params if p.required + ) + out.append(f"result = client.{method.name}({args_demo})") + out.append("print(result)") + out.append("```") + out.append("") + + return "\n".join(out).rstrip() + "\n" + + +def _demo_value(p: SdkParam) -> str: + t = p.type_label.lower() + if "int" in t: + return "12345" + if "float" in t or "number" in t: + return "1.0" + if "bool" in t: + return "True" + if "list" in t: + return "[]" + if "dict" in t: + return "{}" + return '"value"' + + +# --------------------------------------------------------------------------- +# Scope-based filtering +# --------------------------------------------------------------------------- +# +# A CLI command or SDK method is "internal" if every backend endpoint it hits +# is gated by an internal-only scope (admin_read_new, lower_admin, etc.). We +# determine the endpoints by AST-walking vastai/api/*.py, vastai/cli/commands/*.py, +# and vastai/sdk.py, then look each (METHOD, URL) up in the snapshot file +# scripts/data/api_scopes.json (derived from vast/web/scope.json + paths.py). +# +# Policy: +# - endpoint scope ∈ INTERNAL_SCOPES → endpoint is internal +# - endpoint scope ∉ INTERNAL_SCOPES (or "misc") → endpoint is public +# - all endpoints internal → command/method excluded +# - at least one endpoint public → command/method included +# - no endpoints found (couldn't statically resolve) → included (don't drop +# unclassified items; emits a manifest warning instead) + +SCOPE_DATA_PATH = Path(__file__).resolve().parent / "data" / "api_scopes.json" + +# HTTP verbs exposed on VastClient (mirrors vastai/api/client.py) +CLIENT_VERBS = {"get", "post", "put", "delete", "patch"} + + +def _is_client_receiver(node: ast.AST) -> bool: + """True if `node` denotes a VastClient instance — i.e. the parameter + named ``client`` (the convention in vastai/api/*.py) or ``self.client``. + Restricting matches this way prevents collisions with unrelated ``.get(...)`` + calls on dicts, requests.Response, the requests module, etc.""" + if isinstance(node, ast.Name) and node.id == "client": + return True + if (isinstance(node, ast.Attribute) + and node.attr == "client" + and isinstance(node.value, ast.Name) + and node.value.id == "self"): + return True + return False + + +def _stringify_url(node: ast.AST) -> Optional[str]: + """Convert a string literal or f-string AST node to a route pattern. + + f-string interpolations are turned into named placeholders so + f"/instances/{id}/" -> "/instances/{id}/", matching the route pattern + style in vast/web/paths.py. Returns None if the path can't be + statically resolved. + """ + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.JoinedStr): + out: list[str] = [] + for v in node.values: + if isinstance(v, ast.Constant) and isinstance(v.value, str): + out.append(v.value) + elif isinstance(v, ast.FormattedValue): + inner = v.value + if isinstance(inner, ast.Name): + out.append("{" + inner.id + "}") + elif isinstance(inner, ast.Attribute): + out.append("{" + inner.attr + "}") + else: + # Unresolvable expression — placeholder + out.append("{x}") + else: + return None + return "".join(out) + return None + + +def _normalize_subpath(subpath: Optional[str]) -> Optional[str]: + """Apply VastClient._build_url's /api/v0 prefix rule and strip + trailing slash so callsite and snapshot URLs can be compared + irrespective of the noslash _auto routes Pyramid generates.""" + if not subpath: + return None + if not re.match(r"^/api/v\d+/", subpath): + subpath = "/api/v0" + subpath + if subpath.endswith("/") and len(subpath) > 1: + subpath = subpath[:-1] + return subpath + + +def _build_import_map(tree: ast.AST) -> dict[str, str]: + """Map local names -> api module basenames for `from vastai.api import X` + and `from vastai.api import X as Y` style imports.""" + aliases: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.module and node.module.startswith("vastai.api"): + # `from vastai.api import instances` -> instances=instances + # `from vastai.api.instances import foo` -> foo=instances::foo + # (function-level alias, ignored — we resolve via call attr) + if node.module == "vastai.api": + for alias in node.names: + aliases[alias.asname or alias.name] = alias.name + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("vastai.api."): + base = alias.name.split(".")[-1] + aliases[alias.asname or base] = base + return aliases + + +def _stringify_url_candidates(node: ast.AST) -> list[str]: + """Like _stringify_url but for ``a if cond else b`` returns both + branches. Useful for resolving things like + ``endpoint = '/v0/charges/' if x else '/v1/invoices/'``.""" + single = _stringify_url(node) + if single is not None: + return [single] + if isinstance(node, ast.IfExp): + out: list[str] = [] + for branch in (node.body, node.orelse): + out.extend(_stringify_url_candidates(branch)) + return out + return [] + + +def _collect_client_endpoints(fn_node: ast.AST) -> list[tuple[str, str]]: + """Walk one function body and return the list of (METHOD, URL) tuples + for every ``client.(, ...)`` (or ``self.client.(...)``) + call inside it. Handles three forms of path expression: + 1. Literal string or f-string passed directly. + 2. A ``Name`` reference resolved against simple local bindings of the + form ``url = "..."`` or ``url = f"..."`` earlier in the function. + 3. The bound value may itself be an ``a if cond else b`` expression + where both branches are stringifiable — both URLs are recorded. + """ + # First pass: gather trivial local string bindings within this function. + local_strs: dict[str, list[str]] = {} + for sub in ast.walk(fn_node): + if not isinstance(sub, ast.Assign): + continue + if len(sub.targets) == 1 and isinstance(sub.targets[0], ast.Name): + vals = _stringify_url_candidates(sub.value) + if vals: + local_strs[sub.targets[0].id] = vals + + out: list[tuple[str, str]] = [] + for sub in ast.walk(fn_node): + if not isinstance(sub, ast.Call): + continue + func = sub.func + if not (isinstance(func, ast.Attribute) + and func.attr in CLIENT_VERBS + and sub.args + and _is_client_receiver(func.value)): + continue + arg = sub.args[0] + candidates = _stringify_url_candidates(arg) + if not candidates and isinstance(arg, ast.Name): + candidates = local_strs.get(arg.id, []) + for url in candidates: + norm = _normalize_subpath(url) + if norm: + out.append((func.attr.upper(), norm)) + return out + + +def walk_api_endpoints(api_dir: Path) -> dict[tuple[str, str], list[tuple[str, str]]]: + """For every function in vastai/api/*.py, list every (METHOD, URL) it + calls on a VastClient. Returns {(module_basename, func_name): [...]}.""" + endpoints: dict[tuple[str, str], list[tuple[str, str]]] = {} + + for f in sorted(api_dir.glob("*.py")): + if f.name.startswith("_") or f.name == "client.py": + continue + mod = f.stem + try: + tree = ast.parse(f.read_text()) + except SyntaxError: + continue + + for fn in ast.walk(tree): + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + hits = _collect_client_endpoints(fn) + if hits: + endpoints[(mod, fn.name)] = hits + return endpoints + + +def walk_caller_endpoints( + py_path: Path, + api_endpoints: dict[tuple[str, str], list[tuple[str, str]]], +) -> dict[str, list[tuple[str, str]]]: + """For each module-level function (or method on a class) in `py_path`, + list the endpoints it reaches. + + Resolution chains through: + 1. Direct alias.api_fn(...) calls (resolved via this file's import map). + 2. Same-file function references — e.g. ``create__instance`` simply + delegates to ``create_instance_impl``, which is where the api call + actually lives. + + Returns {fn_name: [(METHOD, URL), ...]}. + """ + tree = ast.parse(py_path.read_text()) + alias_map = _build_import_map(tree) + + # Collect local function names so we can chase intra-file calls + local_fns = set() + # Class-body assignments like ``invite_team_member = invite_member`` are + # alias re-exports — record the source name so we can copy its endpoints. + method_aliases: dict[str, str] = {} + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + local_fns.add(node.name) + elif isinstance(node, ast.ClassDef): + for sub in node.body: + if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)): + local_fns.add(sub.name) + elif (isinstance(sub, ast.Assign) + and len(sub.targets) == 1 + and isinstance(sub.targets[0], ast.Name) + and isinstance(sub.value, ast.Name)): + method_aliases[sub.targets[0].id] = sub.value.id + + direct: dict[str, list[tuple[str, str]]] = defaultdict(list) + local_refs: dict[str, set[str]] = defaultdict(set) + + def scan(fn_node: ast.AST, fn_name: str) -> None: + # Direct client.verb(...) calls in the function body — search__offers + # in offers.py is the canonical example. + direct[fn_name].extend(_collect_client_endpoints(fn_node)) + + for sub in ast.walk(fn_node): + if not isinstance(sub, ast.Call): + continue + func = sub.func + # alias.fn(...) where alias resolves to an api module + if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name): + mod = alias_map.get(func.value.id) + if mod is not None: + hits = api_endpoints.get((mod, func.attr)) + if hits: + direct[fn_name].extend(hits) + continue + # self.(...) — record for sdk.py-style methods + if func.value.id == "self" and func.attr in local_fns: + local_refs[fn_name].add(func.attr) + # local_helper(...) at the module level + elif isinstance(func, ast.Name) and func.id in local_fns: + local_refs[fn_name].add(func.id) + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + scan(node, node.name) + elif isinstance(node, ast.ClassDef): + for sub in node.body: + if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)): + scan(sub, sub.name) + + # Resolve same-file call chains to fixed point. Depth-bounded to avoid + # cycles (mutual recursion is fine — we just stop reseeding after we + # stop accumulating new endpoints). + resolved: dict[str, list[tuple[str, str]]] = {fn: list(direct.get(fn, [])) for fn in local_fns} + + # Seed aliases with their source method's direct endpoints; chain + # resolution below will continue propagating through any helpers the + # source calls. + for alias, source in method_aliases.items(): + if source in resolved: + resolved[alias] = list(resolved[source]) + local_refs.setdefault(alias, set()).add(source) + local_fns.add(alias) + + for _ in range(8): + changed = False + for fn, callees in local_refs.items(): + before = len(resolved.get(fn, [])) + existing = set(map(tuple, resolved.get(fn, []))) + for callee in callees: + for hit in resolved.get(callee, []): + if tuple(hit) not in existing: + resolved.setdefault(fn, []).append(hit) + existing.add(tuple(hit)) + if len(resolved.get(fn, [])) != before: + changed = True + if not changed: + break + + return {k: v for k, v in resolved.items() if v} + + +@dataclass +class ScopeIndex: + """Snapshot of api endpoints -> {METHOD: scope}. + + Match logic uses regex patterns so a call to ``/api/v0/users/me/invoices`` + matches the snapshot route ``/api/v0/users/{user_id}/invoices``. Exact + matches are tried first (fast path); the regex pass handles literal/ + placeholder mismatches. + """ + by_url: dict[str, dict[str, str]] + patterns: list[tuple[re.Pattern, dict[str, str]]] + internal_scopes: set[str] + + @classmethod + def load(cls, path: Path = SCOPE_DATA_PATH) -> "ScopeIndex": + raw = json.loads(path.read_text()) + by_url: dict[str, dict[str, str]] = {} + patterns: list[tuple[re.Pattern, dict[str, str]]] = [] + for url, methods in raw["endpoints"].items(): + canon = _canon_url(url) + by_url[canon] = methods + # Compile a regex by replacing each placeholder token with a + # one-segment wildcard. _canon_url already mapped {name} -> {}. + regex = re.compile("^" + re.escape(canon).replace(r"\{\}", "[^/]+") + "$") + patterns.append((regex, methods)) + return cls( + by_url=by_url, + patterns=patterns, + internal_scopes=set(raw["_meta"]["internal_scopes"]), + ) + + def _lookup(self, url: str) -> Optional[dict[str, str]]: + canon = _canon_url(url) + hit = self.by_url.get(canon) + if hit is not None: + return hit + for regex, methods in self.patterns: + if regex.match(canon): + return methods + return None + + def classify(self, endpoint_hits: list[tuple[str, str]]) -> str: + """Return 'public', 'internal', or 'unknown'.""" + if not endpoint_hits: + return "unknown" + any_public = False + any_matched = False + for method, url in endpoint_hits: + scopes = self._lookup(url) + if not scopes: + continue + scope = scopes.get(method) + if scope is None: + continue + any_matched = True + if scope not in self.internal_scopes: + any_public = True + if not any_matched: + return "unknown" + return "public" if any_public else "internal" + + +_PLACEHOLDER_RX = re.compile(r"\{[^}]*\}") + + +def _canon_url(url: str) -> str: + """Strip trailing slash and replace every ``{name}`` placeholder with + ``{}`` so call-sites using ``{id}`` line up with routes declared with + ``{machine_id}`` and similar.""" + s = _PLACEHOLDER_RX.sub("{}", url) + if s.endswith("/") and len(s) > 1: + s = s[:-1] + return s + + +def build_function_endpoint_map( + vastai_pkg: Path, +) -> tuple[dict[str, list[tuple[str, str]]], dict[str, list[tuple[str, str]]]]: + """Return (cli_command_endpoints, sdk_method_endpoints) keyed by + plain Python function/method name. Names collide across CLI command + files only when the same dest name is registered twice, which doesn't + happen in this codebase — but if it ever does, the last writer wins.""" + api_endpoints = walk_api_endpoints(vastai_pkg / "api") + + cli_endpoints: dict[str, list[tuple[str, str]]] = {} + cmd_dir = vastai_pkg / "cli" / "commands" + for f in sorted(cmd_dir.glob("*.py")): + if f.name.startswith("_"): + continue + for fn_name, hits in walk_caller_endpoints(f, api_endpoints).items(): + cli_endpoints[fn_name] = hits + + sdk_endpoints = walk_caller_endpoints(vastai_pkg / "sdk.py", api_endpoints) + return cli_endpoints, sdk_endpoints + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + +SKIP_COMMAND_NAMES = {"help"} + + +def collect_cli_commands(parser_obj) -> dict[str, CliCommand]: + """Walk the registered subparsers and return {doc_name: CliCommand}. + + Each subparser's `prog` is ` `, where + command_name is the string apwrap registered (e.g., "create instance" + or "copy"). We strip the parent prefix to recover the command name — + this works regardless of how the script is invoked (vastai entrypoint, + `python -m`, `python scripts/...`). + """ + commands: dict[str, CliCommand] = {} + parent_prog = parser_obj.parser.prog + + # Pull canonical names from the SubParsersAction.choices map for cases + # where stripping the parent prog is ambiguous. + name_to_subparser: dict[str, argparse.ArgumentParser] = {} + if parser_obj.subparsers_ is not None: + for canonical_name, sub in parser_obj.subparsers_.choices.items(): + name_to_subparser.setdefault(id(sub), canonical_name) + + canonical_by_id = {} + if parser_obj.subparsers_ is not None: + for canonical_name, sub in parser_obj.subparsers_.choices.items(): + canonical_by_id.setdefault(id(sub), canonical_name) + + seen_names: set[str] = set() + + for sp in parser_obj.subparser_objs: + cmd_name = canonical_by_id.get(id(sp)) + if cmd_name is None: + # Fallback: strip parent prog prefix. + if sp.prog.startswith(parent_prog + " "): + cmd_name = sp.prog[len(parent_prog) + 1:] + else: + # Last-ditch: take the trailing 1-2 tokens. + cmd_name = " ".join(sp.prog.split()[-2:]) + cmd_name = cmd_name.strip() + if not cmd_name: + continue + if cmd_name in SKIP_COMMAND_NAMES or cmd_name.split()[0] in SKIP_COMMAND_NAMES: + continue + # Aliases register as separate subparsers in apwrap; dedupe by name. + if cmd_name in seen_names: + continue + seen_names.add(cmd_name) + + func = sp.get_default("func") + cmd = extract_command(sp, cmd_name, func) + commands[cmd.doc_name] = cmd + return commands + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--output-dir", default="generated-docs", + help="Directory to write generated MDX (default: generated-docs)") + ap.add_argument("--cli-subdir", default="cli/reference", + help="Subdirectory under output-dir for CLI pages") + ap.add_argument("--sdk-subdir", default="sdk/python/reference", + help="Subdirectory under output-dir for SDK pages") + ap.add_argument("--manifest", default=None, + help="Optional path to write a JSON manifest of generated files") + ap.add_argument("--skip-cli", action="store_true", + help="Skip generating CLI pages") + ap.add_argument("--skip-sdk", action="store_true", + help="Skip generating SDK pages") + args = ap.parse_args() + + out_root = Path(args.output_dir) + + parser_obj = load_cli_parser() + cli_commands = collect_cli_commands(parser_obj) + print(f"Discovered {len(cli_commands)} CLI commands.") + + # Load the scope snapshot + endpoint map so we can drop internal-only + # commands/methods. Snapshot lives at scripts/data/api_scopes.json. + import vastai + scope_index = ScopeIndex.load() + vastai_pkg = Path(vastai.__file__).resolve().parent + cli_fn_endpoints, sdk_fn_endpoints = build_function_endpoint_map(vastai_pkg) + + written: list[dict] = [] + excluded_cli: list[dict] = [] + excluded_sdk: list[dict] = [] + excluded_policy_cli: list[str] = [] + excluded_policy_sdk: list[str] = [] + unknown_cli: list[str] = [] + unknown_sdk: list[str] = [] + + if not args.skip_cli: + cli_dir = out_root / args.cli_subdir + cli_dir.mkdir(parents=True, exist_ok=True) + for doc_name, cmd in sorted(cli_commands.items()): + if doc_name in EXCLUDED_NAMES: + excluded_policy_cli.append(doc_name) + continue + hits = cli_fn_endpoints.get(cmd.func_name, []) + verdict = scope_index.classify(hits) + if verdict == "internal": + excluded_cli.append({ + "name": doc_name, + "func": cmd.func_name, + "endpoints": hits, + }) + continue + if verdict == "unknown": + unknown_cli.append(doc_name) + mdx = render_cli_mdx(cmd) + (cli_dir / f"{doc_name}.mdx").write_text(mdx) + written.append({"kind": "cli", "name": doc_name}) + print(f"Wrote {sum(1 for w in written if w['kind'] == 'cli')} CLI pages to {cli_dir} " + f"({len(excluded_policy_cli)} excluded by policy, " + f"{len(excluded_cli)} excluded as internal, {len(unknown_cli)} unclassified)") + + if not args.skip_sdk: + sdk_dir = out_root / args.sdk_subdir + sdk_dir.mkdir(parents=True, exist_ok=True) + sdk_methods = extract_sdk_methods(cli_commands) + print(f"Discovered {len(sdk_methods)} SDK methods.") + for m in sorted(sdk_methods, key=lambda x: x.doc_name): + if m.doc_name in EXCLUDED_NAMES: + excluded_policy_sdk.append(m.doc_name) + continue + hits = sdk_fn_endpoints.get(m.name, []) + verdict = scope_index.classify(hits) + if verdict == "internal": + excluded_sdk.append({ + "name": m.doc_name, + "func": m.name, + "endpoints": hits, + }) + continue + if verdict == "unknown": + unknown_sdk.append(m.doc_name) + mdx = render_sdk_mdx(m) + (sdk_dir / f"{m.doc_name}.mdx").write_text(mdx) + written.append({"kind": "sdk", "name": m.doc_name}) + print(f"Wrote {sum(1 for w in written if w['kind'] == 'sdk')} SDK pages to {sdk_dir} " + f"({len(excluded_policy_sdk)} excluded by policy, " + f"{len(excluded_sdk)} excluded as internal, {len(unknown_sdk)} unclassified)") + + if args.manifest: + Path(args.manifest).write_text(json.dumps({ + "cli_count": sum(1 for w in written if w["kind"] == "cli"), + "sdk_count": sum(1 for w in written if w["kind"] == "sdk"), + "files": written, + "excluded_internal_cli": excluded_cli, + "excluded_internal_sdk": excluded_sdk, + "excluded_policy_cli": excluded_policy_cli, + "excluded_policy_sdk": excluded_policy_sdk, + "unclassified_cli": unknown_cli, + "unclassified_sdk": unknown_sdk, + }, indent=2)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/patch_docs_nav.py b/scripts/patch_docs_nav.py new file mode 100644 index 00000000..13cf71d4 --- /dev/null +++ b/scripts/patch_docs_nav.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +Patch vast-ai/docs `docs.json` navigation so it stays in lockstep with the +CLI/SDK MDX files that ``generate_cli_sdk_docs.py`` wrote. + +Mintlify only renders pages that are registered in the navigation tree; +without this step, generated pages exist on disk as orphans and the +Mintlify preview shows "No changes to preview". + +Algorithm: + + 1. Walk docs.json's navigation tree. A "managed group" is one whose + pages list is dominated (>=70%) by entries starting with + ``cli/reference/`` or ``sdk/python/reference/``. + 2. Within each managed group, drop entries that are no longer in the + generator manifest (stale pages) — except for an explicit preserve + list (the SDK overview page, etc.). + 3. Any page in the manifest that is not already registered under SOME + managed group with the matching prefix gets placed into the + semantically-appropriate group via ``classify_new``. + 4. Sort each managed group's string entries alphabetically so the diff + is deterministic; preserve any non-string entries (subgroups, + anchors) in their original order. + +Usage: + + python scripts/patch_docs_nav.py \\ + --docs-json /path/to/docs-repo/docs.json \\ + --manifest manifest.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Iterable + + +CLI_PREFIX = "cli/reference/" +SDK_PREFIX = "sdk/python/reference/" + +# Pages we never auto-manage. These are hand-authored and should stay in +# the navigation even though the generator doesn't produce them. +PRESERVE = { + "sdk/python/reference/vastai", # SDK overview page +} + +# A nav group is "managed" if at least this fraction of its string-page +# entries fall under one of our auto-managed prefixes. 70% leaves room +# for the occasional hand-curated cross-link without classifying that +# group as managed. +MANAGED_GROUP_THRESHOLD = 0.7 + +# Fallback nav group for pages that ``classify_new`` can't categorize. +# Such pages still land in the nav (visible, never silently orphaned); +# the patcher emits a WARNING so editorial can add a precise rule later. +# "Instances" is the most general group and exists for both prefixes. +DEFAULT_GROUP = "Instances" + + +def classify_new(name: str) -> str | None: + """Return the group name a new page should be placed into. + + ``name`` is the bare filename stem (no prefix, no extension), e.g. + ``tfa-activate`` or ``add-network-disk``. Returns ``None`` when no + rule matches — the caller then places the page in ``DEFAULT_GROUP`` + (so it is always visible in the nav) and warns so editorial can add a + precise rule here later. + + Rules are ordered most-specific-first. When adding new commands that + don't fit, prefer extending this function with a precise rule over + leaning on the default bucket — the default keeps pages visible but + may land them in a less-than-ideal category. + """ + if name.startswith("tfa-"): + return "Accounts" + if name.endswith("-api-key"): + return "Accounts" + if "scheduled-job" in name: + return "Accounts" + if "invoice" in name or name == "fetch-contracts": + return "Billing" + if "team" in name: + return "Teams" + if "network-disk" in name or "network-volume" in name or "network-volumes" in name: + return "Volumes" + if "cluster" in name or "overlay" in name or name == "remove-machine-from-cluster": + return "Host" + if name.startswith("metrics") or "gpu-trends" in name or "gpu-locations" in name: + # `metrics gpu`/`gpu-trends`/`gpu-locations` are [Host] GPU-market + # analytics — they live alongside the other host-facing commands. + return "Host" + if name == "dump-logs" or "self-test" in name: + # Machine diagnostic commands (machines.py) — host-facing. + return "Host" + if name.startswith("search-"): + return "Search & templates" + if "deployment" in name: + # show/start/stop/delete-deployment + show-deployment-versions all + # live with the serverless surface in docs.json. + return "Serverless" + if "endpt" in name or "endpoint" in name or "workergroup" in name or "wrkgrp" in name: + return "Serverless" + if ( + "instance" in name + or name == "take-snapshot" + or name == "show-instance-filters" + # accept/reject-price-increase + show-pending-price-increases + or "price-increase" in name + ): + return "Instances" + return None + + +def iter_managed_groups(node) -> Iterable[tuple[dict, str]]: + """Yield (group_dict, prefix) for every navigation group whose pages + are predominantly under one of our managed prefixes.""" + if isinstance(node, list): + for item in node: + if (isinstance(item, dict) + and "group" in item + and isinstance(item.get("pages"), list)): + strs = [p for p in item["pages"] if isinstance(p, str)] + if strs: + cli_share = sum(1 for s in strs if s.startswith(CLI_PREFIX)) / len(strs) + sdk_share = sum(1 for s in strs if s.startswith(SDK_PREFIX)) / len(strs) + if cli_share >= MANAGED_GROUP_THRESHOLD: + yield item, CLI_PREFIX + elif sdk_share >= MANAGED_GROUP_THRESHOLD: + yield item, SDK_PREFIX + if isinstance(item, dict): + for v in item.values(): + yield from iter_managed_groups(v) + elif isinstance(item, list): + yield from iter_managed_groups(item) + elif isinstance(node, dict): + for v in node.values(): + yield from iter_managed_groups(v) + + +def patch_docs_nav(docs: dict, manifest: dict) -> dict: + """Mutate ``docs`` in place to reflect ``manifest``. Returns a + summary dict with counts and any uncategorized pages.""" + gen_cli = {CLI_PREFIX + f["name"] for f in manifest["files"] if f["kind"] == "cli"} + gen_sdk = {SDK_PREFIX + f["name"] for f in manifest["files"] if f["kind"] == "sdk"} + manifest_all = gen_cli | gen_sdk + + managed = list(iter_managed_groups(docs["navigation"])) + if not managed: + raise RuntimeError( + "No managed navigation groups found in docs.json — did the " + "schema change? Expected at least one group whose pages are " + f">={int(MANAGED_GROUP_THRESHOLD * 100)}% cli/reference/* or sdk/python/reference/*." + ) + + # Where does each existing page live? Pages not in this map are + # candidates for placement via classify_new. + existing_loc: dict[str, dict] = {} + for grp, _prefix in managed: + for p in grp["pages"]: + if isinstance(p, str): + existing_loc[p] = grp + + # Step 1: drop stale entries from each managed group. + removed: list[str] = [] + for grp, prefix in managed: + kept = [] + for p in grp["pages"]: + if isinstance(p, str) and p.startswith(prefix): + if p in manifest_all or p in PRESERVE: + kept.append(p) + else: + removed.append(p) + else: + # Non-prefix strings and subgroups stay put + kept.append(p) + grp["pages"] = kept + + # Step 2: place new entries. + groups_by_key: dict[tuple[str, str], dict] = { + (g["group"], prefix): g for g, prefix in managed + } + def fallback_group(prefix: str) -> dict | None: + """Deterministic managed group for ``prefix`` when the intended + group is unavailable — first by group name.""" + candidates = sorted( + (g for (gname, p), g in groups_by_key.items() if p == prefix), + key=lambda g: g["group"], + ) + return candidates[0] if candidates else None + + added: list[str] = [] + uncategorized: list[str] = [] # placed in default/fallback group; want a precise rule + for full in sorted(manifest_all): + if full in existing_loc: + continue + name = full.rsplit("/", 1)[-1] + prefix = CLI_PREFIX if full.startswith(CLI_PREFIX) else SDK_PREFIX + group_name = classify_new(name) + if group_name is None: + # No precise rule — place in DEFAULT_GROUP so the page is never + # silently orphaned, and flag it so a rule can be added later. + uncategorized.append(full) + group_name = DEFAULT_GROUP + grp = groups_by_key.get((group_name, prefix)) or fallback_group(prefix) + if grp is None: + # No managed group exists for this prefix at all — can't place. + if full not in uncategorized: + uncategorized.append(full) + continue + grp["pages"].append(full) + added.append(full) + + # Step 3: sort string entries within each managed group for a stable + # diff. Non-string entries (subgroups, anchors) keep their order. + for grp, _prefix in managed: + strs = sorted([p for p in grp["pages"] if isinstance(p, str)]) + others = [p for p in grp["pages"] if not isinstance(p, str)] + grp["pages"] = strs + others + + # Sanity check: every manifest page must end up in nav, every + # remaining nav-string page must be in the manifest (or in PRESERVE). + final_cli: set[str] = set() + final_sdk: set[str] = set() + def collect(n): + if isinstance(n, list): + for i in n: + if isinstance(i, str): + if i.startswith(CLI_PREFIX): + final_cli.add(i) + elif i.startswith(SDK_PREFIX): + final_sdk.add(i) + else: + collect(i) + elif isinstance(n, dict): + for v in n.values(): + collect(v) + collect(docs["navigation"]) + + missing = (gen_cli - final_cli) | (gen_sdk - final_sdk) + unexpected = ((final_cli | final_sdk) - manifest_all) - PRESERVE + # Uncategorized pages are placed in the default/fallback group, so they + # normally land in nav. Only in the degenerate case where no managed + # group exists for a prefix can one stay unplaced — the WARNING still + # surfaces it, so don't double-report it as "missing". + missing -= set(uncategorized) + + return { + "managed_groups": len(managed), + "added": added, + "removed": removed, + "uncategorized": uncategorized, + "missing_from_nav": sorted(missing), + "unexpected_in_nav": sorted(unexpected), + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--docs-json", required=True, type=Path, + help="Path to docs.json in a clone of vast-ai/docs") + ap.add_argument("--manifest", required=True, type=Path, + help="Path to manifest.json from generate_cli_sdk_docs.py") + ap.add_argument("--check", action="store_true", + help="Don't write; exit non-zero if changes would be made") + args = ap.parse_args() + + docs = json.loads(args.docs_json.read_text()) + manifest = json.loads(args.manifest.read_text()) + + before = json.dumps(docs, sort_keys=True) + summary = patch_docs_nav(docs, manifest) + after = json.dumps(docs, sort_keys=True) + changed = before != after + + unique_removed = sorted(set(summary["removed"])) + print(f"managed groups: {summary['managed_groups']}") + print(f"pages added to nav: {len(summary['added'])}") + print(f"pages removed from nav:{len(unique_removed)} " + f"({len(summary['removed'])} occurrences across groups)") + if unique_removed: + for p in unique_removed: + print(f" - {p}") + if summary["uncategorized"]: + print(f"WARNING: {len(summary['uncategorized'])} new pages had no " + f"classify rule and were placed in the default group " + f"({DEFAULT_GROUP!r}):") + for p in summary["uncategorized"]: + print(f" ? {p}") + print(" → add a precise rule in classify_new() so they land in " + "the right group.") + if summary["missing_from_nav"]: + print(f"ERROR: {len(summary['missing_from_nav'])} manifest pages " + "missing from nav after patch:") + for p in summary["missing_from_nav"]: + print(f" ! {p}") + return 2 + if summary["unexpected_in_nav"]: + print(f"ERROR: {len(summary['unexpected_in_nav'])} pages in nav " + "are neither in manifest nor preserved:") + for p in summary["unexpected_in_nav"]: + print(f" ! {p}") + return 2 + + if args.check: + return 1 if changed else 0 + + if changed: + args.docs_json.write_text(json.dumps(docs, indent=2) + "\n") + print(f"wrote {args.docs_json}") + else: + print("no changes — docs.json already in sync") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify_cli_sdk_docs.py b/scripts/verify_cli_sdk_docs.py new file mode 100644 index 00000000..17d562e9 --- /dev/null +++ b/scripts/verify_cli_sdk_docs.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +""" +Verify CLI/SDK documentation against the actual vast-cli package. + +Compares: + 1. CLI commands (from `vastai --help` + subcommand help) vs docs/cli/reference/*.mdx + 2. SDK methods (from introspecting VastAI class) vs docs/sdk/python/reference/*.mdx + 3. Flags/parameters for each CLI command vs documented flags in MDX pages + +Usage: + # Basic inventory check (requires vastai CLI installed + docs repo cloned) + python scripts/verify_cli_sdk_docs.py --docs-path /path/to/docs + + # Full parameter-level validation + python scripts/verify_cli_sdk_docs.py --docs-path /path/to/docs --check-params + + # Output as JSON (for CI) + python scripts/verify_cli_sdk_docs.py --docs-path /path/to/docs --json + +Exit codes: + 0 = no drift detected + 1 = drift detected (missing/stale docs or parameter mismatches) +""" + +import argparse +import inspect +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class DriftReport: + cli_undocumented: list = field(default_factory=list) + cli_stale: list = field(default_factory=list) + sdk_undocumented: list = field(default_factory=list) + sdk_stale: list = field(default_factory=list) + cli_param_mismatches: dict = field(default_factory=dict) + sdk_param_mismatches: dict = field(default_factory=dict) + errors: list = field(default_factory=list) + + @property + def has_drift(self): + return any([ + self.cli_undocumented, + self.cli_stale, + self.sdk_undocumented, + self.sdk_stale, + self.cli_param_mismatches, + self.sdk_param_mismatches, + ]) + + def to_dict(self): + return { + "cli": { + "undocumented": self.cli_undocumented, + "stale_docs": self.cli_stale, + "param_mismatches": self.cli_param_mismatches, + }, + "sdk": { + "undocumented": self.sdk_undocumented, + "stale_docs": self.sdk_stale, + "param_mismatches": self.sdk_param_mismatches, + }, + "errors": self.errors, + "has_drift": self.has_drift, + } + + def print_summary(self): + print("\n" + "=" * 60) + print("CLI/SDK Documentation Drift Report") + print("=" * 60) + + if not self.has_drift and not self.errors: + print("\nNo drift detected. Docs are in sync.") + return + + if self.cli_undocumented: + print(f"\nCLI commands missing docs ({len(self.cli_undocumented)}):") + for cmd in sorted(self.cli_undocumented): + print(f" - {cmd}") + + if self.cli_stale: + print(f"\nCLI docs for removed commands ({len(self.cli_stale)}):") + for cmd in sorted(self.cli_stale): + print(f" - {cmd}") + + if self.sdk_undocumented: + print(f"\nSDK methods missing docs ({len(self.sdk_undocumented)}):") + for method in sorted(self.sdk_undocumented): + print(f" - {method}") + + if self.sdk_stale: + print(f"\nSDK docs for removed methods ({len(self.sdk_stale)}):") + for method in sorted(self.sdk_stale): + print(f" - {method}") + + if self.cli_param_mismatches: + print(f"\nCLI parameter mismatches ({len(self.cli_param_mismatches)}):") + for cmd, diff in sorted(self.cli_param_mismatches.items()): + print(f" {cmd}:") + if diff.get("missing_from_docs"): + print(f" undocumented flags: {diff['missing_from_docs']}") + if diff.get("stale_in_docs"): + print(f" stale in docs: {diff['stale_in_docs']}") + + if self.sdk_param_mismatches: + print(f"\nSDK parameter mismatches ({len(self.sdk_param_mismatches)}):") + for method, diff in sorted(self.sdk_param_mismatches.items()): + print(f" {method}:") + if diff.get("missing_from_docs"): + print(f" undocumented params: {diff['missing_from_docs']}") + if diff.get("stale_in_docs"): + print(f" stale in docs: {diff['stale_in_docs']}") + + if self.errors: + print(f"\nErrors ({len(self.errors)}):") + for err in self.errors: + print(f" - {err}") + + print() + + +# --------------------------------------------------------------------------- +# CLI introspection +# --------------------------------------------------------------------------- + +def get_cli_commands() -> dict[str, list[str]]: + """ + Run `vastai --help` to get commands, then `vastai --help` for each + to extract flags. + + Handles both flat commands (e.g., `vastai copy`) and two-level commands + (e.g., `vastai show instances`). Two-level commands are flattened to + kebab-case (e.g., "show-instances") for matching against doc filenames. + + Returns: {command_name: [list of --flags]} + """ + commands = {} + + # Get help output + result = subprocess.run( + ["vastai", "--help"], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(f"vastai --help failed: {result.stderr}") + + subcommands = _parse_subcommands(result.stdout) + + for cmd_parts in subcommands: + # cmd_parts is a list like ["show", "instances"] or ["copy"] + doc_name = "-".join(cmd_parts) # flatten to kebab-case for doc matching + try: + sub_result = subprocess.run( + ["vastai"] + cmd_parts + ["--help"], + capture_output=True, text=True, timeout=30, + ) + flags = _parse_flags(sub_result.stdout + sub_result.stderr) + commands[doc_name] = flags + except (subprocess.TimeoutExpired, Exception): + commands[doc_name] = [] + + return commands + + +def _parse_subcommands(help_text: str) -> list[list[str]]: + """ + Extract command names from vastai --help output. + + Handles two-level commands like: + show instances Display user's current instances + create instance Create a new instance + copy Copy directories between instances + + Returns: list of command parts, e.g., [["show", "instances"], ["copy"]] + """ + commands = [] + in_commands_section = False + + for line in help_text.splitlines(): + stripped = line.strip() + + # Detect start of commands section + if re.match(r"^(positional arguments|command)", stripped, re.IGNORECASE): + in_commands_section = True + continue + + # Detect end of commands section + if in_commands_section: + if stripped == "" and commands: + # Empty line after we've found commands — might be end of section + continue + if re.match(r"^(optional arguments|options|$)", stripped, re.IGNORECASE) and commands: + if stripped.startswith(("options", "optional")): + in_commands_section = False + continue + + if not in_commands_section: + continue + + # Skip non-command lines + if stripped.startswith("-") or stripped.startswith("command"): + continue + + # Parse command line: " verb noun description text" + # Use 2+ spaces as separator between command and description + parts = re.split(r"\s{2,}", stripped, maxsplit=1) + if not parts or not parts[0]: + continue + + cmd_text = parts[0].strip() + if not cmd_text or cmd_text.startswith("-"): + continue + + # Split the command into parts (handles "show instances", "tfa activate", "copy") + cmd_parts = cmd_text.split() + if cmd_parts and cmd_parts[0] != "help": + commands.append(cmd_parts) + + # Deduplicate while preserving order + seen = set() + unique = [] + for parts in commands: + key = tuple(parts) + if key not in seen: + seen.add(key) + unique.append(parts) + + return unique + + +def _parse_flags(help_text: str) -> list[str]: + """Extract --flag names from argparse help output.""" + flags = set() + for match in re.finditer(r'(--[\w][\w-]*)', help_text): + flag = match.group(1) + if flag not in ("--help", "--version"): + flags.add(flag) + return sorted(flags) + + +# --------------------------------------------------------------------------- +# SDK introspection +# --------------------------------------------------------------------------- + +def get_sdk_methods() -> dict[str, list[str]]: + """ + Import vastai SDK and introspect the VastAI class for public methods. + + Returns: {method_name: [list of parameter names]} + """ + try: + from vastai.sdk import VastAI + except ImportError: + try: + from vastai_sdk import VastAI + except ImportError: + raise ImportError( + "Cannot import VastAI. Install with: pip install vastai" + ) + + methods = {} + for name, func in inspect.getmembers(VastAI, predicate=inspect.isfunction): + if name.startswith("_"): + continue + sig = inspect.signature(func) + params = [ + p.name for p in sig.parameters.values() + if p.name != "self" + ] + methods[name] = params + + return methods + + +# --------------------------------------------------------------------------- +# Docs introspection +# --------------------------------------------------------------------------- + +def get_documented_cli_commands(docs_path: Path) -> dict[str, list[str]]: + """ + Scan docs/cli/reference/*.mdx for documented CLI commands. + + Returns: {command_name: [list of documented flags]} + """ + ref_dir = docs_path / "cli" / "reference" + if not ref_dir.exists(): + return {} + + commands = {} + for mdx_file in ref_dir.glob("*.mdx"): + cmd_name = mdx_file.stem # e.g., "create-instance" + flags = _parse_mdx_params(mdx_file, param_type="flag") + commands[cmd_name] = flags + + return commands + + +def get_documented_sdk_methods(docs_path: Path) -> dict[str, list[str]]: + """ + Scan docs/sdk/python/reference/*.mdx for documented SDK methods. + + Returns: {method_name: [list of documented params]} + """ + ref_dir = docs_path / "sdk" / "python" / "reference" + if not ref_dir.exists(): + return {} + + methods = {} + for mdx_file in ref_dir.glob("*.mdx"): + method_name = mdx_file.stem # e.g., "create-instance" + params = _parse_mdx_params(mdx_file, param_type="param") + methods[method_name] = params + + return methods + + +def _parse_mdx_params(mdx_file: Path, param_type: str = "flag") -> list[str]: + """ + Extract parameter/flag names from an MDX documentation page. + + Handles common Mintlify patterns: + - + - + - | `--flag-name` | description | (markdown tables) + - **--flag-name** or `--flag-name` (inline) + """ + content = mdx_file.read_text(errors="replace") + params = set() + + # Mintlify components + for match in re.finditer( + r']*(?:name|path|query|body)\s*=\s*["\']([^"\']+)["\']', + content, + ): + params.add(match.group(1).lstrip("-").strip()) + + # Markdown table rows with flags: | `--flag` | or | --flag | + for match in re.finditer(r'\|\s*`?(--[\w-]+)`?\s*\|', content): + params.add(match.group(1).lstrip("-").strip()) + + # Fallback: look for --flag patterns in code blocks and descriptions + if not params: + for match in re.finditer(r'`(--[\w-]+)`', content): + params.add(match.group(1).lstrip("-").strip()) + + return sorted(params) + + +# --------------------------------------------------------------------------- +# Name normalization (SDK method_name <-> doc filename) +# --------------------------------------------------------------------------- + +def sdk_method_to_doc_name(method_name: str) -> str: + """Convert SDK method name (snake_case) to doc filename (kebab-case).""" + return method_name.replace("_", "-") + + +def doc_name_to_sdk_method(doc_name: str) -> str: + """Convert doc filename (kebab-case) to SDK method name (snake_case).""" + return doc_name.replace("-", "_") + + +def cli_command_to_doc_name(command: str) -> str: + """CLI commands already use kebab-case matching doc filenames.""" + return command + + +# --------------------------------------------------------------------------- +# Comparison +# --------------------------------------------------------------------------- + +def compare_inventory( + actual: dict[str, list[str]], + documented: dict[str, list[str]], + name_to_doc: callable, +) -> tuple[list[str], list[str]]: + """ + Compare actual commands/methods against documented ones. + + Returns: (undocumented, stale) + """ + actual_doc_names = {name_to_doc(name) for name in actual} + documented_names = set(documented.keys()) + + undocumented = sorted(actual_doc_names - documented_names) + stale = sorted(documented_names - actual_doc_names) + + return undocumented, stale + + +def compare_params( + actual: dict[str, list[str]], + documented: dict[str, list[str]], + name_to_doc: callable, +) -> dict: + """ + For each command/method that exists in both, compare parameters. + + Returns: {name: {"missing_from_docs": [...], "stale_in_docs": [...]}} + """ + mismatches = {} + + for actual_name, actual_params in actual.items(): + doc_name = name_to_doc(actual_name) + if doc_name not in documented: + continue + + doc_params = documented[doc_name] + if not actual_params and not doc_params: + continue + + # Normalize for comparison (strip --, convert to comparable form) + actual_set = {p.lstrip("-").replace("-", "_") for p in actual_params} + doc_set = {p.lstrip("-").replace("-", "_") for p in doc_params} + + missing = sorted(actual_set - doc_set) + stale = sorted(doc_set - actual_set) + + if missing or stale: + mismatches[doc_name] = {} + if missing: + mismatches[doc_name]["missing_from_docs"] = missing + if stale: + mismatches[doc_name]["stale_in_docs"] = stale + + return mismatches + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def run(docs_path: str, check_params: bool = False, output_json: bool = False): + report = DriftReport() + docs = Path(docs_path) + + if not docs.exists(): + print(f"Error: docs path does not exist: {docs_path}", file=sys.stderr) + sys.exit(2) + + # --- CLI --- + try: + cli_actual = get_cli_commands() + cli_documented = get_documented_cli_commands(docs) + + undoc, stale = compare_inventory(cli_actual, cli_documented, cli_command_to_doc_name) + report.cli_undocumented = undoc + report.cli_stale = stale + + if check_params and cli_actual and cli_documented: + report.cli_param_mismatches = compare_params( + cli_actual, cli_documented, cli_command_to_doc_name, + ) + except Exception as e: + report.errors.append(f"CLI check failed: {e}") + + # --- SDK --- + try: + sdk_actual = get_sdk_methods() + sdk_documented = get_documented_sdk_methods(docs) + + undoc, stale = compare_inventory(sdk_actual, sdk_documented, sdk_method_to_doc_name) + report.sdk_undocumented = undoc + report.sdk_stale = stale + + if check_params and sdk_actual and sdk_documented: + report.sdk_param_mismatches = compare_params( + sdk_actual, sdk_documented, sdk_method_to_doc_name, + ) + except Exception as e: + report.errors.append(f"SDK check failed: {e}") + + # --- Output --- + if output_json: + print(json.dumps(report.to_dict(), indent=2)) + else: + report.print_summary() + + return report + + +def main(): + parser = argparse.ArgumentParser( + description="Verify CLI/SDK docs match the actual vastai package.", + ) + parser.add_argument( + "--docs-path", required=True, + help="Path to the cloned vast-ai/docs repository", + ) + parser.add_argument( + "--check-params", action="store_true", + help="Also validate flags/parameters for each command (slower)", + ) + parser.add_argument( + "--json", action="store_true", dest="output_json", + help="Output report as JSON", + ) + args = parser.parse_args() + + report = run(args.docs_path, args.check_params, args.output_json) + sys.exit(1 if report.has_drift else 0) + + +if __name__ == "__main__": + main()