-
Notifications
You must be signed in to change notification settings - Fork 111
tests: replace legacy e2e with flash-based mock-worker provisioning #479
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
46 commits
Select commit
Hold shift + click to select a range
537489b
chore: scaffold e2e test directory and fixture project
deanq 853ce49
feat: add QB fixture handlers for e2e tests
deanq 7b29446
feat: add LB fixture handler for e2e tests
deanq 149d571
chore: register e2e pytest markers (qb, lb, cold_start)
deanq a750293
feat: add e2e conftest with flash_server lifecycle fixture
deanq 339ac28
feat: add e2e tests for sync and async QB handlers
deanq fd40904
feat: add e2e tests for stateful worker persistence
deanq aa543e9
feat: add e2e tests for SDK Endpoint client round-trip
deanq cc184c5
feat: add e2e tests for async SDK Endpoint client
deanq a80511e
feat: add e2e cold start benchmark test
deanq cfa6eb1
feat: add e2e tests for LB remote dispatch
deanq c0fd809
feat: replace CI-e2e.yml with flash-based QB e2e tests
deanq 83a798f
feat: add nightly CI workflow for full e2e suite including LB
deanq ea414af
fix: correct e2e test request format, error handling, and CI config
deanq 3cc26bf
refactor: address code quality review findings
deanq 0523f79
fix(ci): install editable runpod with deps before flash
deanq f5a6311
fix(e2e): initialize runpod.api_key from env var for SDK client tests
deanq 076099e
fix(ci): exclude tests/e2e from default pytest collection
deanq 87f6d89
fix(e2e): warm up QB endpoints before running tests
deanq 40af124
fix(e2e): remove incompatible tests and reduce per-test timeout
deanq ea6b7b1
fix(e2e): increase http client timeout and fix error assertion
deanq 76a2124
fix(ci): update unit test matrix to Python 3.10-3.12
deanq f0ac2c0
fix(e2e): remove stateful handler tests incompatible with remote disp…
deanq 0057ffa
fix(tests): fix mock targets and cold start threshold in unit tests
deanq 41fa8eb
fix(ci): add pytest-rerunfailures for flaky remote dispatch timeouts
deanq ef5445e
fix(e2e): remove flaky raw httpx handler tests
deanq fecd512
fix(e2e): consolidate SDK tests to single handler to reduce flakiness
deanq 47bf151
fix(e2e): remove autouse from patch_runpod_globals to prevent cold en…
deanq 1d05f3d
fix(ci): surface flash provisioning logs in e2e test output
deanq f4efb7b
fix(e2e): surface flash server stderr to CI output
deanq 1b22d41
feat(e2e): inject PR branch runpod-python into provisioned endpoints
deanq c865c17
refactor(e2e): redesign e2e tests to provision mock-worker endpoints
deanq d05f761
fix(e2e): add structured logging to provisioner and test execution
deanq a89aef3
feat(e2e): add endpoint cleanup after test session
deanq 30f27cb
chore(ci): remove nightly e2e workflow
deanq 4dfa5b9
fix(e2e): address PR review feedback
deanq 35c372a
perf(e2e): run jobs concurrently and consolidate endpoints
deanq d25fabc
fix(e2e): use flash undeploy CLI for reliable endpoint cleanup
deanq bf9734f
fix(tests): replace empty except pass with continue in cold start poll
deanq 84684af
fix(tests): address review feedback from runpod-Henrik
deanq c64ae1e
fix: update requires-python to >=3.10 in pyproject.toml
deanq 013640c
revert: restore Python >=3.8 support and original CI matrix
deanq 7ac0829
Merge branch 'main' into deanq/e-3379-flash-based-e2e-tests
deanq c6ba109
ci: add manual workflow to cleanup stale serverless endpoints
deanq 9e2cce9
fix(e2e): address copilot review feedback
deanq 7660d2f
fix(e2e): scope undeploy to provisioned endpoints only
deanq File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,93 +1,38 @@ | ||
| # Performs a full test of the package within production environment. | ||
|
|
||
| name: CI | End-to-End Runpod Python Tests | ||
|
|
||
| name: CI-e2e | ||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
|
|
||
| branches: [main] | ||
| pull_request: | ||
| branches: | ||
| - main | ||
|
|
||
| branches: [main] | ||
| workflow_dispatch: | ||
|
|
||
| jobs: | ||
| e2e-build: | ||
| name: Build and push mock-worker Docker image | ||
| e2e: | ||
| if: github.repository == 'runpod/runpod-python' | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| docker_tag: ${{ steps.output_docker_tag.outputs.docker_tag }} | ||
|
|
||
| timeout-minutes: 15 | ||
| steps: | ||
| - name: Checkout Repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 2 | ||
|
|
||
| - name: Clone and patch mock-worker | ||
| run: | | ||
| git clone https://github.com/runpod-workers/mock-worker | ||
| GIT_SHA=${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | ||
| echo "git+https://github.com/runpod/runpod-python.git@$GIT_SHA" > mock-worker/builder/requirements.txt | ||
|
|
||
| - name: Set up QEMU | ||
| uses: docker/setup-qemu-action@v3 | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Set up Docker Buildx | ||
| uses: docker/setup-buildx-action@v3 | ||
| - uses: astral-sh/setup-uv@v6 | ||
|
|
||
| - name: Login to Docker Hub | ||
| uses: docker/login-action@v3 | ||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| username: ${{ secrets.DOCKERHUB_USERNAME }} | ||
| password: ${{ secrets.DOCKERHUB_TOKEN }} | ||
| python-version: "3.12" | ||
|
|
||
| - name: Define Docker Tag | ||
| id: docker_tag | ||
| - name: Install dependencies | ||
| run: | | ||
| DOCKER_TAG=${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | ||
| echo "DOCKER_TAG=$(echo $DOCKER_TAG | cut -c 1-7)" >> $GITHUB_ENV | ||
|
|
||
| - name: Set Docker Tag as Output | ||
| id: output_docker_tag | ||
| run: echo "docker_tag=${{ env.DOCKER_TAG }}" >> $GITHUB_OUTPUT | ||
|
|
||
| - name: Build and push Docker image | ||
| uses: docker/build-push-action@v6 | ||
| with: | ||
| context: ./mock-worker | ||
| file: ./mock-worker/Dockerfile | ||
| push: true | ||
| tags: ${{ vars.DOCKERHUB_REPO }}/${{ vars.DOCKERHUB_IMG }}:${{ env.DOCKER_TAG }} | ||
| cache-from: type=gha | ||
| cache-to: type=gha,mode=max | ||
|
|
||
| test: | ||
| name: Run End-to-End Tests | ||
| runs-on: ubuntu-latest | ||
| needs: [e2e-build] | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - name: Run Tests | ||
| id: run-tests | ||
| uses: runpod/[email protected] | ||
| with: | ||
| image-tag: ${{ vars.DOCKERHUB_REPO }}/${{ vars.DOCKERHUB_IMG }}:${{ needs.e2e-build.outputs.docker_tag }} | ||
| runpod-api-key: ${{ secrets.RUNPOD_API_KEY }} | ||
| request-timeout: 1200 | ||
|
|
||
| - name: Verify Tests | ||
| env: | ||
| TOTAL_TESTS: ${{ steps.run-tests.outputs.total-tests }} | ||
| SUCCESSFUL_TESTS: ${{ steps.run-tests.outputs.succeeded }} | ||
| uv venv | ||
| source .venv/bin/activate | ||
| uv pip install -e ".[test]" --quiet || uv pip install -e . | ||
| uv pip install runpod-flash pytest pytest-asyncio pytest-timeout pytest-rerunfailures httpx | ||
| uv pip install -e . --reinstall --no-deps | ||
| python -c "import runpod; print(f'runpod: {runpod.__version__} from {runpod.__file__}')" | ||
|
|
||
| - name: Run e2e tests | ||
| run: | | ||
| echo "Total tests: $TOTAL_TESTS" | ||
| echo "Successful tests: $SUCCESSFUL_TESTS" | ||
| if [ "$TOTAL_TESTS" != "$SUCCESSFUL_TESTS" ]; then | ||
| exit 1 | ||
| fi | ||
| source .venv/bin/activate | ||
| pytest tests/e2e/ -v -p no:xdist --timeout=600 --reruns 1 --reruns-delay 5 --log-cli-level=INFO -o "addopts=" | ||
| env: | ||
| RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }} | ||
| RUNPOD_SDK_GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| name: Cleanup stale endpoints | ||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| dry_run: | ||
| description: "List endpoints without deleting (true/false)" | ||
| required: true | ||
| default: "true" | ||
| type: choice | ||
| options: | ||
| - "true" | ||
| - "false" | ||
| name_filter: | ||
| description: "Only delete endpoints whose name contains this string (empty = all)" | ||
| required: false | ||
| default: "" | ||
|
|
||
| jobs: | ||
| cleanup: | ||
| if: github.repository == 'runpod/runpod-python' | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
| steps: | ||
| - name: Cleanup endpoints | ||
| env: | ||
| RUNPOD_API_KEY: ${{ secrets.RUNPOD_API_KEY }} | ||
| DRY_RUN: ${{ inputs.dry_run }} | ||
| NAME_FILTER: ${{ inputs.name_filter }} | ||
| run: | | ||
| python3 - <<'SCRIPT' | ||
| import json | ||
| import os | ||
| import urllib.request | ||
|
|
||
| API_URL = "https://api.runpod.io/graphql" | ||
| API_KEY = os.environ["RUNPOD_API_KEY"] | ||
| DRY_RUN = os.environ.get("DRY_RUN", "true") == "true" | ||
| NAME_FILTER = os.environ.get("NAME_FILTER", "").strip() | ||
|
|
||
| def graphql(query, variables=None): | ||
| payload = json.dumps({"query": query, "variables": variables or {}}).encode() | ||
| req = urllib.request.Request( | ||
| f"{API_URL}?api_key={API_KEY}", | ||
| data=payload, | ||
| headers={"Content-Type": "application/json"}, | ||
| ) | ||
| with urllib.request.urlopen(req) as resp: | ||
| return json.loads(resp.read()) | ||
|
|
||
| # List all endpoints | ||
| result = graphql(""" | ||
| query { | ||
| myself { | ||
| endpoints { | ||
| id | ||
| name | ||
| workersMin | ||
| workersMax | ||
| createdAt | ||
| } | ||
| } | ||
| } | ||
| """) | ||
|
|
||
| endpoints = result.get("data", {}).get("myself", {}).get("endpoints", []) | ||
| if not endpoints: | ||
| print("No endpoints found.") | ||
| raise SystemExit(0) | ||
|
|
||
| # Filter if requested | ||
| if NAME_FILTER: | ||
| targets = [ep for ep in endpoints if NAME_FILTER in ep.get("name", "")] | ||
| print(f"Filter '{NAME_FILTER}' matched {len(targets)}/{len(endpoints)} endpoints") | ||
| else: | ||
| targets = endpoints | ||
| print(f"Found {len(targets)} total endpoints (no filter applied)") | ||
|
|
||
| print(f"\n{'DRY RUN — ' if DRY_RUN else ''}{'Listing' if DRY_RUN else 'Deleting'} {len(targets)} endpoint(s):\n") | ||
| for ep in sorted(targets, key=lambda e: e.get("createdAt", "")): | ||
| print(f" {ep['id']} {ep.get('name', '(unnamed)'):<40} " | ||
| f"workers={ep.get('workersMin', '?')}-{ep.get('workersMax', '?')} " | ||
| f"created={ep.get('createdAt', 'unknown')}") | ||
|
|
||
| if DRY_RUN: | ||
| print(f"\nDry run complete. Re-run with dry_run=false to delete.") | ||
| raise SystemExit(0) | ||
|
|
||
| # Delete each endpoint | ||
| deleted = 0 | ||
| failed = 0 | ||
| for ep in targets: | ||
| ep_id = ep["id"] | ||
| ep_name = ep.get("name", "(unnamed)") | ||
| try: | ||
| resp = graphql( | ||
| "mutation deleteEndpoint($id: String!) { deleteEndpoint(id: $id) }", | ||
| {"id": ep_id}, | ||
| ) | ||
| if "errors" in resp: | ||
| print(f" FAILED {ep_id} {ep_name}: {resp['errors']}") | ||
| failed += 1 | ||
| else: | ||
| print(f" DELETED {ep_id} {ep_name}") | ||
| deleted += 1 | ||
| except Exception as exc: | ||
| print(f" ERROR {ep_id} {ep_name}: {exc}") | ||
| failed += 1 | ||
|
|
||
| print(f"\nDone: {deleted} deleted, {failed} failed, {len(endpoints) - len(targets)} skipped (filtered)") | ||
| SCRIPT | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,9 @@ | ||
| [pytest] | ||
| addopts = --durations=10 --cov-config=.coveragerc --timeout=120 --timeout_method=thread --cov=runpod --cov-report=xml --cov-report=term-missing --cov-fail-under=90 -W error -p no:cacheprovider -p no:unraisableexception | ||
| python_files = tests.py test_*.py *_test.py | ||
| norecursedirs = venv *.egg-info .git build | ||
| norecursedirs = venv *.egg-info .git build tests/e2e | ||
| asyncio_mode = auto | ||
| markers = | ||
| qb: Queue-based tests (local execution, fast) | ||
| lb: Load-balanced tests (remote provisioning, slow) | ||
| cold_start: Cold start benchmark (starts own server) |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| """E2E test fixtures: provision real endpoints, configure SDK, clean up.""" | ||
|
|
||
| import logging | ||
| import os | ||
| import subprocess | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
| import runpod | ||
|
|
||
| from tests.e2e.e2e_provisioner import load_test_cases, provision_endpoints | ||
|
|
||
| log = logging.getLogger(__name__) | ||
| REQUEST_TIMEOUT = 300 # seconds per job request | ||
|
|
||
| # Repo root: tests/e2e/conftest.py -> ../../ | ||
| _REPO_ROOT = Path(__file__).resolve().parents[2] | ||
|
|
||
|
|
||
| @pytest.fixture(scope="session", autouse=True) | ||
| def verify_local_runpod(): | ||
| """Fail fast if the local runpod-python is not installed.""" | ||
| log.info("runpod version=%s path=%s", runpod.__version__, runpod.__file__) | ||
| runpod_path = Path(runpod.__file__).resolve() | ||
| if not runpod_path.is_relative_to(_REPO_ROOT): | ||
| pytest.fail( | ||
| f"Expected runpod installed from {_REPO_ROOT} but got {runpod_path}. " | ||
| "Run: pip install -e . --force-reinstall --no-deps" | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(scope="session") | ||
| def require_api_key(): | ||
| """Skip entire session if RUNPOD_API_KEY is not set.""" | ||
| key = os.environ.get("RUNPOD_API_KEY") | ||
| if not key: | ||
| pytest.skip("RUNPOD_API_KEY not set") | ||
| log.info("RUNPOD_API_KEY is set (length=%d)", len(key)) | ||
|
|
||
|
|
||
| @pytest.fixture(scope="session") | ||
| def test_cases(): | ||
| """Load test cases from tests.json.""" | ||
| cases = load_test_cases() | ||
| log.info("Loaded %d test cases: %s", len(cases), [c.get("id") for c in cases]) | ||
| return cases | ||
|
|
||
|
|
||
| @pytest.fixture(scope="session") | ||
| def endpoints(require_api_key, test_cases): | ||
| """Provision one endpoint per unique hardwareConfig. | ||
|
|
||
| Endpoints deploy lazily on first .run()/.runsync() call. | ||
| """ | ||
| eps = provision_endpoints(test_cases) | ||
| for key, ep in eps.items(): | ||
| log.info("Endpoint ready: name=%s image=%s template.dockerArgs=%s", ep.name, ep.image, ep.template.dockerArgs if ep.template else "N/A") | ||
| yield eps | ||
|
|
||
| # Undeploy only the endpoints provisioned by this test run. | ||
| # Uses by-name undeploy to avoid tearing down unrelated endpoints | ||
| # sharing the same API key (parallel CI runs, developer endpoints). | ||
| endpoint_names = [ep.name for ep in eps.values()] | ||
| log.info("Cleaning up %d provisioned endpoints: %s", len(endpoint_names), endpoint_names) | ||
| for name in endpoint_names: | ||
| try: | ||
| result = subprocess.run( | ||
| ["flash", "undeploy", name, "--force"], | ||
| capture_output=True, | ||
| text=True, | ||
| timeout=60, | ||
| ) | ||
| if result.returncode == 0: | ||
| log.info("Undeployed %s", name) | ||
| else: | ||
| log.warning("flash undeploy %s failed (rc=%d): %s", name, result.returncode, result.stderr) | ||
| except Exception: | ||
| log.exception("Failed to undeploy %s", name) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.