Skip to content
Merged
Show file tree
Hide file tree
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 Mar 14, 2026
853ce49
feat: add QB fixture handlers for e2e tests
deanq Mar 14, 2026
7b29446
feat: add LB fixture handler for e2e tests
deanq Mar 14, 2026
149d571
chore: register e2e pytest markers (qb, lb, cold_start)
deanq Mar 14, 2026
a750293
feat: add e2e conftest with flash_server lifecycle fixture
deanq Mar 14, 2026
339ac28
feat: add e2e tests for sync and async QB handlers
deanq Mar 14, 2026
fd40904
feat: add e2e tests for stateful worker persistence
deanq Mar 14, 2026
aa543e9
feat: add e2e tests for SDK Endpoint client round-trip
deanq Mar 14, 2026
cc184c5
feat: add e2e tests for async SDK Endpoint client
deanq Mar 14, 2026
a80511e
feat: add e2e cold start benchmark test
deanq Mar 14, 2026
cfa6eb1
feat: add e2e tests for LB remote dispatch
deanq Mar 14, 2026
c0fd809
feat: replace CI-e2e.yml with flash-based QB e2e tests
deanq Mar 14, 2026
83a798f
feat: add nightly CI workflow for full e2e suite including LB
deanq Mar 14, 2026
ea414af
fix: correct e2e test request format, error handling, and CI config
deanq Mar 14, 2026
3cc26bf
refactor: address code quality review findings
deanq Mar 14, 2026
0523f79
fix(ci): install editable runpod with deps before flash
deanq Mar 14, 2026
f5a6311
fix(e2e): initialize runpod.api_key from env var for SDK client tests
deanq Mar 14, 2026
076099e
fix(ci): exclude tests/e2e from default pytest collection
deanq Mar 14, 2026
87f6d89
fix(e2e): warm up QB endpoints before running tests
deanq Mar 14, 2026
40af124
fix(e2e): remove incompatible tests and reduce per-test timeout
deanq Mar 14, 2026
ea6b7b1
fix(e2e): increase http client timeout and fix error assertion
deanq Mar 14, 2026
76a2124
fix(ci): update unit test matrix to Python 3.10-3.12
deanq Mar 14, 2026
f0ac2c0
fix(e2e): remove stateful handler tests incompatible with remote disp…
deanq Mar 14, 2026
0057ffa
fix(tests): fix mock targets and cold start threshold in unit tests
deanq Mar 14, 2026
41fa8eb
fix(ci): add pytest-rerunfailures for flaky remote dispatch timeouts
deanq Mar 14, 2026
ef5445e
fix(e2e): remove flaky raw httpx handler tests
deanq Mar 14, 2026
fecd512
fix(e2e): consolidate SDK tests to single handler to reduce flakiness
deanq Mar 14, 2026
47bf151
fix(e2e): remove autouse from patch_runpod_globals to prevent cold en…
deanq Mar 14, 2026
1d05f3d
fix(ci): surface flash provisioning logs in e2e test output
deanq Mar 14, 2026
f4efb7b
fix(e2e): surface flash server stderr to CI output
deanq Mar 14, 2026
1b22d41
feat(e2e): inject PR branch runpod-python into provisioned endpoints
deanq Mar 14, 2026
c865c17
refactor(e2e): redesign e2e tests to provision mock-worker endpoints
deanq Mar 14, 2026
d05f761
fix(e2e): add structured logging to provisioner and test execution
deanq Mar 14, 2026
a89aef3
feat(e2e): add endpoint cleanup after test session
deanq Mar 14, 2026
30f27cb
chore(ci): remove nightly e2e workflow
deanq Mar 14, 2026
4dfa5b9
fix(e2e): address PR review feedback
deanq Mar 21, 2026
35c372a
perf(e2e): run jobs concurrently and consolidate endpoints
deanq Mar 21, 2026
d25fabc
fix(e2e): use flash undeploy CLI for reliable endpoint cleanup
deanq Mar 21, 2026
bf9734f
fix(tests): replace empty except pass with continue in cold start poll
deanq Mar 23, 2026
84684af
fix(tests): address review feedback from runpod-Henrik
deanq Mar 23, 2026
c64ae1e
fix: update requires-python to >=3.10 in pyproject.toml
deanq Mar 23, 2026
013640c
revert: restore Python >=3.8 support and original CI matrix
deanq Mar 23, 2026
7ac0829
Merge branch 'main' into deanq/e-3379-flash-based-e2e-tests
deanq Mar 23, 2026
c6ba109
ci: add manual workflow to cleanup stale serverless endpoints
deanq Mar 23, 2026
9e2cce9
fix(e2e): address copilot review feedback
deanq Mar 23, 2026
7660d2f
fix(e2e): scope undeploy to provisioned endpoints only
deanq Mar 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 23 additions & 78 deletions .github/workflows/CI-e2e.yml
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 }}
110 changes: 110 additions & 0 deletions .github/workflows/cleanup-endpoints.yml
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
6 changes: 5 additions & 1 deletion pytest.ini
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 added tests/e2e/__init__.py
Empty file.
78 changes: 78 additions & 0 deletions tests/e2e/conftest.py
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)
Loading
Loading