diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..0dcfa1ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# keep the runtime image build context to what pip install needs: +# package source, metadata, and the files setup.py/MANIFEST.in read +.git +.github +.venv +build +benchmark_results +docs +examples +job_files +scripts +tests +uv.lock +**/__pycache__ +**/*.pyc +*.egg-info diff --git a/.github/workflows/runtimes.yml b/.github/workflows/runtimes.yml new file mode 100644 index 00000000..64deccd5 --- /dev/null +++ b/.github/workflows/runtimes.yml @@ -0,0 +1,162 @@ +name: Runtimes + +# runtime images embed the runpod package, so they are published when a +# release ships (release-please merge -> github release), not per pr. +# the feature branch is a prerelease channel while the apps sdk is in +# development; runtime tests for prs run in ci via the full test suite. +on: + release: + types: [published] + push: + branches: [feat/apps-sdk] + paths: + - "runpod/runtimes/**" + - ".github/workflows/runtimes.yml" + workflow_dispatch: + +concurrency: + group: runtimes-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: docker.io + TAG_SUFFIX: latest + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + gpu-base: ${{ steps.filter.outputs.gpu-base || 'true' }} + steps: + - uses: actions/checkout@v7 + + # branch pushes rebuild the multi-gb torch base only when its + # definition changed; releases and manual runs always rebuild so + # published images never lag the release content + - uses: dorny/paths-filter@v3 + if: github.event_name == 'push' + id: filter + with: + filters: | + gpu-base: + - 'runpod/runtimes/gpu-base/**' + - '.github/workflows/runtimes.yml' + + test-runner: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install test deps + run: | + pip install -e . + pip install pytest pytest-asyncio cloudpickle + + - name: Run runtime tests + run: pytest tests/test_apps/test_tasks.py tests/test_apps/test_task_runner_http.py tests/test_apps/test_bootstrap.py -q -o addopts="" -p no:unraisableexception + + build-gpu-base: + runs-on: ubuntu-latest + needs: [changes, test-runner] + if: needs.changes.outputs.gpu-base == 'true' + strategy: + # a transient registry/mirror failure on one leg must not cancel + # the other python versions + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v7 + + - name: Clear space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/share/boost "$AGENT_TOOLSDIRECTORY" + docker system prune -af + + - uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push gpu base image + uses: docker/build-push-action@v6 + with: + context: . + file: runpod/runtimes/gpu-base/Dockerfile + platforms: linux/amd64 + build-args: | + PYTHON_VERSION=${{ matrix.python-version }} + push: true + tags: | + runpod/gpu-base:py${{ matrix.python-version }}-${{ env.TAG_SUFFIX }} + cache-from: type=gha,scope=gpu-base-py${{ matrix.python-version }} + cache-to: type=gha,mode=max,scope=gpu-base-py${{ matrix.python-version }} + + build-runtimes: + runs-on: ubuntu-latest + # gpu legs build FROM the registry's gpu-base tag, so a skipped or + # failed gpu-base rebuild (e.g. torch mirror outage) must not block + # runtime images: the previously published base keeps working + needs: [test-runner, build-gpu-base] + if: always() && needs.test-runner.result == 'success' + strategy: + fail-fast: false + matrix: + kind: [task, queue, api] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + accelerator: [cpu, gpu] + steps: + - uses: actions/checkout@v7 + + - name: Clear space + if: matrix.accelerator == 'gpu' + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/share/boost "$AGENT_TOOLSDIRECTORY" + docker system prune -af + + - uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Compute image names + id: names + run: | + if [ "${{ matrix.accelerator }}" = "gpu" ]; then + echo "base=runpod/gpu-base:py${{ matrix.python-version }}-${{ env.TAG_SUFFIX }}" >> "$GITHUB_OUTPUT" + repo="runpod/${{ matrix.kind }}-gpu" + else + echo "base=python:${{ matrix.python-version }}-slim" >> "$GITHUB_OUTPUT" + repo="runpod/${{ matrix.kind }}" + fi + tags="$repo:py${{ matrix.python-version }}-${{ env.TAG_SUFFIX }}" + # releases also pin a version tag for rollback + if [ "${{ github.event_name }}" = "release" ]; then + tags="$tags,$repo:py${{ matrix.python-version }}-${{ github.event.release.tag_name }}" + fi + echo "tags=$tags" >> "$GITHUB_OUTPUT" + + - name: Build and push runtime image + uses: docker/build-push-action@v6 + with: + context: . + file: runpod/runtimes/${{ matrix.kind }}/Dockerfile + platforms: linux/amd64 + build-args: | + BASE_IMAGE=${{ steps.names.outputs.base }} + push: true + tags: ${{ steps.names.outputs.tags }} + cache-from: type=gha,scope=${{ matrix.kind }}-${{ matrix.accelerator }}-py${{ matrix.python-version }} + cache-to: type=gha,mode=max,scope=${{ matrix.kind }}-${{ matrix.accelerator }}-py${{ matrix.python-version }} diff --git a/README.md b/README.md index b34014b1..47efd626 100644 --- a/README.md +++ b/README.md @@ -1,315 +1,89 @@
-

Runpod | Python Library

-[![PyPI Package](https://badge.fury.io/py/runpod.svg)](https://badge.fury.io/py/runpod) -  -[![Downloads](https://static.pepy.tech/personalized-badge/runpod?period=total&units=international_system&left_color=grey&right_color=blue&left_text=Downloads)](https://pepy.tech/project/runpod) +

Runpod Python

-[![CI | End-to-End Runpod Python Tests](https://github.com/runpod/runpod-python/actions/workflows/CI-e2e.yml/badge.svg)](https://github.com/runpod/runpod-python/actions/workflows/CI-e2e.yml) +**Define GPU functions in Python. Run them in the cloud with one decorator.** +[![PyPI Package](https://badge.fury.io/py/runpod.svg)](https://badge.fury.io/py/runpod) +[![Downloads](https://static.pepy.tech/personalized-badge/runpod?period=total&units=international_system&left_color=grey&right_color=blue&left_text=downloads)](https://pepy.tech/project/runpod) [![CI | Unit Tests](https://github.com/runpod/runpod-python/actions/workflows/CI-pytests.yml/badge.svg)](https://github.com/runpod/runpod-python/actions/workflows/CI-pytests.yml) -  -[![CI | CodeQL](https://github.com/runpod/runpod-python/actions/workflows/CI-codeql.yml/badge.svg)](https://github.com/runpod/runpod-python/actions/workflows/CI-codeql.yml) - -
- -Welcome to the official Python library for Runpod API & SDK. - -## Table of Contents - -- [Table of Contents](#table-of-contents) -- [💻 | Installation](#--installation) -- [⚡ | Serverless Worker (SDK)](#--serverless-worker-sdk) - - [Quick Start](#quick-start) - - [Local Test Worker](#local-test-worker) -- [📚 | API Language Library (GraphQL Wrapper)](#--api-language-library-graphql-wrapper) - - [Endpoints](#endpoints) - - [GPU Cloud (Pods)](#gpu-cloud-pods) -- [📁 | Directory](#--directory) -- [🤝 | Community and Contributing](#--community-and-contributing) - -## 💻 | Installation - -### Install from PyPI (Stable Release) - -```bash -# Install with pip -pip install runpod - -# Install with uv (faster alternative) -uv add runpod -``` - -### Install from GitHub (Latest Changes) - -To get the latest changes that haven't been released to PyPI yet: - -```bash -# Install latest development version from main branch with pip -pip install git+https://github.com/runpod/runpod-python.git - -# Install with uv -uv add git+https://github.com/runpod/runpod-python.git - -# Install a specific branch -pip install git+https://github.com/runpod/runpod-python.git@branch-name - -# Install a specific tag/release -pip install git+https://github.com/runpod/runpod-python.git@v1.0.0 - -# Install in editable mode for development -git clone https://github.com/runpod/runpod-python.git -cd runpod-python -pip install -e . -``` - -*Python 3.10 or higher is required to use the latest version of this package.* - -## ⚡ | Serverless Worker (SDK) - -This python package can also be used to create a serverless worker that can be deployed to Runpod as a custom endpoint API. +[![License](https://img.shields.io/badge/License-MIT-blue)](LICENSE) -### Quick Start +[Documentation](https://docs.runpod.io) • [Examples](examples/apps) • [Discord](https://discord.gg/pJ3P2DbUUq) -Create a python script in your project that contains your model definition and the Runpod worker start code. Run this python code as your default container start command: - -```python -# my_worker.py - -import runpod - -def is_even(job): - - job_input = job["input"] - the_number = job_input["number"] - - if not isinstance(the_number, int): - return {"error": "Silly human, you need to pass an integer."} - - if the_number % 2 == 0: - return True - - return False - -runpod.serverless.start({"handler": is_even}) -``` - -Make sure that this file is ran when your container starts. This can be accomplished by calling it in the docker command when you set up a template at [console.runpod.io/serverless/user/templates](https://console.runpod.io/serverless/user/templates) or by setting it as the default command in your Dockerfile. - -See our [blog post](https://www.runpod.io/blog/build-basic-serverless-api) for creating a basic Serverless API, or view the [details docs](https://docs.runpod.io/serverless-ai/custom-apis) for more information. - -### Local Test Worker + -You can also test your worker locally before deploying it to Runpod. This is useful for debugging and testing. +## Installation ```bash -python my_worker.py --rp_serve_api +pip install runpod # or: uv add runpod +rp login # authenticate once ``` -### Worker Fitness Checks +Requires Python 3.10+. Installing the package also installs the `rp` CLI. -Fitness checks allow you to validate your worker environment at startup before processing jobs. If any check fails, the worker exits immediately, allowing your orchestrator to restart it. +## Example ```python -# my_worker.py - import runpod -import torch - -# Register fitness checks using the decorator -@runpod.serverless.register_fitness_check -def check_gpu_available(): - """Verify GPU is available.""" - if not torch.cuda.is_available(): - raise RuntimeError("GPU not available") - -@runpod.serverless.register_fitness_check -def check_disk_space(): - """Verify sufficient disk space.""" - import shutil - stat = shutil.disk_usage("/") - free_gb = stat.free / (1024**3) - if free_gb < 10: - raise RuntimeError(f"Insufficient disk space: {free_gb:.2f}GB free") - -def handler(job): - job_input = job["input"] - # Your handler code here - return {"output": "success"} - -# Fitness checks run before handler initialization (production only) -runpod.serverless.start({"handler": handler}) -``` +from runpod import App, Model, Secret, Volume -**Key Features:** -- Supports both synchronous and asynchronous check functions -- Checks run only once at worker startup (production mode) -- Runs before handler initialization and job processing begins -- Any check failure exits with code 1 (worker marked unhealthy) +app = App("inference") -See [Worker Fitness Checks](https://github.com/runpod/runpod-python/blob/main/docs/serverless/worker_fitness_checks.md) documentation for more examples and best practices. +models = Volume("models", size=100) +llama = Model("meta-llama/Llama-3.1-8B-Instruct") -## 📚 | API Language Library (GraphQL Wrapper) - -When interacting with the Runpod API you can use this library to make requests to the API. - -```python -import runpod - -runpod.api_key = "your_runpod_api_key_found_under_settings" -``` -### Endpoints - -You can interact with Runpod endpoints via a `run` or `run_sync` method. - -#### Basic Usage - -```python -endpoint = runpod.Endpoint("ENDPOINT_ID") - -run_request = endpoint.run( - {"your_model_input_key": "your_model_input_value"} +# an autoscaling job queue on cloud H100s: weights pre-cached, +# dependencies vendored at deploy time, scale-to-zero when idle +@app.queue( + gpu="H100", + workers=(0, 3), + dependencies=["vllm"], + volume=models, + model=llama, + env={"HF_TOKEN": Secret("hf-token")}, ) +def chat(prompt: str): + import vllm -# Check the status of the endpoint run request -print(run_request.status()) + llm = vllm.LLM(model=str(llama.path)) # weights already on disk + return llm.generate(prompt) -# Get the output of the endpoint run request, blocking until the endpoint run is complete. -print(run_request.output()) -``` -```python -endpoint = runpod.Endpoint("ENDPOINT_ID") +# one ephemeral pod per call: provisions, runs to completion, terminates +@app.task(gpu="H100", gpu_count=2, volume=models) +def finetune(steps: int = 1000): + ... + return {"loss": final_loss} -run_request = endpoint.run_sync( - {"your_model_input_key": "your_model_input_value"} -) -# Returns the job results if completed within 90 seconds, otherwise, returns the job status. -print(run_request ) +@runpod.local_entrypoint +def main(): + print(chat.remote("why is the sky blue?")) # blocks for the result + job = finetune.spawn(steps=500) # fire and forget -> Job ``` -#### API Key Management - -The SDK supports multiple ways to set API keys: - -**1. Global API Key** (Default) -```python -import runpod - -# Set global API key -runpod.api_key = "your_runpod_api_key" - -# All endpoints will use this key by default -endpoint = runpod.Endpoint("ENDPOINT_ID") -result = endpoint.run_sync({"input": "data"}) -``` - -**2. Endpoint-Specific API Key** -```python -# Create endpoint with its own API key -endpoint = runpod.Endpoint("ENDPOINT_ID", api_key="specific_api_key") - -# This endpoint will always use the provided API key -result = endpoint.run_sync({"input": "data"}) -``` - -#### API Key Precedence - -The SDK uses this precedence order (highest to lowest): -1. Endpoint instance API key (if provided to `Endpoint()`) -2. Global API key (set via `runpod.api_key`) - -```python -import runpod - -# Example showing precedence -runpod.api_key = "GLOBAL_KEY" - -# This endpoint uses GLOBAL_KEY -endpoint1 = runpod.Endpoint("ENDPOINT_ID") - -# This endpoint uses ENDPOINT_KEY (overrides global) -endpoint2 = runpod.Endpoint("ENDPOINT_ID", api_key="ENDPOINT_KEY") - -# All requests from endpoint2 will use ENDPOINT_KEY -result = endpoint2.run_sync({"input": "data"}) -``` - -#### Thread-Safe Operations - -Each `Endpoint` instance maintains its own API key, making concurrent operations safe: - -```python -import threading -import runpod - -def process_request(api_key, endpoint_id, input_data): - # Each thread gets its own Endpoint instance - endpoint = runpod.Endpoint(endpoint_id, api_key=api_key) - return endpoint.run_sync(input_data) - -# Safe concurrent usage with different API keys -threads = [] -for customer in customers: - t = threading.Thread( - target=process_request, - args=(customer["api_key"], customer["endpoint_id"], customer["input"]) - ) - threads.append(t) - t.start() +```bash +rp dev main.py # live dev session: edit, re-run, logs stream back +rp deploy # deploy production endpoints ``` -### GPU Cloud (Pods) - -```python -import runpod - -runpod.api_key = "your_runpod_api_key_found_under_settings" - -# Get all my pods -pods = runpod.get_pods() - -# Get a specific pod -pod = runpod.get_pod(pod.id) - -# Create a pod with GPU -pod = runpod.create_pod("test", "runpod/stack", "NVIDIA GeForce RTX 3070") +Functions keep their Python identity: `chat.remote(...)` runs in the cloud, `await chat.remote.aio(...)` is the async form, `chat.local(...)` runs in-process. See [`examples/apps`](examples/apps) for runnable examples and [docs.runpod.io](https://docs.runpod.io) for the full guide. -# Create a pod with CPU -pod = runpod.create_pod("test", "runpod/stack", instance_id="cpu3c-2-4") +## Contributing -# Stop the pod -runpod.stop_pod(pod.id) - -# Resume the pod -runpod.resume_pod(pod.id) - -# Terminate the pod -runpod.terminate_pod(pod.id) -``` +Pull requests and issues are welcome — see the [contributing guide](CONTRIBUTING.md) to get started. -## 📁 | Directory - -```BASH -. -├── docs # Documentation -├── examples # Examples -├── runpod # Package source code -│ ├── api_wrapper # Language library - API (GraphQL) -│ ├── cli # Command Line Interface Functions -│ ├── endpoint # Language library - Endpoints -│ └── serverless # SDK - Serverless Worker -└── tests # Package tests +```bash +git clone https://github.com/runpod/runpod-python.git +cd runpod-python +make setup +make test ``` -## 🤝 | Community and Contributing - -We welcome both pull requests and issues on [GitHub](https://github.com/runpod/runpod-python). Bug fixes and new features are encouraged, but please read our [contributing guide](CONTRIBUTING.md) first. -
-![Discord Banner 2](https://discordapp.com/api/guilds/912829806415085598/widget.png?style=banner2) +![Discord](https://discordapp.com/api/guilds/912829806415085598/widget.png?style=banner2)
diff --git a/docs/cli/references/command_line_interface.md b/docs/cli/references/command_line_interface.md index d72567ac..e3b5232e 100644 --- a/docs/cli/references/command_line_interface.md +++ b/docs/cli/references/command_line_interface.md @@ -4,42 +4,30 @@ Note: This CLI is not the same as runpodctl and provides a different set of feat ```bash # Auth -runpod config +rp login -runpod ssh list-keys -runpod ssh add-key +# SSH +rp ssh add # add a key to your account +rp ssh list # list account keys +rp ssh POD_ID # open a terminal on a pod -runpod pod list -runpod pod create -runpod pod connect - -runpod exec python file.py +# Pods +rp pod list +rp pod create +rp pod connect POD_ID ``` ## Overview ```bash -runpod --help -``` - -### Configure - -```bash -$ runpod config -Profile [default]: -Runpod API Key [None]: YOUR_RUNPOD_API_KEY +rp --help ``` -### Launch Pod +### Authenticate ```bash -runpod launch --help -runpod launch pod --template-file template.yaml +rp login # browser approval +rp login --api-key YOUR_KEY # store a key directly ``` -### Launch Endpoint - -```bash -runpod launch endpoint --help -runpod launch endpoint --template-file template.yaml -``` +Credentials are stored in `~/.runpod/config.toml`. diff --git a/docs/cli/start_here.md b/docs/cli/start_here.md index 5ee16c91..99902938 100644 --- a/docs/cli/start_here.md +++ b/docs/cli/start_here.md @@ -1,23 +1,21 @@ -# [BETA] | Runpod Python CLI Reference +# Runpod CLI Reference Note: This CLI is not the same as runpodctl and provides a different set of features. ## Getting Started -![runpod --help](demos/help.gif) +![rp --help](demos/help.gif) -### Configure +### Authenticate -![runpod config](demos/config.gif) +Store your Runpod API key by running `rp login` (browser approval) or `rp login --api-key YOUR_KEY`. Credentials are stored in `~/.runpod/config.toml`. -Store your Runpod API key by running `runpod config`. Optionally you can also call the command with your API key `runpod config YOUR_API_KEY` or include the `--profile` to save multiple keys (stored under "default" profile is not specified) Credentials are stored in `~/.runpod/config.toml`. +### SSH -![runpod ssh add-key](demos/ssh.gif) +Add an SSH key to your account by running `rp ssh add`. To use an existing key pass `--key` or `--key-file`. Keys are stored in `~/.runpod/ssh/`. If no key is specified a new one is generated and stored. -Add a SSH key to you account by running `runpod ssh add-key`. To specify and existing key pass in `--key` or `--key-file` to use a file. Keys are stored in `~/.runpod/ssh/`. If no key is specified a new one will be generated and stored. +Once a key is added, open a terminal on any pod with `rp ssh ` (or the equivalent `rp pod connect `). -## Runpod Project +## Apps -A "project" is the start of a serverless worker. To get started call `runpod project new`, you will be asked a few questions about the project you are creating, a project folder will be created. You can now navigate into your repo and run `runpod project start`. - -Once you are finished developing you can run `runpod project deploy` to deploy your project and create an endpoint. +An app is a collection of Python functions that run on Runpod. Scaffold one with `rp init`, iterate on it live with `rp dev main.py`, and ship it with `rp deploy`. See the [README](../../README.md) for the full workflow. diff --git a/examples/apps/README.md b/examples/apps/README.md new file mode 100644 index 00000000..b8a38130 --- /dev/null +++ b/examples/apps/README.md @@ -0,0 +1,20 @@ +# app examples + +each file is a self-contained app showing one way to use the sdk. +run any of them live with `rp dev`: + +```bash +rp dev examples/apps/hello_world.py +``` + +| example | shows | +|---|---| +| `hello_world` | one queue function, one `.remote()` call | +| `streaming` | generator functions, `.stream()` chunks, `job.stream()` | +| `gpu_inference` | gpu selection, dependencies, a cuda benchmark | +| `web_service` | `@app.api` class with routes and per-worker state | +| `train_and_eval` | gpu tasks sharing checkpoints through a volume | + +edit a file while the session is running, then press enter to re-run — +workers pick up the new code automatically. the exhaustive +feature-by-feature suite lives in [`tests/e2e/examples`](../../tests/e2e/examples). diff --git a/examples/apps/gpu_inference.py b/examples/apps/gpu_inference.py new file mode 100644 index 00000000..5e5bacb5 --- /dev/null +++ b/examples/apps/gpu_inference.py @@ -0,0 +1,44 @@ +"""Run a function on a GPU. + +Ask for hardware by name — "4090", "H100", "B200" all work, as do +pool ids like GpuGroup.ADA_24 and exact device names. Dependencies +listed on the decorator are ready before your function runs, so the +worker needs no custom image. + + rp dev examples/apps/gpu_inference.py +""" + +import runpod +from runpod import App + +app = App("gpu-demo") + + +@app.queue(gpu="4090", dependencies=["numpy"]) +def matmul_benchmark(size: int = 4096): + import time + + import torch + + device = torch.cuda.get_device_name(0) + print(f"running on {device}") + + a = torch.randn(size, size, device="cuda") + b = torch.randn(size, size, device="cuda") + + torch.cuda.synchronize() + start = time.time() + for _ in range(10): + a @ b + torch.cuda.synchronize() + elapsed = time.time() - start + + tflops = 10 * 2 * size**3 / elapsed / 1e12 + print(f"{size}x{size} matmul: {tflops:.1f} TFLOPS") + return {"device": device, "tflops": round(tflops, 1)} + + +@runpod.local_entrypoint +def main(): + result = matmul_benchmark.remote() + print("benchmark:", result) diff --git a/examples/apps/hello_world.py b/examples/apps/hello_world.py new file mode 100644 index 00000000..0bee9c5c --- /dev/null +++ b/examples/apps/hello_world.py @@ -0,0 +1,32 @@ +"""Your first app: a Python function that runs in the cloud. + +An App is a named collection of functions. Decorating a function with +@app.queue turns it into a remote resource — calling .remote() sends +the call to a cloud worker and blocks until the result comes back. + +Try it: + + rp dev examples/apps/hello_world.py + +Edit the greeting while the session is running, then press enter to +re-run — the worker picks up your new code automatically. +""" + +import runpod +from runpod import App + +app = App("hello") + + +@app.queue(cpu="cpu3c-1-2") +def hello(name: str): + # this print shows up in your terminal, streamed from the worker + print(f"running in the cloud, greeting {name}") + return f"hello {name}!" + + +@runpod.local_entrypoint +def main(): + # runs on your machine; hello() runs on a cloud worker + greeting = hello.remote("world") + print(greeting) diff --git a/examples/apps/streaming.py b/examples/apps/streaming.py new file mode 100644 index 00000000..35267bcf --- /dev/null +++ b/examples/apps/streaming.py @@ -0,0 +1,42 @@ +"""streaming: yield partial results from a queue function. + +A generator function decorated with @app.queue streams its output. +Calling .stream() yields each chunk as the worker produces it; +calling .remote() returns the full list of chunks at once. A spawned +job can be streamed later with job.stream(). + +Try it: + + rp dev examples/apps/streaming.py +""" + +import time + +import runpod +from runpod import App + +app = App("streaming") + + +@app.queue(cpu="cpu3c-1-2") +def tokens(prompt: str): + # stand-in for token-by-token llm output + for word in f"you said: {prompt}".split(): + time.sleep(0.2) + yield word + + +@runpod.local_entrypoint +def main(): + # chunks arrive as the worker yields them + for chunk in tokens.stream("hello streaming world"): + print(chunk, end=" ", flush=True) + print() + + # .remote() on a generator returns every chunk at once + all_chunks = tokens.remote("all at once") + print(all_chunks) + + # spawn now, stream later + job = tokens.spawn("stream me later") + print(list(job.stream())) diff --git a/examples/apps/train_and_eval.py b/examples/apps/train_and_eval.py new file mode 100644 index 00000000..db7b4c44 --- /dev/null +++ b/examples/apps/train_and_eval.py @@ -0,0 +1,71 @@ +"""A training workflow: tasks, volumes, and functions working together. + +@app.task gives each call its own dedicated pod that terminates when +the function returns — right for work that runs minutes to hours. +A Volume persists files between calls and across resources: train() +writes a checkpoint, evaluate() reads it from a different pod. The +volume is created on first use, and both tasks are automatically +placed in its datacenter. + + rp dev examples/apps/train_and_eval.py +""" + +import runpod +from runpod import App, Volume + +app = App("trainer") + +checkpoints = Volume("checkpoints", size=10) + + +@app.task(gpu="4090", volume=checkpoints) +def train(steps: int = 200): + import torch + import torch.nn as nn + + device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"training on {device}") + + # learn y = 3x + 1 from noisy samples + x = torch.randn(2048, 1, device=device) + y = 3 * x + 1 + torch.randn_like(x) * 0.05 + + model = nn.Linear(1, 1).to(device) + opt = torch.optim.SGD(model.parameters(), lr=0.05) + for step in range(1, steps + 1): + loss = ((model(x) - y) ** 2).mean() + opt.zero_grad() + loss.backward() + opt.step() + if step % 50 == 0: + print(f"step {step}/{steps} loss={loss.item():.5f}") + + # checkpoints.path is where the volume is mounted on this pod + run_dir = checkpoints.path / "linear" + run_dir.mkdir(parents=True, exist_ok=True) + torch.save(model.state_dict(), run_dir / "model.pt") + print(f"saved checkpoint to {run_dir}") + return {"checkpoint": "linear", "final_loss": round(loss.item(), 5)} + + +@app.task(gpu="4090", volume=checkpoints) +def evaluate(checkpoint: str): + import torch + import torch.nn as nn + + model = nn.Linear(1, 1) + model.load_state_dict( + torch.load(checkpoints.path / checkpoint / "model.pt") + ) + weight, bias = model.weight.item(), model.bias.item() + print(f"learned: y = {weight:.3f}x + {bias:.3f}") + return {"weight": round(weight, 2), "bias": round(bias, 2)} + + +@runpod.local_entrypoint +def main(): + out = train.remote() + print("train finished:", out) + + fit = evaluate.remote(out["checkpoint"]) + print("evaluation:", fit) diff --git a/examples/apps/web_service.py b/examples/apps/web_service.py new file mode 100644 index 00000000..fe6f6baa --- /dev/null +++ b/examples/apps/web_service.py @@ -0,0 +1,42 @@ +"""A load-balanced HTTP service from a plain class. + +@app.api turns a class into a web service: mark methods with @get and +@post to expose routes, and @init to run setup once per worker before +it takes traffic. Workers keep state between requests — this counter +lives in memory on the worker. + + rp dev examples/apps/web_service.py + +Deployed services get a stable URL; in dev you call routes through the +class itself, as shown in the entrypoint. +""" + +import runpod +from runpod import App, get, init, post + +app = App("web") + + +@app.api(cpu="cpu3c-1-2") +class Counter: + @init + def setup(self): + # runs once when a worker starts, before any request + self.count = 0 + print("worker ready, counter at 0") + + @post("/bump") + async def bump(self, body: dict): + self.count += body.get("by", 1) + return {"count": self.count} + + @get("/value") + async def value(self): + return {"count": self.count} + + +@runpod.local_entrypoint +def main(): + print(Counter.post("/bump", {"by": 3})) # {'count': 3} + print(Counter.post("/bump", {"by": 2})) # {'count': 5} + print(Counter.get("/value")) # {'count': 5} diff --git a/pyproject.toml b/pyproject.toml index d4639a15..4a8c870c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,8 @@ include-package-data = true runpod = [ "serverless/binaries/gpu_test", "serverless/binaries/README.md", + "runtimes/**/*.py", + "runtimes/**/Dockerfile*", ] [tool.setuptools_scm] @@ -60,7 +62,8 @@ local_scheme = "no-local-version" [project.scripts] -runpod = "runpod.cli.entry:runpod_cli" +runpod = "runpod.rp_cli.main:run" +rp = "runpod.rp_cli.main:run" [dependency-groups] diff --git a/pytest.ini b/pytest.ini index 1c2c135e..3e3bb1d6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,6 +7,9 @@ filterwarnings = # only warn. Don't fail the suite on them until the deps update. ignore:'asyncio\.get_event_loop_policy' is deprecated:DeprecationWarning ignore:'asyncio\.iscoroutinefunction' is deprecated:DeprecationWarning + # fastapi's bundled TestClient imports starlette.testclient, which warns + # about httpx until fastapi migrates to httpx2 + ignore:Using `httpx` with `starlette.testclient` is deprecated.* python_files = tests.py test_*.py *_test.py norecursedirs = venv *.egg-info .git build tests/e2e asyncio_mode = auto diff --git a/requirements.txt b/requirements.txt index 2ed35782..0cdd4a60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ aiohttp-retry >= 2.9.1 backoff >= 2.2.1 boto3 >= 1.43.40 click >= 8.1.7 +cloudpickle >= 3.0.0 colorama >= 0.4.6, < 0.4.7 cryptography >= 48.0.1 fastapi[all] >= 0.138.1 @@ -13,8 +14,8 @@ psutil >= 5.9.0 py-cpuinfo >= 9.0.0 inquirerpy == 0.3.4 requests >= 2.34.2 +rich >= 13.0.0 tomli >= 2.4.1 tomlkit >= 0.15.0 tqdm-loggable >= 0.4.1 urllib3 >= 1.26.6 -watchdog >= 6.0.0 diff --git a/runpod/__init__.py b/runpod/__init__.py index 6d24180a..ddbfa21e 100644 --- a/runpod/__init__.py +++ b/runpod/__init__.py @@ -1,88 +1,116 @@ -""" Allows runpod to be imported as a module. """ +"""Allows runpod to be imported as a module.""" +import importlib import logging import os -from . import serverless -from .api.ctl_commands import ( - create_container_registry_auth, - create_endpoint, - create_pod, - create_template, - delete_container_registry_auth, - get_endpoints, - get_gpu, - get_gpus, - get_pod, - get_pods, - get_user, - resume_pod, - stop_pod, - terminate_pod, - update_container_registry_auth, - update_endpoint_template, - update_user_settings, -) -from .cli.groups.config.functions import ( - check_credentials, - get_credentials, - set_credentials, -) -from .endpoint import AsyncioEndpoint, AsyncioJob, Endpoint -from .serverless.modules.rp_logger import RunPodLogger from .version import __version__ +# public names resolved on first access (PEP 562). keeping the top-level +# import light means `import runpod` (and the deployed worker that does +# it) pulls only what it actually touches, not the whole sdk/cli closure. +_LAZY_ATTRS = { + # api control-plane commands + "create_container_registry_auth": "runpod.api.ctl_commands", + "create_endpoint": "runpod.api.ctl_commands", + "create_pod": "runpod.api.ctl_commands", + "create_template": "runpod.api.ctl_commands", + "delete_container_registry_auth": "runpod.api.ctl_commands", + "get_endpoints": "runpod.api.ctl_commands", + "get_gpu": "runpod.api.ctl_commands", + "get_gpus": "runpod.api.ctl_commands", + "get_pod": "runpod.api.ctl_commands", + "get_pods": "runpod.api.ctl_commands", + "get_user": "runpod.api.ctl_commands", + "resume_pod": "runpod.api.ctl_commands", + "stop_pod": "runpod.api.ctl_commands", + "terminate_pod": "runpod.api.ctl_commands", + "update_container_registry_auth": "runpod.api.ctl_commands", + "update_endpoint_template": "runpod.api.ctl_commands", + "update_user_settings": "runpod.api.ctl_commands", + # config helpers + "check_credentials": "runpod.cli.groups.config.functions", + "get_credentials": "runpod.cli.groups.config.functions", + "set_credentials": "runpod.cli.groups.config.functions", + # endpoint clients + "AsyncioEndpoint": "runpod.endpoint", + "AsyncioJob": "runpod.endpoint", + "Endpoint": "runpod.endpoint", + # apps surface + "Api": "runpod.apps", + "App": "runpod.apps", + "DataCenter": "runpod.apps", + "EndpointNotFound": "runpod.apps", + "Job": "runpod.apps", + "Model": "runpod.apps", + "Queue": "runpod.apps", + "Secret": "runpod.apps", + "Volume": "runpod.apps", + "is_local": "runpod.apps", + "local_entrypoint": "runpod.apps", + "schedule": "runpod.apps", + "CpuInstanceType": "runpod.apps.gpu", + "GpuGroup": "runpod.apps.gpu", + "GpuType": "runpod.apps.gpu", + "delete": "runpod.apps.markers", + "get": "runpod.apps.markers", + "init": "runpod.apps.markers", + "patch": "runpod.apps.markers", + "post": "runpod.apps.markers", + "put": "runpod.apps.markers", + # logger + "RunPodLogger": "runpod.serverless.modules.rp_logger", + # submodule + "serverless": "runpod.serverless", +} + +# lazy names resolve through __getattr__, so __all__ derives from the +# lazy table plus the eagerly-defined module attributes __all__ = [ - # API functions - "create_container_registry_auth", - "create_endpoint", - "create_pod", - "create_template", - "delete_container_registry_auth", - "get_endpoints", - "get_gpu", - "get_gpus", - "get_pod", - "get_pods", - "get_user", - "resume_pod", - "stop_pod", - "terminate_pod", - "update_container_registry_auth", - "update_endpoint_template", - "update_user_settings", - # Config functions - "check_credentials", - "get_credentials", - "set_credentials", - # Endpoint classes - "AsyncioEndpoint", - "AsyncioJob", - "Endpoint", - # Serverless module - "serverless", - # Logger class - "RunPodLogger", - # Version + *_LAZY_ATTRS, "__version__", - # Module variables "SSH_KEY_PATH", "profile", - "api_key", - "endpoint_url_base" + "api_key", + "endpoint_url_base", ] + +def _load_api_key(): + """resolve the api key from the environment, then stored credentials. + + the credential lookup is deferred (and tolerant of a missing cli + dependency closure) so a bare `import runpod` never drags in the + config/cli modules; the deployed worker gets its key from the env. + """ + key = os.environ.get("RUNPOD_API_KEY") + if key: + return key + try: + creds = importlib.import_module( + "runpod.cli.groups.config.functions" + ).get_credentials(profile) + except Exception: # noqa: BLE001 - no stored key is a valid state + return None + return creds["api_key"] if creds else None + + +def __getattr__(name): + module_path = _LAZY_ATTRS.get(name) + if module_path is None: + raise AttributeError(f"module 'runpod' has no attribute {name!r}") + module = importlib.import_module(module_path) + value = module if module_path == f"runpod.{name}" else getattr(module, name) + globals()[name] = value + return value + + # ------------------------------- Config Paths ------------------------------- # SSH_KEY_PATH = os.path.expanduser("~/.runpod/ssh") - profile = "default" # pylint: disable=invalid-name -_credentials = get_credentials(profile) -if _credentials is not None: - api_key = _credentials["api_key"] # pylint: disable=invalid-name -else: - api_key = None # pylint: disable=invalid-name +api_key = _load_api_key() # pylint: disable=invalid-name endpoint_url_base = os.environ.get( "RUNPOD_ENDPOINT_BASE_URL", "https://api.runpod.ai/v2" diff --git a/runpod/api/graphql.py b/runpod/api/graphql.py index c7c3ccc3..36787425 100644 --- a/runpod/api/graphql.py +++ b/runpod/api/graphql.py @@ -14,40 +14,109 @@ HTTP_STATUS_UNAUTHORIZED = 401 -def run_graphql_query(query: str, api_key: Optional[str] = None) -> Dict[str, Any]: - """ - Run a GraphQL query with optional API key override. - - Args: - query: The GraphQL query to execute. - api_key: Optional API key to use for this query. - """ +def _graphql_url() -> str: + api_url_base = os.environ.get("RUNPOD_API_BASE_URL", "https://api.runpod.io") + return f"{api_url_base}/graphql" + + +def _resolve_api_key(api_key: Optional[str]) -> str: from runpod import api_key as global_api_key # pylint: disable=import-outside-toplevel, cyclic-import - - # Use provided API key or fall back to global + effective_api_key = api_key or global_api_key - if not effective_api_key: raise error.AuthenticationError("No API key provided") + return effective_api_key - api_url_base = os.environ.get("RUNPOD_API_BASE_URL", "https://api.runpod.io") - url = f"{api_url_base}/graphql" +def _build_headers(api_key: Optional[str]) -> Dict[str, str]: headers = { "Content-Type": "application/json", "User-Agent": USER_AGENT, - "Authorization": f"Bearer {effective_api_key}", } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + +def _build_payload(query: str, variables: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload: Dict[str, Any] = {"query": query} + if variables: + payload["variables"] = variables + return payload + + +def _check_response(response_json: Dict[str, Any], query: str) -> Dict[str, Any]: + if "errors" in response_json: + raise error.QueryError(response_json["errors"][0]["message"], query) + return response_json + + +def run_graphql_query( + query: str, + api_key: Optional[str] = None, + variables: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Run a GraphQL query with optional API key override. - data = json.dumps({"query": query}) - response = requests.post(url, headers=headers, data=data, timeout=30) + Args: + query: The GraphQL query to execute. + api_key: Optional API key to use for this query. + variables: Optional GraphQL variables to send with the query. + """ + effective_api_key = _resolve_api_key(api_key) + + response = requests.post( + _graphql_url(), + headers=_build_headers(effective_api_key), + data=json.dumps(_build_payload(query, variables)), + timeout=30, + ) if response.status_code == HTTP_STATUS_UNAUTHORIZED: raise error.AuthenticationError( "Unauthorized request, please check your API key." ) - if "errors" in response.json(): - raise error.QueryError(response.json()["errors"][0]["message"], query) + return _check_response(response.json(), query) + + +async def run_graphql_query_async( + query: str, + api_key: Optional[str] = None, + variables: Optional[Dict[str, Any]] = None, + timeout: float = 60.0, + anonymous: bool = False, +) -> Dict[str, Any]: + """ + Async variant of run_graphql_query, sharing the same url, headers, + auth resolution, and error handling. + + Args: + query: The GraphQL query to execute. + api_key: Optional API key to use for this query. + variables: Optional GraphQL variables to send with the query. + timeout: Total request timeout in seconds. + anonymous: Send the request without credentials even when a key + is available. The browser-login flow requires this: the + server only releases the granted key to guest requests, so + a stale stored key would make the poll spin forever. + """ + import aiohttp # pylint: disable=import-outside-toplevel + + effective_api_key = None if anonymous else _resolve_api_key(api_key) + + client_timeout = aiohttp.ClientTimeout(total=timeout) + async with aiohttp.ClientSession(timeout=client_timeout) as session: + async with session.post( + _graphql_url(), + headers=_build_headers(effective_api_key), + json=_build_payload(query, variables), + ) as response: + if response.status == HTTP_STATUS_UNAUTHORIZED: + raise error.AuthenticationError( + "Unauthorized request, please check your API key." + ) + response_json = await response.json() - return response.json() + return _check_response(response_json, query) diff --git a/runpod/api/mutations/apps.py b/runpod/api/mutations/apps.py new file mode 100644 index 00000000..ff7287f5 --- /dev/null +++ b/runpod/api/mutations/apps.py @@ -0,0 +1,132 @@ +"""graphql mutations for the apps control-plane client.""" + +MUTATION_SAVE_ENDPOINT = """ +mutation saveEndpoint($input: EndpointInput!) { + saveEndpoint(input: $input) { + id + name + templateId + gpuIds + instanceIds + workersMin + workersMax + idleTimeout + aiKey + } +} +""" + +MUTATION_DELETE_ENDPOINT = """ +mutation deleteEndpoint($id: String!) { + deleteEndpoint(id: $id) +} +""" + +MUTATION_DEPLOY_CPU_POD = """ +mutation deployCpuPod($input: deployCpuPodInput!) { + deployCpuPod(input: $input) { id desiredStatus } +} +""" + +MUTATION_DEPLOY_POD = """ +mutation deployPod($input: PodFindAndDeployOnDemandInput) { + podFindAndDeployOnDemand(input: $input) { id desiredStatus } +} +""" + +MUTATION_TERMINATE_POD = """ +mutation terminatePod($input: PodTerminateInput!) { + podTerminate(input: $input) +} +""" + +MUTATION_CREATE_FLASH_APP = """ +mutation createFlashApp($input: CreateFlashAppInput!) { + createFlashApp(input: $input) { id name } +} +""" + +MUTATION_CREATE_FLASH_ENVIRONMENT = """ +mutation createFlashEnvironment($input: CreateFlashEnvironmentInput!) { + createFlashEnvironment(input: $input) { id name } +} +""" + +MUTATION_CREATE_NETWORK_VOLUME = """ +mutation createNetworkVolume($input: CreateNetworkVolumeInput!) { + createNetworkVolume(input: $input) { + id + name + size + dataCenterId + } +} +""" + +MUTATION_SAVE_REGISTRY_AUTH = """ +mutation SaveRegistryAuth($input: SaveRegistryAuthInput!) { + saveRegistryAuth(input: $input) { id name } +} +""" + +MUTATION_DELETE_REGISTRY_AUTH = """ +mutation DeleteRegistryAuth($registryAuthId: String!) { + deleteRegistryAuth(registryAuthId: $registryAuthId) +} +""" + +MUTATION_CREATE_SECRET = """ +mutation secretCreate($input: SecretCreateInput!) { + secretCreate(input: $input) { id name } +} +""" + +MUTATION_DELETE_SECRET = """ +mutation secretDelete($id: ID!) { + secretDelete(id: $id) +} +""" + +MUTATION_DELETE_FLASH_APP = """ +mutation deleteFlashApp($flashAppId: String!) { + deleteFlashApp(flashAppId: $flashAppId) +} +""" + +MUTATION_DELETE_FLASH_ENVIRONMENT = """ +mutation deleteFlashEnvironment($flashEnvironmentId: String!) { + deleteFlashEnvironment(flashEnvironmentId: $flashEnvironmentId) +} +""" + +MUTATION_CREATE_FLASH_AUTH_REQUEST = """ +mutation createFlashAuthRequest { + createFlashAuthRequest { + id + status + expiresAt + } +} +""" + +MUTATION_PREPARE_ARTIFACT_UPLOAD = """ +mutation PrepareArtifactUpload($input: PrepareFlashArtifactUploadInput!) { + prepareFlashArtifactUpload(input: $input) { + uploadUrl + objectKey + expiresAt + } +} +""" + +MUTATION_FINALIZE_ARTIFACT_UPLOAD = """ +mutation FinalizeArtifactUpload($input: FinalizeFlashArtifactUploadInput!) { + finalizeFlashArtifactUpload(input: $input) { id manifest } +} +""" + +MUTATION_DEPLOY_BUILD = """ +mutation deployBuildToEnvironment($input: DeployBuildToEnvironmentInput!) { + deployBuildToEnvironment(input: $input) { id name } +} +""" diff --git a/runpod/api/queries/apps.py b/runpod/api/queries/apps.py new file mode 100644 index 00000000..8089aa25 --- /dev/null +++ b/runpod/api/queries/apps.py @@ -0,0 +1,116 @@ +"""graphql queries for the apps control-plane client.""" + +QUERY_MY_ENDPOINTS = """ +query myEndpoints { + myself { + endpoints { + id + name + templateId + workersMin + workersMax + template { env { key value } } + } + } +} +""" + +QUERY_FLASH_APP_BY_NAME = """ +query getFlashAppByName($flashAppName: String!) { + flashAppByName(flashAppName: $flashAppName) { + id + name + flashEnvironments { + id + name + state + activeBuildId + endpoints { id name } + } + } +} +""" + +QUERY_GPU_STOCK = """ +query GpuStock($gpuTypesInput: GpuTypeFilter, $lowestPriceInput: GpuLowestPriceInput) { + gpuTypes(input: $gpuTypesInput) { + lowestPrice(input: $lowestPriceInput) { stockStatus } + } +} +""" + +QUERY_CPU_STOCK = """ +query CpuStock($cpuFlavorInput: CpuFlavorInput, $specificsInput: SpecificsInput) { + cpuFlavors(input: $cpuFlavorInput) { + specifics(input: $specificsInput) { stockStatus } + } +} +""" + +QUERY_NETWORK_VOLUMES = """ +query myVolumes { + myself { + networkVolumes { id name size dataCenterId } + } +} +""" + +QUERY_REGISTRY_AUTHS = """ +query myRegistryCreds { + myself { + containerRegistryCreds { id name } + } +} +""" + +QUERY_SECRETS = """ +query mySecrets { + myself { + secrets { id name description createdAt } + } +} +""" + +QUERY_FLASH_APPS = """ +query getFlashApps { + myself { + flashApps { + id + name + flashEnvironments { + id + name + state + createdAt + activeBuildId + } + flashBuilds { id createdAt } + } + } +} +""" + +QUERY_FLASH_ENVIRONMENT_BY_NAME = """ +query getFlashEnvironmentByName($input: FlashEnvironmentByNameInput!) { + flashEnvironmentByName(input: $input) { + id + name + state + activeBuildId + createdAt + endpoints { id name } + networkVolumes { id name } + } +} +""" + +QUERY_FLASH_AUTH_REQUEST_STATUS = """ +query flashAuthRequestStatus($flashAuthRequestId: String!) { + flashAuthRequestStatus(flashAuthRequestId: $flashAuthRequestId) { + id + status + expiresAt + apiKey + } +} +""" diff --git a/runpod/apps/__init__.py b/runpod/apps/__init__.py new file mode 100644 index 00000000..3651f574 --- /dev/null +++ b/runpod/apps/__init__.py @@ -0,0 +1,53 @@ +"""app-centric sdk surface: App, decorated handles, and execution primitives.""" + +from .app import App, get_registered_apps +from .context import Context, current_context, is_local +from .datacenter import DataCenter +from .entrypoint import local_entrypoint +from .errors import ( + AppError, + EndpointNotFound, + RemoteExecutionError, + ScheduleNotSupported, +) +from .handles import ApiHandle, FunctionHandle +from .job import Job +from .markers import delete, get, init, patch, post, put +from .schedule import schedule +from .spec import ResourceKind, ResourceSpec, RouteSpec +from .stubs import Api, Queue +from .model import Model +from .secret import Secret +from .volume import Volume + +__all__ = [ + "Api", + "ApiHandle", + "App", + "AppError", + "Context", + "DataCenter", + "EndpointNotFound", + "FunctionHandle", + "Job", + "Model", + "Queue", + "Secret", + "Volume", + "RemoteExecutionError", + "ResourceKind", + "ResourceSpec", + "RouteSpec", + "ScheduleNotSupported", + "current_context", + "delete", + "get", + "get_registered_apps", + "init", + "is_local", + "local_entrypoint", + "patch", + "post", + "put", + "schedule", +] diff --git a/runpod/apps/api.py b/runpod/apps/api.py new file mode 100644 index 00000000..e69bfc78 --- /dev/null +++ b/runpod/apps/api.py @@ -0,0 +1,380 @@ +"""control-plane calls for app provisioning. + +a thin wrapper over runpod.api.graphql's shared async transport, +scoped to what the apps surface needs: endpoint save/delete for dev +sessions, and the app / build / environment lifecycle for deploys. +management verbs for the wider sdk stay in runpod.api.ctl_commands. +""" + +from typing import Any, Dict, List, Optional + +import aiohttp + +from ..api.graphql import run_graphql_query_async +from ..api.mutations import apps as app_mutations +from ..api.queries import apps as app_queries +from ..error import QueryError + + +_TRANSPORT_RETRIES = 4 + + +class AppsApiClient: + """async control-plane client scoped to apps provisioning.""" + + def __init__(self, api_key: Optional[str] = None): + self._api_key = api_key + + async def _execute( + self, + query: str, + variables: Optional[Dict[str, Any]] = None, + *, + anonymous: bool = False, + ) -> Dict[str, Any]: + import asyncio + + last_exc: Optional[Exception] = None + for attempt in range(_TRANSPORT_RETRIES): + if attempt: + await asyncio.sleep(2 ** (attempt - 1)) + try: + response = await run_graphql_query_async( + query, + api_key=self._api_key, + variables=variables, + anonymous=anonymous, + ) + return response["data"] + except (aiohttp.ClientError, OSError, asyncio.TimeoutError) as exc: + # transport-level failures (ssl hiccups, resets, dns) + # are transient; graphql/auth errors propagate untouched + last_exc = exc + if last_exc is None: # pragma: no cover - loop always runs once + raise RuntimeError("graphql transport retry loop exited cleanly") + raise last_exc + + async def save_endpoint(self, endpoint_input: Dict[str, Any]) -> Dict[str, Any]: + """create or update a serverless endpoint. include id to update.""" + mutation = app_mutations.MUTATION_SAVE_ENDPOINT + data = await self._execute(mutation, {"input": endpoint_input}) + return data["saveEndpoint"] + + async def delete_endpoint(self, endpoint_id: str) -> bool: + mutation = app_mutations.MUTATION_DELETE_ENDPOINT + data = await self._execute(mutation, {"id": endpoint_id}) + return bool(data.get("deleteEndpoint")) + + async def list_my_endpoints(self) -> List[Dict[str, Any]]: + query = app_queries.QUERY_MY_ENDPOINTS + data = await self._execute(query) + return data["myself"]["endpoints"] + + async def deploy_task_pod( + self, pod_input: Dict[str, Any], *, is_cpu: bool + ) -> Dict[str, Any]: + """deploy an on-demand pod for a task run.""" + pod_input = dict(pod_input) + if is_cpu: + # deployCpuPod takes a single instanceId + instance_ids = pod_input.pop("instanceIds", None) + if instance_ids: + pod_input["instanceId"] = instance_ids[0] + mutation = app_mutations.MUTATION_DEPLOY_CPU_POD + data = await self._execute(mutation, {"input": pod_input}) + return data["deployCpuPod"] + + mutation = app_mutations.MUTATION_DEPLOY_POD + data = await self._execute(mutation, {"input": pod_input}) + return data["podFindAndDeployOnDemand"] + + async def terminate_pod(self, pod_id: str) -> None: + mutation = app_mutations.MUTATION_TERMINATE_POD + await self._execute(mutation, {"input": {"podId": pod_id}}) + + async def get_app_by_name(self, name: str) -> Optional[Dict[str, Any]]: + query = app_queries.QUERY_FLASH_APP_BY_NAME + try: + data = await self._execute(query, {"flashAppName": name}) + except QueryError as exc: + if "not found" in str(exc).lower(): + return None + raise + return data["flashAppByName"] + + async def create_app(self, name: str) -> Dict[str, Any]: + mutation = app_mutations.MUTATION_CREATE_FLASH_APP + data = await self._execute(mutation, {"input": {"name": name}}) + return data["createFlashApp"] + + async def create_environment(self, app_id: str, name: str) -> Dict[str, Any]: + mutation = app_mutations.MUTATION_CREATE_FLASH_ENVIRONMENT + data = await self._execute( + mutation, {"input": {"flashAppId": app_id, "name": name}} + ) + return data["createFlashEnvironment"] + + async def gpu_stock_status( + self, + gpu_id: str, + data_center_id: str, + gpu_count: int = 1, + pods: bool = False, + ) -> Optional[str]: + """stock signal for a gpu device in one datacenter. + + the serverless plane (includeAiApi) and the pod plane have + different availability; pods=True queries pod stock (tasks). + """ + query = app_queries.QUERY_GPU_STOCK + data = await self._execute( + query, + { + "gpuTypesInput": {"ids": [gpu_id]}, + "lowestPriceInput": { + "dataCenterId": data_center_id, + "gpuCount": gpu_count, + "secureCloud": True, + "includeAiApi": not pods, + }, + }, + ) + gpu_types = data.get("gpuTypes") or [] + first = gpu_types[0] if gpu_types else {} + price = first.get("lowestPrice") if isinstance(first, dict) else None + return (price or {}).get("stockStatus") + + async def cpu_stock_status( + self, instance_id: str, data_center_id: str + ) -> Optional[str]: + """stock signal for a cpu flavor in one datacenter.""" + flavor = instance_id.split("-", 1)[0] + query = app_queries.QUERY_CPU_STOCK + data = await self._execute( + query, + { + "cpuFlavorInput": {"id": flavor}, + "specificsInput": { + "dataCenterId": data_center_id, + "instanceId": instance_id, + }, + }, + ) + flavors = data.get("cpuFlavors") or [] + first = flavors[0] if flavors else {} + specifics = first.get("specifics") if isinstance(first, dict) else None + return (specifics or {}).get("stockStatus") + + async def list_network_volumes(self) -> List[Dict[str, Any]]: + query = app_queries.QUERY_NETWORK_VOLUMES + data = await self._execute(query) + return data["myself"].get("networkVolumes") or [] + + async def create_network_volume( + self, name: str, size: int, data_center_id: str + ) -> Dict[str, Any]: + mutation = app_mutations.MUTATION_CREATE_NETWORK_VOLUME + data = await self._execute( + mutation, + { + "input": { + "name": name, + "size": size, + "dataCenterId": data_center_id, + } + }, + ) + return data["createNetworkVolume"] + + async def list_registry_auths(self) -> List[Dict[str, Any]]: + query = app_queries.QUERY_REGISTRY_AUTHS + data = await self._execute(query) + return data["myself"].get("containerRegistryCreds") or [] + + async def create_registry_auth( + self, name: str, username: str, password: str + ) -> Dict[str, Any]: + mutation = app_mutations.MUTATION_SAVE_REGISTRY_AUTH + data = await self._execute( + mutation, + { + "input": { + "name": name, + "username": username, + "password": password, + } + }, + ) + return data["saveRegistryAuth"] + + async def delete_registry_auth(self, auth_id: str) -> bool: + mutation = app_mutations.MUTATION_DELETE_REGISTRY_AUTH + data = await self._execute(mutation, {"registryAuthId": auth_id}) + return bool(data.get("deleteRegistryAuth")) + + async def list_secrets(self) -> List[Dict[str, Any]]: + query = app_queries.QUERY_SECRETS + data = await self._execute(query) + return data["myself"].get("secrets") or [] + + async def create_secret( + self, name: str, value: str, description: str = "" + ) -> Dict[str, Any]: + mutation = app_mutations.MUTATION_CREATE_SECRET + data = await self._execute( + mutation, + { + "input": { + "name": name, + "value": value, + "description": description, + } + }, + ) + return data["secretCreate"] + + async def delete_secret(self, secret_id: str) -> bool: + mutation = app_mutations.MUTATION_DELETE_SECRET + data = await self._execute(mutation, {"id": secret_id}) + return bool(data.get("secretDelete")) + + async def list_apps(self) -> List[Dict[str, Any]]: + """all flash apps with their environments and builds.""" + query = app_queries.QUERY_FLASH_APPS + data = await self._execute(query) + return data["myself"].get("flashApps") or [] + + async def get_environment_by_name( + self, app_name: str, env_name: str + ) -> Optional[Dict[str, Any]]: + """an environment with its attached endpoints and volumes.""" + app = await self.get_app_by_name(app_name) + if app is None: + return None + query = app_queries.QUERY_FLASH_ENVIRONMENT_BY_NAME + try: + data = await self._execute( + query, + {"input": {"flashAppId": app["id"], "name": env_name}}, + ) + except QueryError as exc: + if "not found" in str(exc).lower(): + return None + raise + return data["flashEnvironmentByName"] + + async def delete_app(self, app_id: str) -> bool: + mutation = app_mutations.MUTATION_DELETE_FLASH_APP + data = await self._execute(mutation, {"flashAppId": app_id}) + return bool(data.get("deleteFlashApp")) + + async def delete_environment(self, environment_id: str) -> bool: + mutation = app_mutations.MUTATION_DELETE_FLASH_ENVIRONMENT + data = await self._execute(mutation, {"flashEnvironmentId": environment_id}) + return bool(data.get("deleteFlashEnvironment")) + + async def create_auth_request(self) -> Dict[str, Any]: + """open a browser-approval auth request; no credentials needed.""" + mutation = app_mutations.MUTATION_CREATE_FLASH_AUTH_REQUEST + data = await self._execute(mutation, anonymous=True) + return data.get("createFlashAuthRequest") or {} + + async def get_auth_request_status(self, request_id: str) -> Dict[str, Any]: + query = app_queries.QUERY_FLASH_AUTH_REQUEST_STATUS + data = await self._execute( + query, + {"flashAuthRequestId": request_id}, + anonymous=True, + ) + return data.get("flashAuthRequestStatus") or {} + + async def prepare_artifact_upload( + self, app_id: str, tarball_size: int + ) -> Dict[str, Any]: + mutation = app_mutations.MUTATION_PREPARE_ARTIFACT_UPLOAD + data = await self._execute( + mutation, {"input": {"flashAppId": app_id, "tarballSize": tarball_size}} + ) + return data["prepareFlashArtifactUpload"] + + async def finalize_artifact_upload( + self, app_id: str, object_key: str, manifest: Dict[str, Any] + ) -> Dict[str, Any]: + mutation = app_mutations.MUTATION_FINALIZE_ARTIFACT_UPLOAD + data = await self._execute( + mutation, + { + "input": { + "flashAppId": app_id, + "objectKey": object_key, + "manifest": manifest, + } + }, + ) + return data["finalizeFlashArtifactUpload"] + + async def deploy_build(self, environment_id: str, build_id: str) -> Dict[str, Any]: + mutation = app_mutations.MUTATION_DEPLOY_BUILD + data = await self._execute( + mutation, + { + "input": { + "flashEnvironmentId": environment_id, + "flashBuildId": build_id, + } + }, + ) + return data["deployBuildToEnvironment"] + + async def upload_tarball( + self, + upload_url: str, + tar_path: str, + progress: Optional[Any] = None, + ) -> None: + """put the build tarball to the presigned url. + + progress, when given, is called with (bytes_sent, total_bytes) + as chunks go out. transient resets (broken pipe, connection + reset, 5xx) are retried with backoff; the payload is re-read + from disk on each attempt. + """ + import asyncio + import os + + timeout = aiohttp.ClientTimeout(total=600) + total = os.path.getsize(tar_path) + + async def _reader(): + sent = 0 + with open(tar_path, "rb") as f: + while True: + chunk = f.read(1024 * 1024) + if not chunk: + break + sent += len(chunk) + if progress is not None: + progress(sent, total) + yield chunk + + attempts = 4 + for attempt in range(attempts): + try: + await self._put_tarball(upload_url, _reader(), total, timeout) + return + except (aiohttp.ClientError, OSError) as exc: + if attempt == attempts - 1: + raise + await asyncio.sleep(2**attempt) + + async def _put_tarball(self, upload_url, reader, total, timeout) -> None: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.put( + upload_url, + data=reader, + headers={ + "Content-Type": "application/gzip", + "Content-Length": str(total), + }, + ) as resp: + resp.raise_for_status() diff --git a/runpod/apps/app.py b/runpod/apps/app.py new file mode 100644 index 00000000..b62027b5 --- /dev/null +++ b/runpod/apps/app.py @@ -0,0 +1,328 @@ +"""the App registry: where a project declares its resources. + + import runpod + from runpod import App, GpuType + + app = App("my-app") + + @app.queue(name="transcribe", gpu=GpuType.NVIDIA_GEFORCE_RTX_4090) + async def transcribe(audio_url: str): ... + +`rp deploy` imports project modules, collects App instances from the +module-level registry, and ships each app's resources. resolution of +deployed resources is fully server-side (sentinel headers), so apps hold +no persistent local state. +""" + +import os +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from .context import Context, current_context +from .errors import EndpointNotFound, InvalidResourceError +from .gpu import GpuLike +from .handles import ApiHandle, FunctionHandle +from .spec import ( + DEFAULT_SCALER_VALUE, + ResourceKind, + ResourceSpec, + normalize_cpu, + normalize_cuda_version, + normalize_gpu, + normalize_scaler_type, + normalize_workers, +) +from .targets import InvocationTarget, PodTarget, SentinelTarget + +DEFAULT_ENV = "default" + +_REGISTRY: List["App"] = [] + + +def get_registered_apps() -> List["App"]: + """all App instances created in this process.""" + return list(_REGISTRY) + + +def _clear_registry() -> None: + """testing only.""" + _REGISTRY.clear() + + +class App: + """a named collection of deployable resources.""" + + def __init__(self, name: str, *, env: str = DEFAULT_ENV): + if not name or not isinstance(name, str): + raise InvalidResourceError("App name must be a non-empty string") + self.name = name + self.env = env + self._resources: Dict[str, Union[FunctionHandle, ApiHandle]] = {} + self._dev_events: Optional[object] = None + self._dev_targets: Dict[str, InvocationTarget] = {} + _REGISTRY.append(self) + + @property + def resources(self) -> Dict[str, Union[FunctionHandle, ApiHandle]]: + return dict(self._resources) + + def _register(self, name: str, handle: Union[FunctionHandle, ApiHandle]) -> None: + if name in self._resources: + raise InvalidResourceError( + f"duplicate resource name '{name}' in app '{self.name}'" + ) + self._resources[name] = handle + + def queue( + self, + *, + name: Optional[str] = None, + gpu: Optional[Union[GpuLike, List[GpuLike]]] = None, + cpu: Optional[Union[str, List[str]]] = None, + gpu_count: int = 1, + workers: Union[int, Tuple[int, int], None] = None, + idle_timeout: int = 60, + dependencies: Optional[List[str]] = None, + system_dependencies: Optional[List[str]] = None, + volume: Optional[Any] = None, + env: Optional[Dict[str, Any]] = None, + datacenter: Optional[Union[str, List[str]]] = None, + image: Optional[str] = None, + registry_auth: Optional[str] = None, + model: Optional[Any] = None, + max_concurrency: int = 1, + execution_timeout_ms: int = 0, + flashboot: bool = True, + scaler_type: Optional[str] = None, + scaler_value: int = DEFAULT_SCALER_VALUE, + min_cuda_version: Optional[str] = None, + accelerate_downloads: bool = True, + container_disk_gb: Optional[int] = None, + ) -> Callable[[Callable], FunctionHandle]: + """declare a queue-based serverless endpoint from a function.""" + + def decorator(fn: Callable) -> FunctionHandle: + spec = ResourceSpec( + kind=ResourceKind.QUEUE, + name=name or fn.__name__, + gpu=normalize_gpu(gpu), + cpu=normalize_cpu(cpu), + gpu_count=gpu_count, + workers=normalize_workers(workers), + idle_timeout=idle_timeout, + dependencies=dependencies, + system_dependencies=system_dependencies, + volume=_volume_ref(volume), + env=env, + datacenter=_datacenter_list(datacenter), + image=image, + registry_auth=registry_auth, + model=model, + max_concurrency=max_concurrency, + execution_timeout_ms=execution_timeout_ms, + flashboot=flashboot, + scaler_type=normalize_scaler_type(scaler_type), + scaler_value=scaler_value, + min_cuda_version=normalize_cuda_version(min_cuda_version), + accelerate_downloads=accelerate_downloads, + container_disk_gb=container_disk_gb, + ) + handle = FunctionHandle(self, fn, spec) + self._register(spec.name, handle) + return handle + + return decorator + + def task( + self, + *, + name: Optional[str] = None, + gpu: Optional[Union[GpuLike, List[GpuLike]]] = None, + cpu: Optional[Union[str, List[str]]] = None, + gpu_count: int = 1, + dependencies: Optional[List[str]] = None, + system_dependencies: Optional[List[str]] = None, + volume: Optional[Any] = None, + env: Optional[Dict[str, Any]] = None, + image: Optional[str] = None, + registry_auth: Optional[str] = None, + datacenter: Optional[Union[str, List[str]]] = None, + min_cuda_version: Optional[str] = None, + accelerate_downloads: bool = True, + container_disk_gb: Optional[int] = None, + ) -> Callable[[Callable], FunctionHandle]: + """declare ephemeral pod compute from a function.""" + + def decorator(fn: Callable) -> FunctionHandle: + spec = ResourceSpec( + kind=ResourceKind.TASK, + name=name or fn.__name__, + gpu=normalize_gpu(gpu), + cpu=normalize_cpu(cpu), + gpu_count=gpu_count, + dependencies=dependencies, + system_dependencies=system_dependencies, + volume=_volume_ref(volume), + env=env, + image=image, + registry_auth=registry_auth, + datacenter=_datacenter_list(datacenter), + min_cuda_version=normalize_cuda_version(min_cuda_version), + accelerate_downloads=accelerate_downloads, + container_disk_gb=container_disk_gb, + ) + handle = FunctionHandle(self, fn, spec) + self._register(spec.name, handle) + return handle + + return decorator + + def api( + self, + *, + name: Optional[str] = None, + gpu: Optional[Union[GpuLike, List[GpuLike]]] = None, + cpu: Optional[Union[str, List[str]]] = None, + gpu_count: int = 1, + workers: Union[int, Tuple[int, int], None] = None, + idle_timeout: int = 60, + dependencies: Optional[List[str]] = None, + system_dependencies: Optional[List[str]] = None, + volume: Optional[Any] = None, + env: Optional[Dict[str, Any]] = None, + datacenter: Optional[Union[str, List[str]]] = None, + image: Optional[str] = None, + registry_auth: Optional[str] = None, + model: Optional[Any] = None, + execution_timeout_ms: int = 0, + flashboot: bool = True, + scaler_type: Optional[str] = None, + scaler_value: int = DEFAULT_SCALER_VALUE, + min_cuda_version: Optional[str] = None, + accelerate_downloads: bool = True, + container_disk_gb: Optional[int] = None, + ) -> Callable[[Any], ApiHandle]: + """declare a load-balanced serverless endpoint. + + decorate a class with route markers for the native experience, or + a zero-argument function returning an asgi app to serve an + existing fastapi/starlette application: + + @app.api(name="inference", gpu=GpuType.NVIDIA_L4) + class Inference: + @init + def setup(self): self.model = load() + + @post("/generate") + async def generate(self, body: dict): ... + + @app.api(name="fast", cpu="cpu5c-2-4") + def web(): + from fastapi import FastAPI + server = FastAPI() + ... + return server + """ + + def decorator(target: Any) -> ApiHandle: + spec = ResourceSpec( + kind=ResourceKind.API, + name=name or target.__name__, + gpu=normalize_gpu(gpu), + cpu=normalize_cpu(cpu), + gpu_count=gpu_count, + workers=normalize_workers(workers), + idle_timeout=idle_timeout, + dependencies=dependencies, + system_dependencies=system_dependencies, + volume=_volume_ref(volume), + env=env, + datacenter=_datacenter_list(datacenter), + image=image, + registry_auth=registry_auth, + model=model, + execution_timeout_ms=execution_timeout_ms, + flashboot=flashboot, + scaler_type=normalize_scaler_type(scaler_type), + scaler_value=scaler_value, + min_cuda_version=normalize_cuda_version(min_cuda_version), + accelerate_downloads=accelerate_downloads, + container_disk_gb=container_disk_gb, + ) + handle = ApiHandle(self, target, spec) + self._register(spec.name, handle) + return handle + + return decorator + + async def _resolve(self, spec: ResourceSpec) -> InvocationTarget: + """resolve a resource spec to an invocation target.""" + if spec.kind is ResourceKind.TASK: + handle = self._resources.get(spec.name) + fn = getattr(handle, "_fn", None) + return PodTarget(spec, fn, events=self._dev_events) + + ctx = current_context() + + if ctx is Context.DEV: + target = self._dev_targets.get(spec.name) + if target is not None: + return target + raise EndpointNotFound(self.name, spec.name) + + if ctx is Context.WORKER and os.getenv("RUNPOD_DEV_APP") == self.name: + return await self._resolve_dev_sibling(spec) + + env_name = os.getenv("FLASH_ENVIRONMENT") or self.env + return SentinelTarget(self.name, env_name, spec.name) + + async def _resolve_dev_sibling(self, spec: ResourceSpec) -> InvocationTarget: + """find the dev endpoint for a sibling resource by name.""" + from .targets import LiveTarget + + target = self._dev_targets.get(spec.name) + if target is not None: + return target + + from .api import AppsApiClient + from .dev import dev_endpoint_name + + wanted = dev_endpoint_name(self.name, spec.name) + endpoints = await AppsApiClient().list_my_endpoints() + for endpoint in endpoints: + if endpoint["name"] == wanted: + target = LiveTarget(endpoint["id"], spec.name) + self._dev_targets[spec.name] = target + return target + raise EndpointNotFound(self.name, spec.name) + + def __repr__(self) -> str: + return f"" + + +def _datacenter_list( + datacenter: Optional[Union[str, List[str]]], +) -> Optional[List[str]]: + """normalize datacenter input to a list of location strings.""" + if datacenter is None: + return None + if isinstance(datacenter, str): + return [datacenter] + return [str(d) for d in datacenter] + + +def _volume_ref(volume: Any) -> Optional[Any]: + """validate a volume argument: a Volume, or a name/id string. + + Volume objects pass through whole so creation config (size, + datacenter) survives to provision time. + """ + if volume is None: + return None + from .volume import Volume + + if isinstance(volume, (str, Volume)): + return volume + raise InvalidResourceError( + f"volume must be a runpod.Volume or a name/id string, " + f"got {type(volume).__name__}" + ) diff --git a/runpod/apps/auth.py b/runpod/apps/auth.py new file mode 100644 index 00000000..5bca96d2 --- /dev/null +++ b/runpod/apps/auth.py @@ -0,0 +1,77 @@ +"""browser-approval login flow.""" + +import asyncio +import datetime as dt +import os +from typing import Any, Callable, Dict, Optional + +from .api import AppsApiClient +from .errors import AppError + +CONSOLE_URL = os.environ.get("RUNPOD_CONSOLE_URL", "https://console.runpod.io") + +POLL_INTERVAL_SECONDS = 2.0 +DEFAULT_TIMEOUT_SECONDS = 600.0 + +_APPROVED_STATUSES = frozenset({"APPROVED", "CONSUMED"}) +_FAILED_STATUSES = frozenset({"DENIED", "EXPIRED"}) + + +class LoginError(AppError): + pass + + +def _parse_expires_at(value: Optional[str]) -> Optional[dt.datetime]: + if not value: + return None + try: + return dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def auth_url(request_id: str) -> str: + return f"{CONSOLE_URL}/flash/login?request={request_id}" + + +async def browser_login( + *, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + on_url: Optional[Callable[[str], None]] = None, + api: Optional[AppsApiClient] = None, +) -> str: + """run the browser-approval flow and return the granted api key. + + on_url is called with the approval url as soon as the request is + created (the caller renders/opens it). + """ + client = api or AppsApiClient() + request: Dict[str, Any] = await client.create_auth_request() + request_id = request.get("id") + + if not request_id: + raise LoginError("auth request failed to initialize") + + if on_url is not None: + on_url(auth_url(request_id)) + + deadline = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=timeout_seconds) + expires_at = _parse_expires_at(request.get("expiresAt")) + if expires_at and expires_at < deadline: + deadline = expires_at + + while True: + status_payload = await client.get_auth_request_status(request_id) + status = status_payload.get("status") + api_key = status_payload.get("apiKey") + + if api_key and status in _APPROVED_STATUSES: + return api_key + if status in _FAILED_STATUSES: + raise LoginError(f"login {status.lower()}") + if status == "CONSUMED": + raise LoginError("login request was already used") + if dt.datetime.now(dt.timezone.utc) >= deadline: + raise LoginError("login timed out") + + await asyncio.sleep(POLL_INTERVAL_SECONDS) diff --git a/runpod/apps/build.py b/runpod/apps/build.py new file mode 100644 index 00000000..53744903 --- /dev/null +++ b/runpod/apps/build.py @@ -0,0 +1,390 @@ +"""deploy-time environment build: vendor dependencies into the artifact.""" + +import logging +import os +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional, Tuple + +from .errors import AppError +from .images import DEFAULT_PYTHON_VERSION, SUPPORTED_PYTHON_VERSIONS + +log = logging.getLogger(__name__) + +# manylinux tags matching runpod worker glibc, newest first +MANYLINUX_PLATFORMS = ( + "manylinux_2_28_x86_64", + "manylinux_2_17_x86_64", + "manylinux2014_x86_64", +) + +# CUDA-scale packages provided by the gpu worker images. excluded by +# size, not by image contents: do not add packages just because an +# image happens to ship them. +SIZE_PROHIBITIVE_PACKAGES = frozenset( + { + "torch", + "torchvision", + "torchaudio", + "triton", + } +) + +# the worker runtime's actual dependency closure, a small subset of the +# runpod package's full dependency list. the runpod package is vendored +# without its dependencies (sdk/cli-only packages like boto3, paramiko, +# and cryptography never load in a worker), so these must be vendored +# explicitly to keep a dependency-free worker small. +RUNTIME_REQUIREMENTS = ( + "aiohttp[speedups]", + "aiohttp-retry", + "backoff", + "cloudpickle", + "py-cpuinfo", + "requests", + "tomli", + "tomlkit", + "tqdm-loggable", +) + +# api workers additionally serve over fastapi/uvicorn +API_RUNTIME_REQUIREMENTS = ("fastapi[standard]", "uvicorn>=0.30") + +# runpod's serverless limit for build artifacts +MAX_ARTIFACT_MB = 1500 + +_REQ_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)") + + +class BuildError(AppError): + """environment vendoring failed.""" + + +@dataclass +class BuildResult: + """output of build_environment.""" + + env_dir: Path + requirements: List[str] = field(default_factory=list) + excluded: List[str] = field(default_factory=list) + + +def requirement_name(requirement: str) -> str: + """distribution name from a requirement string (drops extras/pins).""" + match = _REQ_NAME_RE.match(requirement) + return (match.group(1) if match else requirement).lower().replace("_", "-") + + +def collect_requirements(project_root: Path, app) -> List[str]: + """project requirements.txt plus per-resource dependencies, deduped.""" + requirements: List[str] = [] + + req_file = project_root / "requirements.txt" + if req_file.exists(): + for line in req_file.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and not line.startswith("-"): + requirements.append(line) + + for handle in app.resources.values(): + for dep in handle.spec.dependencies or []: + requirements.append(dep) + + seen = set() + unique = [] + for req in requirements: + key = requirement_name(req) + if key not in seen: + seen.add(key) + unique.append(req) + return unique + + +def split_exclusions( + requirements: List[str], + extra_excludes: Optional[List[str]] = None, + *, + auto_exclude: bool = True, +) -> Tuple[List[str], List[str]]: + """(vendored, excluded-names). excluded packages resolve from the + worker image at runtime instead of the artifact. + + auto_exclude applies the size-prohibitive set; it is only sound + when the workers run on builtin gpu images that preinstall those + packages. custom images vendor everything unless the user excludes + explicitly. + """ + excluded_names = {requirement_name(e) for e in (extra_excludes or [])} + if auto_exclude: + excluded_names |= SIZE_PROHIBITIVE_PACKAGES + kept: List[str] = [] + excluded: List[str] = [] + for req in requirements: + name = requirement_name(req) + if name in excluded_names: + excluded.append(name) + else: + kept.append(req) + # size-prohibitive packages are expected from the image whether or + # not the user listed them, so the manifest always records the auto + # set that user requirements referenced + return kept, sorted(set(excluded)) + + +def _is_pinnable_name(spec: str) -> bool: + """true if the spec is a plain requirement (name, optional pin), as + opposed to a url, git ref, or filesystem path.""" + return not any(token in spec for token in ("/", "\\", ":", "@")) or bool( + _REQ_NAME_RE.match(spec) + and re.match(r"^[A-Za-z0-9._-]+(\[[^\]]+\])?([<>=!~;].*)?$", spec) + ) + + +def runtime_requirement(scratch_dir: Path) -> str: + """the runpod runtime spec to vendor into the artifact. + + RUNPOD_PACKAGE_SPEC overrides the published package (version pins, + git refs, tarball urls, local checkouts). non-index specs are + built into a wheel first so the platform-targeted vendoring + install can stay binary-only. + + without an override the published package is vendored for its + dependency closure, then sync_running_package overlays the + client's own package tree so the worker always runs exactly the + client's code regardless of what pypi has. + """ + spec = os.environ.get("RUNPOD_PACKAGE_SPEC", "runpod") + if _is_pinnable_name(spec): + return spec + return str(_build_wheel(spec, scratch_dir)) + + +def sync_running_package(env_dir: Path) -> None: + """overlay the running runpod package onto the vendored env. + + the package is pure python (py3-none-any), so the client's + installed tree is valid on the worker platform. this pins the + vendored runtime to the exact client version: wire protocols, + manifest handling, and worker code can never drift, and prerelease + clients work before their version reaches pypi. + """ + import runpod as _runpod + + src = Path(_runpod.__file__).resolve().parent + dest = env_dir / "runpod" + shutil.copytree( + src, + dest, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + dirs_exist_ok=True, + ) + + +def _ensure_pip() -> None: + """make `python -m pip` work in venvs created without pip (uv).""" + probe = subprocess.run( + [sys.executable, "-m", "pip", "--version"], + capture_output=True, + ) + if probe.returncode == 0: + return + bootstrap = subprocess.run( + [sys.executable, "-m", "ensurepip", "--upgrade"], + capture_output=True, + text=True, + ) + if bootstrap.returncode != 0: + raise BuildError( + f"pip is not available in this environment and ensurepip " + f"failed: {bootstrap.stderr[-500:]}" + ) + + +def _build_wheel(spec: str, scratch_dir: Path) -> Path: + """build a wheel from a url/path spec for binary-only vendoring.""" + _ensure_pip() + wheel_dir = scratch_dir / "wheels" + wheel_dir.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + # tarball specs (e.g. github archive urls) have no .git for + # setuptools-scm version inference; pretend a version so they build + env.setdefault("SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD", "0.0.0.dev0") + result = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "--no-deps", + "-q", + "-w", + str(wheel_dir), + spec, + ], + capture_output=True, + text=True, + env=env, + ) + if result.returncode != 0: + raise BuildError( + f"failed to build wheel from RUNPOD_PACKAGE_SPEC={spec!r}: " + f"{result.stderr[-2000:]}" + ) + wheels = sorted(wheel_dir.glob("runpod-*.whl")) + if not wheels: + raise BuildError( + f"pip wheel produced no runpod wheel from {spec!r} " + f"(found: {[w.name for w in wheel_dir.glob('*.whl')]})" + ) + return wheels[-1] + + +def vendor( + env_dir: Path, + requirements: List[str], + python_version: str, + *, + no_deps: bool = False, + progress: Optional[callable] = None, +) -> None: + """resolve requirements into env_dir for the worker platform. + + progress, if given, is called as progress(count, package) each time + pip starts resolving another distribution. + """ + if not requirements: + return + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + "--no-color", + "--target", + str(env_dir), + "--python-version", + python_version, + "--implementation", + "cp", + "--only-binary", + ":all:", + ] + for platform in MANYLINUX_PLATFORMS: + cmd.extend(["--platform", platform]) + if no_deps: + cmd.append("--no-deps") + cmd.extend(requirements) + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + count = 0 + for line in proc.stdout: + line = line.strip() + # pip emits one "Collecting " per resolved distribution + if line.startswith("Collecting "): + count += 1 + if progress is not None: + name = re.split(r"[><=!~\[ (]", line[11:], maxsplit=1)[0] + progress(count, name) + stderr = proc.stderr.read() + proc.wait() + if proc.returncode != 0: + raise BuildError( + f"dependency vendoring failed: {stderr[-3000:]}\n" + f"packages without linux wheels cannot be vendored; " + f"exclude them (they must then come from the image)" + ) + + +def _verify_runtime(env_dir: Path) -> None: + """the vendored runpod must include the worker runtimes.""" + if not (env_dir / "runpod" / "runtimes").is_dir(): + raise BuildError( + "the vendored runpod package has no runtimes modules; the " + "resolved version predates them. set RUNPOD_PACKAGE_SPEC to a " + "version that includes runpod.runtimes" + ) + + +def build_environment( + app, + project_root: Path, + *, + python_version: str = DEFAULT_PYTHON_VERSION, + exclude: Optional[List[str]] = None, + scratch_dir: Optional[Path] = None, + events: Optional[object] = None, +) -> BuildResult: + """vendor the app's full runtime environment into a directory tree.""" + if python_version not in SUPPORTED_PYTHON_VERSIONS: + raise BuildError( + f"python {python_version} is not supported " + f"(supported: {', '.join(SUPPORTED_PYTHON_VERSIONS)})" + ) + scratch = scratch_dir or Path(tempfile.mkdtemp(prefix="rp-build-")) + env_dir = scratch / "env" + env_dir.mkdir(parents=True, exist_ok=True) + + user_requirements = collect_requirements(project_root, app) + # size-prohibitive packages come preinstalled only on the builtin + # gpu images. custom images carry no guarantee, and builtin cpu + # images ship without them, so every resource must be gpu-on-builtin + # for auto-exclusion to be sound (the artifact is shared app-wide) + auto_exclude = all( + h.spec.gpu and not h.spec.image for h in app.resources.values() + ) + vendored, excluded = split_exclusions( + user_requirements, exclude, auto_exclude=auto_exclude + ) + + from .spec import ResourceKind + + runtime_deps = list(RUNTIME_REQUIREMENTS) + if any(h.spec.kind is ResourceKind.API for h in app.resources.values()): + runtime_deps.extend(API_RUNTIME_REQUIREMENTS) + + package_spec = os.environ.get("RUNPOD_PACKAGE_SPEC") + progress = getattr(events, "vendor_progress", None) + + # the runpod package ships without its own dependency closure; the + # worker runtime pulls only runtime_deps. with an override the pinned + # version is installed no-deps; otherwise the client's own package + # tree is overlaid below, so no index install of runpod is needed. + if package_spec: + vendor( + env_dir, + [runtime_requirement(scratch)], + python_version, + no_deps=True, + progress=progress, + ) + + all_requirements = runtime_deps + vendored + log.info( + "vendoring %d packages for python %s (%d excluded: %s)", + len(all_requirements), + python_version, + len(excluded), + ", ".join(excluded) or "none", + ) + vendor(env_dir, all_requirements, python_version, progress=progress) + + if not package_spec: + sync_running_package(env_dir) + _verify_runtime(env_dir) + + return BuildResult( + env_dir=env_dir, + requirements=all_requirements, + excluded=excluded, + ) diff --git a/runpod/apps/context.py b/runpod/apps/context.py new file mode 100644 index 00000000..198df12e --- /dev/null +++ b/runpod/apps/context.py @@ -0,0 +1,86 @@ +"""execution context detection and the sync/async bridge.""" + +import asyncio +import os +import threading +from enum import Enum +from typing import Any, Coroutine + + +class Context(Enum): + """where the current process is running.""" + + LOCAL = "local" + """dev machine, plain `python main.py`.""" + + DEV = "dev" + """inside an `rp dev` session (ephemeral live provisioning).""" + + WORKER = "worker" + """inside a runpod serverless endpoint or pod.""" + + +def current_context() -> Context: + """detect the execution context from the environment.""" + if os.getenv("RUNPOD_ENDPOINT_ID") or os.getenv("RUNPOD_POD_ID"): + return Context.WORKER + if os.getenv("RUNPOD_DEV_SESSION"): + return Context.DEV + return Context.LOCAL + + +def is_local() -> bool: + """true when not running inside a runpod container. + + usable as a module-level guard so code only runs on the dev machine: + + if runpod.is_local(): + print("running or imported locally") + """ + return current_context() is not Context.WORKER + + +class _LoopThread: + """a dedicated background event loop for driving async engine code + from synchronous callers. + + this makes `.remote()` safe to call both from plain sync code and from + inside an already-running event loop (where `asyncio.run` would raise). + """ + + _loop: "asyncio.AbstractEventLoop | None" = None + _lock = threading.Lock() + + @classmethod + def _ensure_loop(cls) -> asyncio.AbstractEventLoop: + with cls._lock: + if cls._loop is None or cls._loop.is_closed(): + loop = asyncio.new_event_loop() + thread = threading.Thread( + target=loop.run_forever, + name="runpod-apps-loop", + daemon=True, + ) + thread.start() + cls._loop = loop + return cls._loop + + @classmethod + def run(cls, coro: Coroutine[Any, Any, Any]) -> Any: + loop = cls._ensure_loop() + future = asyncio.run_coroutine_threadsafe(coro, loop) + try: + while True: + try: + return future.result(timeout=0.2) + except TimeoutError: + if future.done(): + raise + except BaseException: + future.cancel() + raise + + +def block(coro: Coroutine[Any, Any, Any]) -> Any: + """run a coroutine to completion from a synchronous caller.""" + return _LoopThread.run(coro) diff --git a/runpod/apps/datacenter.py b/runpod/apps/datacenter.py new file mode 100644 index 00000000..d98236a9 --- /dev/null +++ b/runpod/apps/datacenter.py @@ -0,0 +1,58 @@ +"""datacenter selection for app resources. + +only datacenters with storage support and S3 API support are listed""" + +from enum import Enum +from typing import List + + +class DataCenter(str, Enum): + + # north america + US_CA_2 = "US-CA-2" + US_IL_1 = "US-IL-1" + US_KS_2 = "US-KS-2" + US_MO_1 = "US-MO-1" + US_MO_2 = "US-MO-2" + US_NC_2 = "US-NC-2" + US_NE_1 = "US-NE-1" + US_WA_1 = "US-WA-1" + + # europe + EU_CZ_1 = "EU-CZ-1" + EU_RO_1 = "EU-RO-1" + EUR_NO_1 = "EUR-NO-1" + + @classmethod + def from_string(cls, value: str) -> "DataCenter": + """parse a datacenter id, accepting lowercase and underscores.""" + normalized = value.strip().upper().replace("_", "-") + + try: + return cls(normalized) + except ValueError: + valid = ", ".join(dc.value for dc in cls) + raise ValueError( + f"unknown datacenter '{value}'. valid datacenters: {valid}" + ) + + @classmethod + def all(cls) -> List["DataCenter"]: + return list(cls) + + +# datacenters with high cpu serverless stock, restricted to the +# storage+S3 set above. cpu5c/cpu5g are only stocked in EU-RO-1. +CPU3_DATACENTERS: List[DataCenter] = [ + DataCenter.EU_CZ_1, + DataCenter.EU_RO_1, + DataCenter.US_CA_2, + DataCenter.US_IL_1, + DataCenter.US_KS_2, + DataCenter.US_MO_1, + DataCenter.US_MO_2, + DataCenter.US_NC_2, + DataCenter.US_NE_1, + DataCenter.US_WA_1, +] +CPU5_DATACENTERS: List[DataCenter] = [DataCenter.EU_RO_1] diff --git a/runpod/apps/deploy.py b/runpod/apps/deploy.py new file mode 100644 index 00000000..461ff254 --- /dev/null +++ b/runpod/apps/deploy.py @@ -0,0 +1,518 @@ +"""deploy pipeline: discovered apps -> manifest -> artifact -> activated build.""" + +import base64 +import fnmatch +import json +import logging +import os +import tarfile +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import runpod + +from .api import AppsApiClient +from .app import App +from .build import ( + DEFAULT_PYTHON_VERSION, + MAX_ARTIFACT_MB, + BuildError, + BuildResult, + build_environment, +) +from .errors import InvalidResourceError, ScheduleNotSupported +from .schedule import SCHEDULES_ENABLED +from .spec import ResourceKind +from .utils.events import emit + +log = logging.getLogger(__name__) + +MANIFEST_VERSION = 1 + +DEFAULT_IGNORES = [ + ".git", + ".venv", + "venv", + "__pycache__", + "*.pyc", + ".runpod", + ".flash", + "node_modules", + ".DS_Store", + "*.tar.gz", +] + + +def _module_path_for(fn, project_root: Path) -> str: + """dotted import path for fn's file relative to the project root.""" + import inspect + + module = getattr(fn, "__module__", "") or "" + try: + source = Path(inspect.getfile(fn)).resolve() + rel = source.relative_to(project_root.resolve()) + except (TypeError, ValueError, OSError): + return module + parts = list(rel.with_suffix("").parts) + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) or module + + +@dataclass +class DeployResult: + app_name: str + build_id: str + environment_id: str + resources: List[str] + endpoints: Dict[str, str] = field(default_factory=dict) + + +def build_manifest( + app: App, + project_root: Path, + *, + python_version: str = DEFAULT_PYTHON_VERSION, + excluded_packages: Optional[List[str]] = None, +) -> Dict[str, Any]: + """serialize an app's resources for the coordinator and workers.""" + resources = [] + + for handle in app.resources.values(): + spec = handle.spec + if spec.schedule and not SCHEDULES_ENABLED: + raise ScheduleNotSupported() + + if spec.kind in (ResourceKind.QUEUE, ResourceKind.API) and len(spec.name) < 3: + raise InvalidResourceError( + f"resource name '{spec.name}' is too short to deploy; " + f"queue and api names must be at least 3 characters" + ) + + entry = spec.to_manifest() + + fn = ( + getattr(handle, "_fn", None) + or getattr(handle, "_cls", None) + or getattr(handle, "__wrapped__", None) + ) + + if fn is not None: + entry["module"] = _module_path_for(fn, project_root) + entry["qualname"] = getattr(fn, "__qualname__", fn.__name__) + resources.append(entry) + + manifest: Dict[str, Any] = { + "version": MANIFEST_VERSION, + "app": app.name, + "pythonVersion": python_version, + "resources": resources, + } + + if excluded_packages: + manifest["excludedPackages"] = excluded_packages + + return manifest + + +def _load_ignores(project_root: Path) -> List[str]: + patterns = list(DEFAULT_IGNORES) + ignore_file = project_root / ".runpodignore" + if ignore_file.exists(): + for line in ignore_file.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#"): + patterns.append(line) + return patterns + + +def _is_ignored(rel_path: str, patterns: List[str]) -> bool: + parts = rel_path.split("/") + for pattern in patterns: + if fnmatch.fnmatch(rel_path, pattern): + return True + if any(fnmatch.fnmatch(part, pattern) for part in parts): + return True + return False + + +ENV_DIR_NAME = "env" + + +def package_project( + project_root: Path, + manifest: Dict[str, Any], + output: Optional[Path] = None, + env_dir: Optional[Path] = None, +) -> Path: + """tar source + vendored env + manifest into a build artifact. + + layout inside the tarball: + {source files} project code, .runpodignore honored + env/ vendored site-packages tree + runpod_manifest.json + """ + if output is None: + output = Path(tempfile.mkdtemp()) / "artifact.tar.gz" + + patterns = _load_ignores(project_root) + env_resolved = env_dir.resolve() if env_dir is not None else None + + with tarfile.open(output, "w:gz") as tar: + for path in sorted(project_root.rglob("*")): + if not path.is_file(): + continue + if env_resolved is not None and env_resolved in path.resolve().parents: + continue + rel = path.relative_to(project_root).as_posix() + if _is_ignored(rel, patterns): + continue + if rel == ENV_DIR_NAME or rel.startswith(f"{ENV_DIR_NAME}/"): + continue + tar.add(path, arcname=rel) + + if env_dir is not None and env_dir.is_dir(): + for path in sorted(env_dir.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(env_dir).as_posix() + tar.add(path, arcname=f"{ENV_DIR_NAME}/{rel}") + + manifest_bytes = json.dumps(manifest, indent=2).encode() + info = tarfile.TarInfo(name="runpod_manifest.json") + info.size = len(manifest_bytes) + import io + + tar.addfile(info, io.BytesIO(manifest_bytes)) + + return output + + +# gpu images have some hefty dependencies pre-installed +CPU_CONTAINER_DISK_GB = 10 +GPU_CONTAINER_DISK_GB = 30 + + +def _container_disk_gb(spec) -> int: + if spec.container_disk_gb: + return spec.container_disk_gb + return CPU_CONTAINER_DISK_GB if spec.is_cpu else GPU_CONTAINER_DISK_GB + + +def _deployed_endpoint_input( + app: App, + spec, + environment_id: str, + build_id: str, + python_version: str, +) -> Dict[str, Any]: + """saveEndpoint payload for one deployed queue/api resource. + + the endpoint name must equal the resource name: sentinel + resolution matches X-Flash-Endpoint against endpoint.name within + the environment. binding via flashEnvironmentId is also what makes + hosts deliver the build artifact to this endpoint's workers. + """ + from .datacenter import CPU3_DATACENTERS, CPU5_DATACENTERS, DataCenter + from .images import image_for_spec + + from .secret import render_env + + template_env = { + "FLASH_RESOURCE_NAME": spec.name, + # version-triggering: a new build recreates all workers + "RUNPOD_BUILD_ID": build_id, + **render_env(spec.env), + } + + # cross-resource calls from inside a worker go through the sentinel, + # which authenticates with the account api key; without it any + # .remote() from worker code would fail + api_key = runpod.api_key + if api_key and "RUNPOD_API_KEY" not in template_env: + template_env["RUNPOD_API_KEY"] = api_key + + # workers that spawn tasks must select runtime images from the same + # channel the deploy used (the vendored env supplies the code, so + # only the image channel needs to travel) + tag = os.environ.get("RUNPOD_RUNTIME_TAG") + if tag and "RUNPOD_RUNTIME_TAG" not in template_env: + template_env["RUNPOD_RUNTIME_TAG"] = tag + + if spec.max_concurrency > 1: + template_env["RUNPOD_MAX_CONCURRENCY"] = str(spec.max_concurrency) + + payload: Dict[str, Any] = { + "name": spec.name, + "flashEnvironmentId": environment_id, + "workersMin": spec.workers[0], + "workersMax": spec.workers[1], + "idleTimeout": spec.idle_timeout, + "scalerType": spec.effective_scaler_type, + "scalerValue": spec.scaler_value, + "executionTimeoutMs": spec.execution_timeout_ms, + "template": { + "name": f"{app.name}-{spec.name}-template", + "imageName": image_for_spec(spec, python_version=python_version), + "containerDiskInGb": _container_disk_gb(spec), + "dockerArgs": "", + "env": [{"key": k, "value": v} for k, v in template_env.items()], + }, + } + + if spec.flashboot: + payload["flashBootType"] = "FLASHBOOT" + + if spec.image: + # custom image: inject the bootstrap; the vendored env in the + # artifact provides the runtime once the bootstrap unpacks it + from .dev import _bootstrap_docker_args, _bootstrap_source + + payload["template"]["env"].extend( + [ + { + "key": "RUNPOD_BOOTSTRAP_B64", + "value": base64.b64encode(_bootstrap_source().encode()).decode(), + }, + {"key": "RUNPOD_RUNTIME_KIND", "value": spec.kind.value}, + ] + ) + payload["template"]["dockerArgs"] = _bootstrap_docker_args() + + if spec.kind is ResourceKind.API: + payload["type"] = "LB" + if spec.datacenter: + payload["locations"] = ",".join(spec.datacenter) + if spec.is_cpu: + payload["instanceIds"] = spec.cpu + if not spec.datacenter: + if any(i.startswith("cpu5") for i in spec.cpu or []): + payload["locations"] = ",".join(dc.value for dc in CPU5_DATACENTERS) + else: + payload["locations"] = ",".join(dc.value for dc in CPU3_DATACENTERS) + else: + from .gpu import gpu_ids_value + + payload["gpuIds"] = gpu_ids_value(spec.gpu) + payload["gpuCount"] = spec.gpu_count + if spec.min_cuda_version: + payload["minCudaVersion"] = spec.min_cuda_version + if not spec.datacenter: + # artifact delivery rides the flash volume (network + # storage); machines outside storage-cluster regions fail + # with "requires network storage" and recycle forever + payload["locations"] = ",".join(dc.value for dc in DataCenter) + return payload + + +async def reconcile_endpoints( + client: AppsApiClient, + app: App, + environment: Dict[str, Any], + build_id: str, + python_version: str, +) -> Dict[str, str]: + """converge the environment's endpoints to the app's resources. + + resource name -> endpoint id for everything provisioned. existing + endpoints (matched by name) are updated in place; endpoints whose + resources disappeared are deleted. + """ + existing = {e["name"]: e["id"] for e in environment.get("endpoints") or []} + provisionable = { + h.spec.name: h + for h in app.resources.values() + if h.spec.kind in (ResourceKind.QUEUE, ResourceKind.API) + } + + from .registry import resolve_registry_auth + from .volume import VolumeResolver + + resolver = VolumeResolver(client) + endpoints: Dict[str, str] = {} + for name, handle in sorted(provisionable.items()): + payload = _deployed_endpoint_input( + app, handle.spec, environment["id"], build_id, python_version + ) + await attach_endpoint_volumes(payload, handle.spec, resolver, app) + auth_id = await resolve_registry_auth(handle.spec.registry_auth, api=client) + if auth_id: + payload["template"]["containerRegistryAuthId"] = auth_id + if handle.spec.model: + from .model import model_reference + + payload["modelReferences"] = [model_reference(handle.spec.model)] + if name in existing: + payload["id"] = existing[name] + result = await client.save_endpoint(payload) + endpoints[name] = result["id"] + log.info( + "%s endpoint %s (%s)", + "updated" if name in existing else "provisioned", + name, + result["id"], + ) + + for name, endpoint_id in existing.items(): + if name not in provisionable: + await client.delete_endpoint(endpoint_id) + log.info("deleted removed endpoint %s (%s)", name, endpoint_id) + + return endpoints + + +async def attach_endpoint_volumes(payload: Dict[str, Any], spec, resolver, app) -> None: + """resolve a resource's volumes onto an endpoint payload. + + endpoints may span regions: one volume per datacenter, locations + derived from the resolved volumes so lists cannot disagree. + """ + from .volume import specs_sharing_volume, volume_list + + volumes = volume_list(spec.volume) + if not volumes: + return + resolved = [] + for volume in volumes: + sharing = specs_sharing_volume([app], volume.name) or [spec] + resolved.append(await resolver.resolve(volume, sharing)) + payload["networkVolumeIds"] = [{"networkVolumeId": r["id"]} for r in resolved] + payload["locations"] = ",".join(dict.fromkeys(r["dataCenterId"] for r in resolved)) + + +def _phase(events, name: str, detail: str = "") -> None: + emit(events, "phase", name, detail) + + +def build_artifact( + app: App, + project_root: Path, + *, + python_version: str = DEFAULT_PYTHON_VERSION, + exclude: Optional[List[str]] = None, + events: Optional[object] = None, + output: Optional[Path] = None, +) -> Path: + """vendor, build the manifest, and package one app's artifact.""" + _phase(events, "vendor", f"python {python_version}") + build: BuildResult = build_environment( + app, + project_root, + python_version=python_version, + exclude=exclude, + events=events, + ) + manifest = build_manifest( + app, + project_root, + python_version=python_version, + excluded_packages=build.excluded, + ) + _phase(events, "package") + tar_path = package_project( + project_root, manifest, output=output, env_dir=build.env_dir + ) + size_mb = tar_path.stat().st_size / (1024 * 1024) + if size_mb > MAX_ARTIFACT_MB: + raise BuildError( + f"artifact is {size_mb:.0f} MB (limit {MAX_ARTIFACT_MB} MB). " + f"exclude large packages with --exclude (they must then come " + f"from the worker image) or trim project files via .runpodignore" + ) + log.info("packaged %s (%.1f MB)", app.name, size_mb) + return tar_path + + +async def deploy_app( + app: App, + project_root: Path, + *, + env_name: Optional[str] = None, + api: Optional[AppsApiClient] = None, + python_version: str = DEFAULT_PYTHON_VERSION, + exclude: Optional[List[str]] = None, + events: Optional[object] = None, +) -> DeployResult: + """run the full deploy pipeline for one app.""" + client = api or AppsApiClient() + env_name = env_name or app.env + + # fail fast on unresolvable secret references (workers would boot + # with the literal template string otherwise) + from .secret import secret_names, validate_secrets + + referenced = [ + name + for handle in app.resources.values() + for name in secret_names(handle.spec.env) + ] + await validate_secrets(referenced, api=client) + + _phase(events, "vendor", f"python {python_version}") + build: BuildResult = build_environment( + app, + project_root, + python_version=python_version, + exclude=exclude, + events=events, + ) + manifest = build_manifest( + app, + project_root, + python_version=python_version, + excluded_packages=build.excluded, + ) + _phase(events, "package") + tar_path = package_project(project_root, manifest, env_dir=build.env_dir) + tar_size = tar_path.stat().st_size + size_mb = tar_size / (1024 * 1024) + if size_mb > MAX_ARTIFACT_MB: + raise BuildError( + f"artifact is {size_mb:.0f} MB (limit {MAX_ARTIFACT_MB} MB). " + f"exclude large packages with --exclude (they must then come " + f"from the worker image) or trim project files via .runpodignore" + ) + log.info("packaged %s (%.1f MB)", app.name, size_mb) + + _phase(events, "upload", f"{size_mb:.1f} MB") + upload_progress = getattr(events, "upload_progress", None) + remote_app = await client.get_app_by_name(app.name) + if remote_app is None: + remote_app = await client.create_app(app.name) + log.info("created app %s (%s)", app.name, remote_app["id"]) + app_id = remote_app["id"] + + environments = {e["name"]: e for e in remote_app.get("flashEnvironments") or []} + environment = environments.get(env_name) + if environment is None: + environment = await client.create_environment(app_id, env_name) + log.info("created environment %s (%s)", env_name, environment["id"]) + + upload = await client.prepare_artifact_upload(app_id, tar_size) + await client.upload_tarball( + upload["uploadUrl"], str(tar_path), progress=upload_progress + ) + build = await client.finalize_artifact_upload(app_id, upload["objectKey"], manifest) + log.info("uploaded build %s", build["id"]) + + await client.deploy_build(environment["id"], build["id"]) + log.info("activated build %s on %s/%s", build["id"], app.name, env_name) + + _phase(events, "endpoints") + endpoints = await reconcile_endpoints( + client, app, environment, build["id"], python_version + ) + endpoint_ready = getattr(events, "endpoint_ready", None) + if endpoint_ready is not None: + for name, endpoint_id in sorted(endpoints.items()): + endpoint_ready(name, endpoint_id) + + return DeployResult( + app_name=app.name, + build_id=build["id"], + environment_id=environment["id"], + resources=[r["name"] for r in manifest["resources"]], + endpoints=endpoints, + ) diff --git a/runpod/apps/dev.py b/runpod/apps/dev.py new file mode 100644 index 00000000..c1480c80 --- /dev/null +++ b/runpod/apps/dev.py @@ -0,0 +1,455 @@ +"""dev session: ephemeral live endpoints for `rp dev`. + +lifecycle: get-or-create endpoints named dev-{app}-{resource} on the +generic worker images (adopting leftovers from a killed session), run +the local entrypoint, and delete every session endpoint on exit. the +api is the only source of truth; nothing is persisted locally. +""" + +import base64 +import logging +from pathlib import Path +from typing import Dict, List, Optional, Union + +from .api import AppsApiClient +from .app import App +from .handles import ApiHandle, FunctionHandle +from .utils.events import emit +from .spec import ResourceKind, ResourceSpec +from .targets import LiveTarget + +log = logging.getLogger(__name__) + +DEV_PREFIX = "dev" + +import os as _os + + +def dev_endpoint_name(app_name: str, resource_name: str) -> str: + """unique endpoint name for a dev (app, resource) pair. + + dash-joined names alone are ambiguous (("a-b","c") vs ("a","b-c")), + so a short digest of the exact pair disambiguates. + """ + import hashlib + + digest = hashlib.sha256( + f"{app_name}/{resource_name}".encode() + ).hexdigest()[:6] + return f"{DEV_PREFIX}-{app_name}-{resource_name}-{digest}" + + +def _resource_of(endpoint_name: str) -> str: + """resource name from a dev endpoint name (display only).""" + return endpoint_name.rsplit("-", 2)[-2] + + +def _comparable(payload: Dict) -> Dict: + """payload normalized for change detection. + + the generation env is bumped on every refresh by design; strip it so + only real configuration differences count. + """ + import copy + + clean = copy.deepcopy(payload) + clean.pop("id", None) + template = clean.get("template") or {} + template["env"] = [ + e for e in template.get("env") or [] if e.get("key") != GENERATION_ENV + ] + return clean + + +def _changed_fields(old: Dict, new: Dict) -> List[str]: + """human-readable names of top-level payload fields that differ.""" + fields = [] + for key in sorted(set(old) | set(new)): + if old.get(key) == new.get(key): + continue + if key == "template": + t_old, t_new = old.get(key) or {}, new.get(key) or {} + for sub in sorted(set(t_old) | set(t_new)): + if t_old.get(sub) != t_new.get(sub): + fields.append("image" if sub == "imageName" else sub) + else: + fields.append(key) + return fields + + +def _image_for(spec: ResourceSpec) -> str: + """dev worker image: custom image, env override, or the builtin + runtime image matched to the local python (dev requests carry + pickled source, so client and worker versions should align).""" + + if spec.image: + return spec.image + + override = _os.getenv("RUNPOD_DEV_IMAGE") + if override: + return override + + from .images import image_for_spec, local_python_version + + return image_for_spec(spec, python_version=local_python_version()) + + +def _bootstrap_source() -> str: + return (Path(__file__).parent.parent / "runtimes" / "bootstrap.py").read_text() + + +def _bootstrap_docker_args() -> str: + """shell command that materializes and starts the bootstrap on a + custom image.""" + from .shim import shell_launcher + + return shell_launcher("RUNPOD_BOOTSTRAP_B64", "/bootstrap.py") + + +def _render_env(env): + from .secret import render_env + + return render_env(env) + + +def _client_api_key() -> str: + from .targets import _api_key + + try: + return _api_key() + except RuntimeError: + # payload construction must not require credentials (tests, + # dry runs); the session itself fails loudly on its first call + return "" + + +def _cpu_locations(instance_ids: List[str]) -> str: + """locations covering every requested cpu flavor's stock, limited to + datacenters with storage and S3 support. the flash images are + autocached on every host (host imagecache daemon, no DC filter), so + the widest supported spread maximizes provisioning odds.""" + from .datacenter import CPU3_DATACENTERS, CPU5_DATACENTERS + + if any(i.startswith("cpu5") for i in instance_ids): + return ",".join(dc.value for dc in CPU5_DATACENTERS) + return ",".join(dc.value for dc in CPU3_DATACENTERS) + + +# template env var bumped on refresh; env is a version-triggering template +# property, so changing it recreates all workers server-side +GENERATION_ENV = "RUNPOD_DEV_GENERATION" + + +def _endpoint_input(app: App, spec: ResourceSpec, generation: int = 1) -> Dict: + """build the saveEndpoint payload for a live dev endpoint. + + the template is nested in the saveEndpoint input so it is bound to + the endpoint and cascades on deleteEndpoint. + """ + + payload: Dict = { + "name": dev_endpoint_name(app.name, spec.name), + "workersMin": spec.workers[0], + "workersMax": spec.workers[1], + "idleTimeout": spec.idle_timeout, + "scalerType": spec.effective_scaler_type, + "scalerValue": spec.scaler_value, + "executionTimeoutMs": spec.execution_timeout_ms, + "template": { + "name": f"{dev_endpoint_name(app.name, spec.name)}-template", + "imageName": _image_for(spec), + "containerDiskInGb": spec.container_disk_gb or (10 if spec.is_cpu else 30), + "dockerArgs": "", + "env": [ + {"key": GENERATION_ENV, "value": str(generation)}, + # nested .remote() calls from inside a dev worker need + # credentials and the dev-session marker to resolve + # sibling dev endpoints by name + *( + [{"key": "RUNPOD_API_KEY", "value": key}] + if (key := _client_api_key()) + else [] + ), + {"key": "RUNPOD_DEV_APP", "value": app.name}, + *( + [ + { + "key": "RUNPOD_MAX_CONCURRENCY", + "value": str(spec.max_concurrency), + } + ] + if spec.max_concurrency > 1 + else [] + ), + *({"key": k, "value": v} for k, v in _render_env(spec.env).items()), + ], + }, + } + + if spec.flashboot: + payload["flashBootType"] = "FLASHBOOT" + + if spec.image: + # custom image: inject the bootstrap so the worker runtime starts + # regardless of what the image contains + payload["template"]["env"].extend( + [ + { + "key": "RUNPOD_BOOTSTRAP_B64", + "value": base64.b64encode(_bootstrap_source().encode()).decode(), + }, + {"key": "RUNPOD_RUNTIME_KIND", "value": spec.kind.value}, + ] + ) + # the bootstrap pip-installs the runpod package on bare images; + # forward a pinned spec (e.g. a git branch during prerelease) + package_spec = _os.environ.get("RUNPOD_PACKAGE_SPEC") + if package_spec: + payload["template"]["env"].append( + {"key": "RUNPOD_PACKAGE_SPEC", "value": package_spec} + ) + payload["template"]["dockerArgs"] = _bootstrap_docker_args() + if spec.kind is ResourceKind.API: + payload["type"] = "LB" + if spec.datacenter: + payload["locations"] = ",".join(spec.datacenter) + if spec.is_cpu: + payload["instanceIds"] = spec.cpu + if not spec.datacenter: + payload["locations"] = _cpu_locations(spec.cpu or []) + else: + from .gpu import gpu_ids_value + + payload["gpuIds"] = gpu_ids_value(spec.gpu) + payload["gpuCount"] = spec.gpu_count + if spec.min_cuda_version: + payload["minCudaVersion"] = spec.min_cuda_version + return payload + + +class DevSession: + """owns the live endpoints for one `rp dev` invocation. + + lifecycle: adopt-or-create by name on start, reconcile on refresh + (config updates + a generation bump that recreates workers so every + request after a code change runs fully fresh), delete on stop. + + `events`, when provided, receives lifecycle callbacks: + provisioning(name, kind, hardware), adopted(name, id), + ready(name, id), refreshed(name, generation), deleted(name). + """ + + def __init__( + self, + apps: List[App], + api: Optional[AppsApiClient] = None, + events: Optional[object] = None, + ): + self.apps = apps + self.api = api or AppsApiClient() + self.generation = 1 + self.events = events + # endpoint name -> id for everything this session owns + self._endpoints: Dict[str, str] = {} + # endpoint name -> comparable payload, for refresh diffing + self._payloads: Dict[str, Dict] = {} + # volumes resolve once per session (placement is stable) + self._volume_resolver = None + + def _emit(self, event: str, *args) -> None: + emit(self.events, event, *args) + + async def _attach_volumes(self, payload: Dict, spec, app) -> None: + """resolve the resource's volumes and registry auth onto a + dev endpoint payload.""" + from .deploy import attach_endpoint_volumes + from .registry import resolve_registry_auth + from .volume import VolumeResolver + + if spec.registry_auth: + auth_id = await resolve_registry_auth(spec.registry_auth, api=self.api) + payload["template"]["containerRegistryAuthId"] = auth_id + if spec.model: + from .model import model_reference + + payload["modelReferences"] = [model_reference(spec.model)] + if not spec.volume: + return + if self._volume_resolver is None: + self._volume_resolver = VolumeResolver(self.api, events=self.events) + await attach_endpoint_volumes(payload, spec, self._volume_resolver, app) + + @property + def _endpoint_ids(self) -> List[str]: + return list(self._endpoints.values()) + + def _provisionable(self, app: App) -> List[Union[FunctionHandle, ApiHandle]]: + """endpoints only; tasks have no standing infra to manage.""" + return [ + h + for h in app.resources.values() + if h.spec.kind in (ResourceKind.QUEUE, ResourceKind.API) + ] + + async def start(self) -> None: + """provision (or adopt) a live endpoint per queue/api resource and + register the targets on each app.""" + self._emit("session_starting") + from .secret import secret_names, validate_secrets + + referenced = [ + name + for app in self.apps + for handle in app.resources.values() + for name in secret_names(handle.spec.env) + ] + await validate_secrets(referenced, api=self.api) + for app in self.apps: + # task targets read the sink off the app at resolve time + app._dev_events = self.events + existing = {e["name"]: e for e in await self.api.list_my_endpoints()} + + for app in self.apps: + for handle in self._provisionable(app): + spec = handle.spec + name = dev_endpoint_name(app.name, spec.name) + payload = _endpoint_input(app, spec, self.generation) + await self._attach_volumes(payload, spec, app) + + hardware = ",".join(spec.cpu or spec.gpu or ["any"]) + found = existing.get(name) + if found: + # adopt: reconcile the leftover endpoint to the + # current spec instead of creating a duplicate + self._emit("adopted", spec.name, found["id"]) + payload["id"] = found["id"] + result = await self.api.save_endpoint(payload) + endpoint_id = result["id"] + log.info("adopted dev endpoint %s (%s)", name, endpoint_id) + else: + self._emit("provisioning", spec.name, spec.kind.value, hardware) + result = await self.api.save_endpoint(payload) + endpoint_id = result["id"] + log.info("provisioned dev endpoint %s (%s)", name, endpoint_id) + + self._emit("ready", spec.name, endpoint_id) + self._endpoints[name] = endpoint_id + self._payloads[name] = _comparable(payload) + app._dev_targets[spec.name] = LiveTarget( + endpoint_id, + spec.name, + events=self.events, + metrics_key=result.get("aiKey"), + ) + + self._emit("session_started") + + async def refresh(self, apps: List[App]) -> None: + """reconcile endpoints against a re-scanned set of apps. + + every surviving endpoint gets the new config plus a bumped + generation env var; env is a version-triggering template + property, so the platform recreates all workers and subsequent + requests execute in fresh processes. added resources are + provisioned, removed ones deleted.""" + + self.generation += 1 + self.apps = apps + for app in apps: + # re-scanned apps are fresh instances; re-attach the sink + # so task lifecycle events keep rendering after reloads + app._dev_events = self.events + + desired: Dict[str, tuple] = {} + for app in apps: + for handle in self._provisionable(app): + name = dev_endpoint_name(app.name, handle.spec.name) + desired[name] = (app, handle) + + # delete endpoints whose resources disappeared + for name in list(self._endpoints): + if name not in desired: + endpoint_id = self._endpoints.pop(name) + self._payloads.pop(name, None) + try: + await self.api.delete_endpoint(endpoint_id) + self._emit("resource_removed", _resource_of(name)) + log.info("deleted removed dev endpoint %s", name) + except Exception as exc: + log.warning("failed to delete %s: %s", name, exc) + + # update survivors (config + generation bump) and create additions + for name, (app, handle) in desired.items(): + payload = _endpoint_input(app, handle.spec, self.generation) + await self._attach_volumes(payload, handle.spec, app) + + existing_id = self._endpoints.get(name) + comparable = _comparable(payload) + previous = self._payloads.get(name) + + if existing_id: + payload["id"] = existing_id + + result = await self.api.save_endpoint(payload) + endpoint_id = result["id"] + + self._endpoints[name] = endpoint_id + self._payloads[name] = comparable + app._dev_targets[handle.spec.name] = LiveTarget( + endpoint_id, + handle.spec.name, + events=self.events, + metrics_key=result.get("aiKey"), + ) + + spec = handle.spec + hardware = ",".join(spec.cpu or spec.gpu or ["any"]) + + if previous is None: + self._emit("resource_added", spec.name, spec.kind.value, hardware) + elif previous != comparable: + self._emit( + "resource_changed", + spec.name, + _changed_fields(previous, comparable), + ) + + log.info( + "refreshed dev endpoint %s (%s, generation %d)", + name, + endpoint_id, + self.generation, + ) + + async def stop(self, events: Optional[object] = None) -> None: + """delete every endpoint this session owns. + + events, when given, overrides the session sink for teardown + rendering: cleanup_started(total), deleting(name), deleted(name), + delete_failed(name). + """ + sink = events if events is not None else self.events + + pending = list(self._endpoints.items()) + emit(sink, "cleanup_started", len(pending)) + for name, endpoint_id in pending: + resource = _resource_of(name) + emit(sink, "deleting", resource) + try: + await self.api.delete_endpoint(endpoint_id) + emit(sink, "deleted", resource) + log.info("deleted dev endpoint %s (%s)", name, endpoint_id) + except Exception as exc: + emit(sink, "delete_failed", resource) + log.warning("failed to delete dev endpoint %s: %s", endpoint_id, exc) + self._endpoints.clear() + for app in self.apps: + app._dev_targets.clear() + app._dev_events = None + + async def __aenter__(self) -> "DevSession": + await self.start() + return self + + async def __aexit__(self, *exc_info) -> None: + await self.stop() diff --git a/runpod/apps/discovery.py b/runpod/apps/discovery.py new file mode 100644 index 00000000..4179b6ff --- /dev/null +++ b/runpod/apps/discovery.py @@ -0,0 +1,163 @@ +"""module discovery for `rp deploy` and `rp dev`. + +imports target modules under __name__ != "__main__" (so main guards +never run) and collects App instances from the registry. a per-module +import timeout guards against module-level code that blocks. +""" + +import importlib.util +import logging +import os +import sys +import threading +from pathlib import Path +from typing import List + +from .app import App, get_registered_apps + +log = logging.getLogger(__name__) + +IMPORT_TIMEOUT_SECONDS = 30 + +# set while discovery imports modules; handle invocation checks it so +# module-level .remote()/.spawn() calls fail fast with a precise +# diagnosis instead of attempting network calls mid-scan +DISCOVERY_ENV = "RUNPOD_DISCOVERY_SCAN" + + +def in_discovery() -> bool: + return bool(os.environ.get(DISCOVERY_ENV)) + + +class DiscoveryInvocationError(Exception): + """a handle was invoked at module level during a discovery scan.""" + + def __init__(self, resource_name: str): + super().__init__( + f"'{resource_name}' was invoked at import time. move calls " + f"into a function, an entrypoint, or an " + f'`if __name__ == "__main__":` guard so importing the file ' + f"has no side effects." + ) + + +_SKIP_DIRS = { + ".git", + ".venv", + "venv", + "node_modules", + "__pycache__", + ".runpod", + ".flash", + "build", + "dist", +} + + +class DiscoveryError(Exception): + """a module failed to import during discovery.""" + + +def _python_files(target: Path) -> List[Path]: + if target.is_file(): + if target.suffix != ".py": + raise DiscoveryError(f"{target} is not a python file") + return [target] + files = [] + for path in sorted(target.rglob("*.py")): + if any(part in _SKIP_DIRS for part in path.parts): + continue + files.append(path) + return files + + +def _import_module(path: Path) -> None: + """import one file as a uniquely-named module, never as __main__.""" + module_name = f"_runpod_discovered_{path.stem}_{abs(hash(str(path)))}" + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise DiscoveryError(f"cannot load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + + error: List[BaseException] = [] + + def run() -> None: + try: + spec.loader.exec_module(module) + # BaseException on purpose: user module top-level code can raise + # SystemExit/KeyboardInterrupt; those must surface as discovery + # errors on the caller thread, not kill this worker thread + except BaseException as exc: # noqa: BLE001 - reported to caller + error.append(exc) + + thread = threading.Thread(target=run, daemon=True) + thread.start() + thread.join(IMPORT_TIMEOUT_SECONDS) + + if thread.is_alive(): + raise DiscoveryError( + f"importing {path} timed out after {IMPORT_TIMEOUT_SECONDS}s; " + f"module-level code must not block (guard it with " + f'`if __name__ == "__main__":` or runpod.is_local())' + ) + if error: + raise DiscoveryError(f"importing {path} failed: {error[0]}") from error[0] + + +def discover_apps(target: Path) -> List[App]: + """import python files under target and return the apps they define. + + a single-file target imports strictly and raises on any failure. + a directory walk is tolerant: files that fail to import are + collected as warnings, and discovery only fails outright when no + app was found anywhere (the failures are then the likely cause and + are included in the error). + """ + before = set(id(a) for a in get_registered_apps()) + + sys_path_added = False + root = target if target.is_dir() else target.parent + root_str = str(root.resolve()) + if root_str not in sys.path: + sys.path.insert(0, root_str) + sys_path_added = True + + strict = target.is_file() + failures: List[str] = [] + os.environ[DISCOVERY_ENV] = "1" + try: + for path in _python_files(target): + seen = set(id(a) for a in get_registered_apps()) + try: + _import_module(path) + except DiscoveryError as exc: + if strict: + raise + failures.append(str(exc)) + log.warning("%s", exc) + continue + # stamp fresh apps with their defining file so callers can + # report where each app came from + for app in get_registered_apps(): + if id(app) not in seen and not hasattr(app, "_source_file"): + app._source_file = path + finally: + os.environ.pop(DISCOVERY_ENV, None) + if sys_path_added: + sys.path.remove(root_str) + + found = [a for a in get_registered_apps() if id(a) not in before] + + # dedupe + by_name = {} + for app in found: + by_name.setdefault(app.name, app) + found = list(by_name.values()) + + if not found and failures: + raise DiscoveryError( + "no runpod.App found; some files failed to import:\n " + + "\n ".join(failures) + ) + return found diff --git a/runpod/apps/entrypoint.py b/runpod/apps/entrypoint.py new file mode 100644 index 00000000..e920308c --- /dev/null +++ b/runpod/apps/entrypoint.py @@ -0,0 +1,43 @@ +"""@runpod.local_entrypoint: the dev-session entry hook. + + @runpod.local_entrypoint + async def main(): + result = transcribe.remote("https://...") + +the decorated function is registered so `rp dev ` can find and +run it (sync or async). importing the module never executes it. +""" + +import inspect +from typing import Any, Callable, List, Optional + +_ENTRYPOINTS: List[Callable] = [] + + +def local_entrypoint(fn: Callable) -> Callable: + """register a function as the dev-session entrypoint. + + the function is returned unwrapped so it stays directly callable. + """ + _ENTRYPOINTS.append(fn) + return fn + + +def get_entrypoint() -> Optional[Callable]: + """the most recently registered entrypoint, if any.""" + return _ENTRYPOINTS[-1] if _ENTRYPOINTS else None + + +def run_entrypoint(fn: Callable) -> Any: + """execute an entrypoint, driving the loop for async functions.""" + from .context import block + + result = fn() + if inspect.isawaitable(result): + return block(result) + return result + + +def _clear_entrypoints() -> None: + """testing only.""" + _ENTRYPOINTS.clear() diff --git a/runpod/apps/errors.py b/runpod/apps/errors.py new file mode 100644 index 00000000..acd3dd0a --- /dev/null +++ b/runpod/apps/errors.py @@ -0,0 +1,35 @@ +"""errors raised by the apps surface.""" + + +class AppError(Exception): + """base error for the apps sdk.""" + + +class EndpointNotFound(AppError): + """a resource was invoked but is not deployed.""" + + def __init__(self, app_name: str, resource_name: str): + self.app_name = app_name + self.resource_name = resource_name + super().__init__( + f"'{resource_name}' in app '{app_name}' is not deployed. " + f"run `rp deploy` first, or use .local() to run it here." + ) + + +class RemoteExecutionError(AppError): + """the remote worker reported a failure executing the function.""" + + +class ScheduleNotSupported(AppError): + """@schedule requires backend support that is not yet available.""" + + def __init__(self) -> None: + super().__init__( + "recurring schedules are not yet supported by the runpod api. " + "the @schedule decorator records intent but cannot be deployed." + ) + + +class InvalidResourceError(AppError): + """a decorator was applied to an unsupported target or with bad config.""" diff --git a/runpod/apps/gpu.py b/runpod/apps/gpu.py new file mode 100644 index 00000000..21a261fb --- /dev/null +++ b/runpod/apps/gpu.py @@ -0,0 +1,225 @@ +"""gpu and cpu selection enums. + +GpuGroup values are backend pool ids; GpuType values are exact device +display names. either (or plain strings) are accepted anywhere a gpu is +configured. CpuInstanceType values are backend cpu flavor ids. +""" + +from enum import Enum +from typing import List, Optional, Union + + +class GpuGroup(Enum): + ANY = "any" + """any gpu""" + + ADA_24 = "ADA_24" + """NVIDIA GeForce RTX 4090""" + + ADA_32_PRO = "ADA_32_PRO" + """NVIDIA GeForce RTX 5090""" + + ADA_48_PRO = "ADA_48_PRO" + """NVIDIA RTX 6000 Ada Generation, NVIDIA L40, NVIDIA L40S""" + + ADA_80_PRO = "ADA_80_PRO" + """NVIDIA H100 PCIe, NVIDIA H100 80GB HBM3, NVIDIA H100 NVL""" + + AMPERE_16 = "AMPERE_16" + """NVIDIA RTX A4000, NVIDIA RTX A4500, NVIDIA RTX 4000 Ada Generation, NVIDIA RTX 2000 Ada Generation""" + + AMPERE_24 = "AMPERE_24" + """NVIDIA RTX A5000, NVIDIA L4, NVIDIA GeForce RTX 3090""" + + AMPERE_48 = "AMPERE_48" + """NVIDIA A40, NVIDIA RTX A6000""" + + AMPERE_80 = "AMPERE_80" + """NVIDIA A100 80GB PCIe, NVIDIA A100-SXM4-80GB""" + + HOPPER_141 = "HOPPER_141" + """NVIDIA H200""" + + BLACKWELL_96 = "BLACKWELL_96" + """NVIDIA RTX PRO 6000 Blackwell Server/Workstation/Max-Q editions""" + + BLACKWELL_180 = "BLACKWELL_180" + """NVIDIA B200""" + + @classmethod + def all(cls) -> List["GpuGroup"]: + """all gpu groups except ANY.""" + return [g for g in cls if g is not cls.ANY] + + def device_names(self) -> List[str]: + """exact device display names in this pool. + + serverless endpoints take pool ids directly (gpuIds), but pods + take device names (gpuTypeIdList), so tasks need the expansion. + """ + return [t.value for t in POOLS_TO_TYPES.get(self, [])] + + +class GpuType(Enum): + ANY = "any" + """any gpu""" + + NVIDIA_GEFORCE_RTX_4090 = "NVIDIA GeForce RTX 4090" + NVIDIA_GEFORCE_RTX_5090 = "NVIDIA GeForce RTX 5090" + NVIDIA_RTX_6000_ADA_GENERATION = "NVIDIA RTX 6000 Ada Generation" + NVIDIA_RTX_PRO_6000_BLACKWELL_SERVER_EDITION = ( + "NVIDIA RTX PRO 6000 Blackwell Server Edition" + ) + NVIDIA_RTX_PRO_6000_BLACKWELL_WORKSTATION_EDITION = ( + "NVIDIA RTX PRO 6000 Blackwell Workstation Edition" + ) + NVIDIA_RTX_PRO_6000_BLACKWELL_MAX_Q_WORKSTATION_EDITION = ( + "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition" + ) + NVIDIA_H100_80GB_HBM3 = "NVIDIA H100 80GB HBM3" + NVIDIA_RTX_A4000 = "NVIDIA RTX A4000" + NVIDIA_RTX_A4500 = "NVIDIA RTX A4500" + NVIDIA_RTX_4000_ADA_GENERATION = "NVIDIA RTX 4000 Ada Generation" + NVIDIA_RTX_2000_ADA_GENERATION = "NVIDIA RTX 2000 Ada Generation" + NVIDIA_RTX_A5000 = "NVIDIA RTX A5000" + NVIDIA_L4 = "NVIDIA L4" + NVIDIA_GEFORCE_RTX_3090 = "NVIDIA GeForce RTX 3090" + NVIDIA_A40 = "NVIDIA A40" + NVIDIA_RTX_A6000 = "NVIDIA RTX A6000" + NVIDIA_A100_80GB_PCIe = "NVIDIA A100 80GB PCIe" + NVIDIA_A100_SXM4_80GB = "NVIDIA A100-SXM4-80GB" + NVIDIA_H200 = "NVIDIA H200" + NVIDIA_B200 = "NVIDIA B200" + + @classmethod + def all(cls) -> List["GpuType"]: + """all gpu types except ANY.""" + return [g for g in cls if g is not cls.ANY] + + +POOLS_TO_TYPES = { + GpuGroup.ADA_24: [GpuType.NVIDIA_GEFORCE_RTX_4090], + GpuGroup.ADA_32_PRO: [GpuType.NVIDIA_GEFORCE_RTX_5090], + GpuGroup.ADA_48_PRO: [GpuType.NVIDIA_RTX_6000_ADA_GENERATION], + GpuGroup.ADA_80_PRO: [GpuType.NVIDIA_H100_80GB_HBM3], + GpuGroup.AMPERE_16: [ + GpuType.NVIDIA_RTX_A4000, + GpuType.NVIDIA_RTX_A4500, + GpuType.NVIDIA_RTX_4000_ADA_GENERATION, + GpuType.NVIDIA_RTX_2000_ADA_GENERATION, + ], + GpuGroup.AMPERE_24: [ + GpuType.NVIDIA_RTX_A5000, + GpuType.NVIDIA_L4, + GpuType.NVIDIA_GEFORCE_RTX_3090, + ], + GpuGroup.AMPERE_48: [GpuType.NVIDIA_A40, GpuType.NVIDIA_RTX_A6000], + GpuGroup.AMPERE_80: [ + GpuType.NVIDIA_A100_80GB_PCIe, + GpuType.NVIDIA_A100_SXM4_80GB, + ], + GpuGroup.HOPPER_141: [GpuType.NVIDIA_H200], + GpuGroup.BLACKWELL_96: [ + GpuType.NVIDIA_RTX_PRO_6000_BLACKWELL_SERVER_EDITION, + GpuType.NVIDIA_RTX_PRO_6000_BLACKWELL_WORKSTATION_EDITION, + GpuType.NVIDIA_RTX_PRO_6000_BLACKWELL_MAX_Q_WORKSTATION_EDITION, + ], + GpuGroup.BLACKWELL_180: [GpuType.NVIDIA_B200], +} + + +def pool_for_gpu_type(gpu_type: GpuType) -> Optional[GpuGroup]: + """the pool a specific gpu type belongs to, if any.""" + for group, types in POOLS_TO_TYPES.items(): + if gpu_type in types: + return group + return None + + +class CpuInstanceType(str, Enum): + """cpu instance flavors: {generation}{type}-{vcpu}-{memory_gb}.""" + + CPU3G_1_4 = "cpu3g-1-4" + CPU3G_2_8 = "cpu3g-2-8" + CPU3G_4_16 = "cpu3g-4-16" + CPU3G_8_32 = "cpu3g-8-32" + CPU3C_1_2 = "cpu3c-1-2" + CPU3C_2_4 = "cpu3c-2-4" + CPU3C_4_8 = "cpu3c-4-8" + CPU3C_8_16 = "cpu3c-8-16" + CPU5C_1_2 = "cpu5c-1-2" + CPU5C_2_4 = "cpu5c-2-4" + CPU5C_4_8 = "cpu5c-4-8" + CPU5C_8_16 = "cpu5c-8-16" + + +GpuLike = Union[GpuGroup, GpuType, str] + + +def resolve_gpu_string(value: str) -> List[str]: + """resolve a user-supplied gpu string to api-facing values. + + accepted forms, in match order: + - "any" + - pool ids ("ADA_24", case-insensitive) + - exact device names ("NVIDIA B200") + - enum-style names ("NVIDIA_B200") + - shorthand device fragments ("B200", "4090", "h100"): every + device whose name contains the fragment matches, so "H100" + selects all H100 variants + + unknown strings raise with the full list of valid options. + """ + text = value.strip() + if text.lower() == "any": + return [GpuGroup.ANY.value] + + upper = text.upper() + for group in GpuGroup.all(): + if group.value.upper() == upper: + return [group.value] + + for gpu_type in GpuType.all(): + if gpu_type.value.upper() == upper or gpu_type.name == upper: + return [gpu_type.value] + + fragment = upper.replace("_", " ") + matches = [ + gpu_type.value + for gpu_type in GpuType.all() + if fragment in gpu_type.value.upper() + ] + if matches: + return matches + + from .errors import InvalidResourceError + + pools = ", ".join(g.value for g in GpuGroup.all()) + devices = ", ".join(t.value for t in GpuType.all()) + raise InvalidResourceError( + f"unknown gpu '{value}'. use a pool id ({pools}), a device " + f"name ({devices}), or a fragment like '4090' or 'H100'" + ) + + +def gpu_ids_value(gpu: Optional[List[str]]) -> str: + """the gpuIds string for an endpoint payload. + + "any gpu" (no selection, or the ANY sentinel) means every pool id: + the api has no wildcard and rejects "any". device names map back + to their pool (endpoints select by pool, not device). + """ + if not gpu or any(str(g).lower() == "any" for g in gpu): + return ",".join(g.value for g in GpuGroup.all()) + pools: List[str] = [] + for entry in gpu: + value = str(entry) + try: + pool = pool_for_gpu_type(GpuType(value)) + value = pool.value if pool is not None else value + except ValueError: + # not a known GpuType: pass the raw pool/id string through + pass + if value not in pools: + pools.append(value) + return ",".join(pools) diff --git a/runpod/apps/handles.py b/runpod/apps/handles.py new file mode 100644 index 00000000..08e8fca3 --- /dev/null +++ b/runpod/apps/handles.py @@ -0,0 +1,329 @@ +"""handles returned by app decorators. + +a handle replaces the decorated object and is the single way to interact +with the resource: + + @app.queue(name="transcribe", gpu=GpuType.NVIDIA_GEFORCE_RTX_4090) + async def transcribe(audio_url: str): ... + + transcribe.remote("https://...") # sync remote call + await transcribe.remote.aio("https://...") + transcribe.spawn("https://...") # fire and forget -> job handle + transcribe.local("https://...") # run the body here + + for chunk in generate.stream(prompt="hi"): # generator functions + ... + + @transcribe.init + def load_model(): ... # worker startup hook +""" + +import inspect +from typing import ( + TYPE_CHECKING, + Any, + AsyncIterator, + Callable, + Dict, + List, + Optional, + Type, +) + +from .context import Context, current_context +from .errors import InvalidResourceError +from .invoker import Invoker, StreamInvoker +from .job import Job +from .markers import INIT_ATTR, is_init, route_of +from .schedule import SCHEDULE_ATTR +from .spec import ResourceKind, ResourceSpec, RouteSpec + +if TYPE_CHECKING: + from .app import App + + +class FunctionHandle: + """handle for @app.queue and @app.task functions.""" + + def __init__( + self, + app: "App", + fn: Callable, + spec: ResourceSpec, + ): + self._app = app + self._fn = fn + self.spec = spec + self._init_fn: Optional[Callable] = None + # per-job options merged into the run payload; set by with_options + self._job_options: Dict[str, Any] = {} + + # adopt a schedule stamped by @schedule below the app decorator + stamped = getattr(fn, SCHEDULE_ATTR, None) + if stamped and not spec.schedule: + spec.schedule = stamped + + self.remote = Invoker(self._remote_async) + self.stream = StreamInvoker(self._stream_async) + self.spawn = Invoker(self._spawn_async) + self.job = Invoker(self._job_async) + + self.__name__ = getattr(fn, "__name__", spec.name) + self.__doc__ = getattr(fn, "__doc__", None) + self.__wrapped__ = fn + + def with_options( + self, + *, + webhook: Optional[str] = None, + execution_timeout: Optional[int] = None, + ttl: Optional[int] = None, + low_priority: Optional[bool] = None, + s3_config: Optional[Dict[str, Any]] = None, + ) -> "FunctionHandle": + """bind per-job options for the next call, returning a new handle. + + transcribe.with_options(webhook="https://...").spawn(url) + + options apply per invocation and stack across chained calls; + the original handle is untouched. queue only: tasks run on a + dedicated pod and take no job payload options. + """ + from .targets import build_job_options, merge_job_options + + if self.spec.kind is ResourceKind.TASK: + raise InvalidResourceError( + "per-job options apply only to @app.queue functions; " + "tasks run on a dedicated pod" + ) + new_options = build_job_options( + webhook, execution_timeout, ttl, low_priority, s3_config + ) + clone = FunctionHandle(self._app, self._fn, self.spec) + clone._init_fn = self._init_fn + clone._job_options = merge_job_options(self._job_options, new_options) + return clone + + def init(self, fn: Callable) -> Callable: + """register a worker-startup hook; runs before the worker is ready, + never locally.""" + setattr(fn, INIT_ATTR, True) + self._init_fn = fn + return fn + + def local(self, *args: Any, **kwargs: Any) -> Any: + """run the function body here. returns a coroutine iff the + function is async.""" + return self._fn(*args, **kwargs) + + async def _remote_async(self, *args: Any, **kwargs: Any) -> Any: + self._guard_discovery() + ctx = current_context() + + if ctx is Context.WORKER and self._is_current_worker(): + result = self._fn(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + # generators aggregate, matching the deployed worker's + # return_aggregate_stream output for .remote() + if inspect.isasyncgen(result): + return [chunk async for chunk in result] + if inspect.isgenerator(result): + return list(result) + return result + + target = await self._app._resolve(self.spec) + payload = target.build_payload(self._fn, self.spec, args, kwargs) + return await target.invoke(self._apply_options(payload)) + + async def _stream_async(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]: + """invoke a generator function remotely, yielding partial outputs + as the worker produces them.""" + self._guard_discovery() + self._guard_generator() + ctx = current_context() + + if ctx is Context.WORKER and self._is_current_worker(): + gen = self._fn(*args, **kwargs) + if inspect.isasyncgen(gen): + async for chunk in gen: + yield chunk + else: + for chunk in gen: + yield chunk + return + + target = await self._app._resolve(self.spec) + payload = target.build_payload(self._fn, self.spec, args, kwargs) + data = await target.submit(self._apply_options(payload)) + async for chunk in target.stream_job(data["id"]): + yield chunk + + def _guard_generator(self) -> None: + if self.spec.kind is ResourceKind.TASK: + raise InvalidResourceError( + "tasks do not stream; use @app.queue for generator functions" + ) + if not ( + inspect.isgeneratorfunction(self._fn) + or inspect.isasyncgenfunction(self._fn) + ): + raise InvalidResourceError( + f"'{self.__name__}' is not a generator function; " + f"use .remote(...) instead" + ) + + def _apply_options(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if not self._job_options: + return payload + return {**payload, **self._job_options} + + async def _spawn_async(self, *args: Any, **kwargs: Any) -> Any: + self._guard_discovery() + target = await self._app._resolve(self.spec) + payload = target.build_payload(self._fn, self.spec, args, kwargs) + data = await target.submit(self._apply_options(payload)) + # queue targets return raw job data; task targets return a TaskJob + if isinstance(data, dict): + return Job(data, target) + return data + + async def _job_async(self, job_id: str) -> Job: + """reconnect to a submitted queue job by id.""" + if self.spec.kind is ResourceKind.TASK: + raise InvalidResourceError( + "task jobs cannot be reconnected by id; retain the job " + "returned by .spawn()" + ) + target = await self._app._resolve(self.spec) + return Job({"id": job_id, "status": "UNKNOWN"}, target) + + def _guard_discovery(self) -> None: + from .discovery import DiscoveryInvocationError, in_discovery + + if in_discovery(): + raise DiscoveryInvocationError(self.spec.name) + + def _is_current_worker(self) -> bool: + import os + + current = os.getenv("FLASH_RESOURCE_NAME") or os.getenv("RUNPOD_RESOURCE_NAME") + return current is not None and current == self.spec.name + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + raise TypeError( + f"'{self.__name__}' is a runpod {self.spec.kind.value} handle. " + f"use .remote(...) to execute remotely, .local(...) to run here, " + f"or .spawn(...) to fire and forget." + ) + + def __repr__(self) -> str: + return ( + f"<{type(self).__name__} {self.spec.kind.value}:{self.spec.name} " + f"app={self._app.name!r}>" + ) + + +class _RouteCaller: + """client-side http verb on an ApiHandle: Inference.post("/x", body).""" + + __slots__ = ("_handle", "_method") + + def __init__(self, handle: "ApiHandle", method: str): + self._handle = handle + self._method = method + + def __call__(self, path: str, body: Any = None, **kwargs: Any) -> Any: + from .context import block + + return block(self.aio(path, body, **kwargs)) + + async def aio(self, path: str, body: Any = None, **kwargs: Any) -> Any: + from .discovery import DiscoveryInvocationError, in_discovery + + if in_discovery(): + raise DiscoveryInvocationError(self._handle.spec.name) + target = await self._handle._app._resolve(self._handle.spec) + # live targets need the module source to materialize the api + # server-side; other targets ignore the reference + setter = getattr(target, "attach_source", None) + if setter is not None: + setter( + self._handle.__wrapped__, + self._handle.spec.name, + self._handle.spec, + ) + return await target.request(self._method, path, body, **kwargs) + + +class ApiHandle: + """handle for @app.api classes and asgi factories. + + route registration happens inside the decorated class via markers + (@get/@post/...); the handle only ever makes client calls, so there is + no decorator/client dual mode. + """ + + def __init__(self, app: "App", target: Any, spec: ResourceSpec): + self._app = app + self.spec = spec + self._cls: Optional[Type] = None + self._asgi_factory: Optional[Callable] = None + self._init_name: Optional[str] = None + + if inspect.isclass(target): + self._cls = target + self._collect_routes(target) + elif callable(target): + self._asgi_factory = target + spec.asgi_factory = getattr(target, "__qualname__", target.__name__) + else: + raise InvalidResourceError( + "@app.api must decorate a class with route markers or a " + "zero-argument function returning an asgi app" + ) + + self.get = _RouteCaller(self, "GET") + self.post = _RouteCaller(self, "POST") + self.put = _RouteCaller(self, "PUT") + self.delete = _RouteCaller(self, "DELETE") + self.patch = _RouteCaller(self, "PATCH") + + self.__name__ = getattr(target, "__name__", spec.name) + self.__doc__ = getattr(target, "__doc__", None) + self.__wrapped__ = target + + def _collect_routes(self, cls: Type) -> None: + seen: Dict[tuple, str] = {} + routes: List[RouteSpec] = [] + for name, member in inspect.getmembers(cls, callable): + route = route_of(member) + if route is not None: + method, path = route + if (method, path) in seen: + raise InvalidResourceError( + f"duplicate route {method} {path} on {cls.__name__}: " + f"'{seen[(method, path)]}' and '{name}'" + ) + seen[(method, path)] = name + routes.append(RouteSpec(method=method, path=path, handler_name=name)) + if is_init(member): + if self._init_name is not None and self._init_name != name: + raise InvalidResourceError( + f"multiple @init methods on {cls.__name__}: " + f"'{self._init_name}' and '{name}'" + ) + self._init_name = name + if not routes: + raise InvalidResourceError( + f"@app.api class {cls.__name__} defines no routes; mark " + f"methods with @get/@post/@put/@delete/@patch" + ) + self.spec.routes = routes + + def __repr__(self) -> str: + n_routes = len(self.spec.routes) + return ( + f"" + ) diff --git a/runpod/apps/images.py b/runpod/apps/images.py new file mode 100644 index 00000000..10d1400f --- /dev/null +++ b/runpod/apps/images.py @@ -0,0 +1,80 @@ +"""runtime image selection. + +one place maps (resource kind, gpu/cpu, python version) to the runtime +image built by the runtimes workflow. cpu images are python:X.Y-slim +based; gpu images additionally preinstall the torch family (matching +the build-time exclusion set) so deployed artifacts never install it +at cold start. + +RUNPOD_RUNTIME_TAG selects the image channel (latest, dev, or a pinned +version). +""" + +import os +import sys +from typing import Optional + +from .spec import ResourceKind, ResourceSpec + +# policy: non-EOL cpython versions with torch wheel support. 3.10 +# ages out at its 2026-10 EOL. new versions join once torch ships +# wheels for them. +SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") +DEFAULT_PYTHON_VERSION = "3.12" + +_REPOS = { + (ResourceKind.QUEUE, False): "runpod/queue", + (ResourceKind.QUEUE, True): "runpod/queue-gpu", + (ResourceKind.API, False): "runpod/api", + (ResourceKind.API, True): "runpod/api-gpu", + (ResourceKind.TASK, False): "runpod/task", + (ResourceKind.TASK, True): "runpod/task-gpu", +} + + +def runtime_tag() -> str: + return os.environ.get("RUNPOD_RUNTIME_TAG", "latest") + + +def local_python_version() -> str: + """the local interpreter's version, which the worker must match. + + dev sessions and tasks ship cloudpickle payloads produced on the + client interpreter; those are not reliably portable across python + versions, so a silent fallback would surface as cryptic + deserialization failures on the worker. mismatches fail here, + loudly, instead. + """ + version = f"{sys.version_info.major}.{sys.version_info.minor}" + if version not in SUPPORTED_PYTHON_VERSIONS: + raise RuntimeError( + f"python {version} has no runtime image " + f"(supported: {', '.join(SUPPORTED_PYTHON_VERSIONS)}). " + f"run under a supported python or use a custom image= built " + f"for {version}." + ) + return version + + +def runtime_image( + kind: ResourceKind, + *, + gpu: bool, + python_version: Optional[str] = None, +) -> str: + """the builtin runtime image for a resource shape.""" + version = python_version or DEFAULT_PYTHON_VERSION + if version not in SUPPORTED_PYTHON_VERSIONS: + raise ValueError( + f"python {version} is not supported by the runtime images " + f"(supported: {', '.join(SUPPORTED_PYTHON_VERSIONS)})" + ) + repo = _REPOS[(kind, gpu)] + return f"{repo}:py{version}-{runtime_tag()}" + + +def image_for_spec(spec: ResourceSpec, *, python_version: Optional[str] = None) -> str: + """the image a resource runs on: its custom image or the builtin.""" + if spec.image: + return spec.image + return runtime_image(spec.kind, gpu=not spec.is_cpu, python_version=python_version) diff --git a/runpod/apps/init.py b/runpod/apps/init.py new file mode 100644 index 00000000..b7a59edc --- /dev/null +++ b/runpod/apps/init.py @@ -0,0 +1,80 @@ +"""project scaffolding for `rp init`. + +writes a minimal app project: a main module with one queue function and +a local entrypoint, a requirements file, and a .runpodignore. existing +files are never overwritten unless the caller says so. +""" + +from pathlib import Path +from typing import Dict, List + +MAIN_TEMPLATE = '''"""{name}: a Runpod app. + +Run it live: + + rp dev main.py + +Deploy it: + + rp deploy +""" + +import runpod +from runpod import App + +app = App("{name}") + + +@app.queue(cpu="cpu3c-1-2") +def hello(name: str): + # this print streams back to your terminal during rp dev + print(f"running in the cloud, greeting {{name}}") + return f"hello {{name}}!" + + +@runpod.local_entrypoint +def main(): + # runs on your machine; hello() runs on a cloud worker + print(hello.remote("world")) +''' + +REQUIREMENTS_TEMPLATE = """# packages your functions need on the workers +# (also installed locally for rp dev) +""" + +RUNPODIGNORE_TEMPLATE = """# excluded from the deploy artifact +.git +.venv +__pycache__ +*.pyc +.env +""" + +PROJECT_FILES: Dict[str, str] = { + "main.py": MAIN_TEMPLATE, + "requirements.txt": REQUIREMENTS_TEMPLATE, + ".runpodignore": RUNPODIGNORE_TEMPLATE, +} + + +def detect_conflicts(project_dir: Path) -> List[str]: + """names of skeleton files that already exist in project_dir.""" + return [ + name for name in PROJECT_FILES if (project_dir / name).exists() + ] + + +def create_project( + project_dir: Path, name: str, *, overwrite: bool = False +) -> List[Path]: + """write the project skeleton; returns the files written.""" + project_dir.mkdir(parents=True, exist_ok=True) + written: List[Path] = [] + for filename, template in PROJECT_FILES.items(): + path = project_dir / filename + if path.exists() and not overwrite: + continue + content = template.format(name=name) if "{name}" in template else template + path.write_text(content, encoding="utf-8") + written.append(path) + return written diff --git a/runpod/apps/invoker.py b/runpod/apps/invoker.py new file mode 100644 index 00000000..f44c6585 --- /dev/null +++ b/runpod/apps/invoker.py @@ -0,0 +1,49 @@ +"""sync-by-default invocation with an async escape hatch. + +an Invoker wraps a coroutine factory. calling it blocks and returns the +result; calling `.aio(...)` returns the coroutine for the caller to await. + + handle.remote(x) # sync, blocks + await handle.remote.aio(x) # async + +a StreamInvoker is the same idea for async generators: calling it +returns a sync iterator, `.aio(...)` returns the async iterator. +""" + +from typing import Any, AsyncIterator, Callable, Coroutine, Iterator + +from .context import block + +CoroFactory = Callable[..., Coroutine[Any, Any, Any]] +AsyncGenFactory = Callable[..., AsyncIterator[Any]] + + +class Invoker: + __slots__ = ("_factory",) + + def __init__(self, factory: CoroFactory): + self._factory = factory + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return block(self._factory(*args, **kwargs)) + + def aio(self, *args: Any, **kwargs: Any) -> Coroutine[Any, Any, Any]: + return self._factory(*args, **kwargs) + + +class StreamInvoker: + __slots__ = ("_factory",) + + def __init__(self, factory: AsyncGenFactory): + self._factory = factory + + def __call__(self, *args: Any, **kwargs: Any) -> Iterator[Any]: + agen = self._factory(*args, **kwargs) + while True: + try: + yield block(agen.__anext__()) + except StopAsyncIteration: + return + + def aio(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]: + return self._factory(*args, **kwargs) diff --git a/runpod/apps/job.py b/runpod/apps/job.py new file mode 100644 index 00000000..0d0dc24c --- /dev/null +++ b/runpod/apps/job.py @@ -0,0 +1,80 @@ +"""submitted queue jobs shared by decorated handles and Queue stubs.""" + +from typing import Any, AsyncIterator, Dict, TYPE_CHECKING + +from .invoker import Invoker, StreamInvoker +from .targets import FINAL_STATUSES, DEFAULT_TIMEOUT_SECONDS + +if TYPE_CHECKING: + from .targets import InvocationTarget + + +class Job: + """a submitted queue job. + + job operations are sync by default and expose async forms through + ``.aio``: + + job.status() + await job.status.aio() + job.result() + job.cancel() + job.retry() + + generator jobs stream partial outputs: + + for chunk in job.stream(): ... + async for chunk in job.stream.aio(): ... + """ + + def __init__(self, data: Dict[str, Any], target: "InvocationTarget"): + self._data = dict(data) + self._target = target + self.status = Invoker(self._status_async) + self.result = Invoker(self._result_async) + self.output = self.result + self.cancel = Invoker(self._cancel_async) + self.retry = Invoker(self._retry_async) + self.stream = StreamInvoker(self._stream_async) + + @property + def id(self) -> str: + return self._data.get("id", "") + + @property + def done(self) -> bool: + return self._data.get("status", "UNKNOWN") in FINAL_STATUSES + + def _update(self, data: Dict[str, Any]) -> None: + self._data.update(data) + + async def _status_async(self) -> str: + # terminal statuses never change; skip the network round trip + if not self.done: + self._update(await self._target.job_status(self.id)) + return self._data.get("status", "UNKNOWN") + + async def _result_async(self, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> Any: + return await self._target.wait( + self._data, + timeout=timeout, + on_status=self._update, + ) + + async def _cancel_async(self) -> "Job": + self._update(await self._target.cancel_job(self.id)) + return self + + async def _retry_async(self) -> "Job": + self._update(await self._target.retry_job(self.id)) + return self + + async def _stream_async( + self, timeout: float = DEFAULT_TIMEOUT_SECONDS + ) -> AsyncIterator[Any]: + async for chunk in self._target.stream_job(self.id, timeout=timeout): + yield chunk + + def __repr__(self) -> str: + status = self._data.get("status", "UNKNOWN") + return f"Job(id={self.id!r}, status={status!r})" diff --git a/runpod/apps/logs.py b/runpod/apps/logs.py new file mode 100644 index 00000000..482e0967 --- /dev/null +++ b/runpod/apps/logs.py @@ -0,0 +1,97 @@ +"""pod log access via the host api (hapi). + +hosts expose per-pod container and system logs; this is the fastest +way to see why a worker is stuck (image pull progress, region errors, +crash output) without ssh. snapshot and sse streaming supported. + + GET https://hapi.runpod.net/v1/pod/{podId}/logs + ?type=all|container|system + GET .../logs?stream=true&type=...&tail=N&since=RFC3339 +""" + +import json +import os +from typing import Any, AsyncIterator, Dict, List, Optional + +import aiohttp + +HAPI_BASE = os.environ.get("RUNPOD_HAPI_URL", "https://hapi.runpod.net") + +STREAM_TIMEOUT_SECONDS = 3600.0 + + +def _headers() -> Dict[str, str]: + from .targets import _api_key + + return {"Authorization": f"Bearer {_api_key()}"} + + +async def pod_logs( + pod_id: str, + *, + log_type: str = "all", + timeout: float = 30.0, +) -> Dict[str, List[str]]: + """snapshot of a pod's logs: {"container": [...], "system": [...]}.""" + url = f"{HAPI_BASE}/v1/pod/{pod_id}/logs" + client_timeout = aiohttp.ClientTimeout(total=timeout) + async with aiohttp.ClientSession(timeout=client_timeout) as session: + async with session.get( + url, params={"type": log_type}, headers=_headers() + ) as resp: + resp.raise_for_status() + data = await resp.json() + return {k: v or [] for k, v in data.items()} + + +async def stream_pod_logs( + pod_id: str, + *, + log_type: str = "all", + tail: int = 100, + since: Optional[str] = None, +) -> AsyncIterator[Dict[str, Any]]: + """follow a pod's logs as they arrive. + + yields {"source": "container"|"system", "line": str, "ts": str} + parsed from the host's sse stream. ends when the host closes the + stream (1h server-side cap) or the caller breaks out. + """ + url = f"{HAPI_BASE}/v1/pod/{pod_id}/logs" + params: Dict[str, Any] = { + "stream": "true", + "type": log_type, + "tail": str(tail), + } + if since: + params["since"] = since + + client_timeout = aiohttp.ClientTimeout( + total=STREAM_TIMEOUT_SECONDS, sock_read=60 + ) + async with aiohttp.ClientSession(timeout=client_timeout) as session: + async with session.get( + url, params=params, headers=_headers() + ) as resp: + resp.raise_for_status() + async for raw in resp.content: + line = raw.decode("utf-8", "replace").strip() + # sse frames: "data: {...}"; comments (heartbeats) start + # with ":" + if not line.startswith("data:"): + continue + try: + yield json.loads(line[len("data:") :].strip()) + except json.JSONDecodeError: + continue + + +def tail_summary(logs: Dict[str, List[str]], lines: int = 20) -> str: + """human-readable tail of a log snapshot, for error messages.""" + parts = [] + for source in ("system", "container"): + entries = logs.get(source) or [] + if entries: + parts.append(f"--- {source} (last {min(lines, len(entries))}) ---") + parts.extend(entries[-lines:]) + return "\n".join(parts) if parts else "(no logs available)" diff --git a/runpod/apps/manage.py b/runpod/apps/manage.py new file mode 100644 index 00000000..9c260e1f --- /dev/null +++ b/runpod/apps/manage.py @@ -0,0 +1,138 @@ +"""app and environment lifecycle: list, inspect, undeploy, delete. + +everything resolves server-side (the flash app registry is the source +of truth); nothing is tracked locally. undeploying an environment +deletes its endpoints first so no orphaned workers keep billing. +""" + +import logging +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from .api import AppsApiClient +from .errors import AppError +from .utils.events import emit + +log = logging.getLogger(__name__) + + +class AppNotFound(AppError): + def __init__(self, app_name: str): + super().__init__( + f"no app named '{app_name}' found. run `rp deploy` to create one." + ) + + +class EnvironmentNotFound(AppError): + def __init__(self, app_name: str, env_name: str): + super().__init__( + f"no environment '{env_name}' in app '{app_name}'." + ) + + +@dataclass +class UndeployResult: + """outcome of tearing down one environment.""" + + endpoints_deleted: int = 0 + environment_deleted: bool = False + app_deleted: bool = False + failures: List[str] = field(default_factory=list) + + +async def list_apps(api: Optional[AppsApiClient] = None) -> List[Dict]: + """all apps with their environments, newest first.""" + client = api or AppsApiClient() + apps = await client.list_apps() + return sorted(apps, key=lambda a: a.get("name") or "") + + +async def get_app( + app_name: str, api: Optional[AppsApiClient] = None +) -> Dict: + client = api or AppsApiClient() + app = await client.get_app_by_name(app_name) + if app is None: + raise AppNotFound(app_name) + return app + + +async def get_environment( + app_name: str, env_name: str, api: Optional[AppsApiClient] = None +) -> Dict: + client = api or AppsApiClient() + env = await client.get_environment_by_name(app_name, env_name) + if env is None: + raise EnvironmentNotFound(app_name, env_name) + return env + + +async def undeploy_environment( + app_name: str, + env_name: str, + *, + api: Optional[AppsApiClient] = None, + delete_env: bool = True, + events: Optional[object] = None, +) -> UndeployResult: + """tear down an environment: endpoints first, then the environment. + + events, when given, receives cleanup_started(total), deleting(name), + deleted(name), delete_failed(name). + """ + client = api or AppsApiClient() + env = await get_environment(app_name, env_name, api=client) + + result = UndeployResult() + endpoints = env.get("endpoints") or [] + emit(events, "cleanup_started", len(endpoints)) + for endpoint in endpoints: + name = endpoint.get("name") or endpoint.get("id") + emit(events, "deleting", name) + try: + await client.delete_endpoint(endpoint["id"]) + result.endpoints_deleted += 1 + emit(events, "deleted", name) + log.info("deleted endpoint %s (%s)", name, endpoint["id"]) + except Exception as exc: # noqa: BLE001 - collected for the caller + result.failures.append(f"endpoint {name}: {exc}") + emit(events, "delete_failed", name) + + if delete_env and not result.failures: + try: + await client.delete_environment(env["id"]) + result.environment_deleted = True + log.info("deleted environment %s (%s)", env_name, env["id"]) + except Exception as exc: # noqa: BLE001 - collected for the caller + result.failures.append(f"environment {env_name}: {exc}") + + return result + + +async def delete_app( + app_name: str, + *, + api: Optional[AppsApiClient] = None, + events: Optional[object] = None, +) -> UndeployResult: + """delete an app after undeploying every environment in it.""" + client = api or AppsApiClient() + app = await get_app(app_name, api=client) + + result = UndeployResult() + for env in app.get("flashEnvironments") or []: + env_result = await undeploy_environment( + app_name, env["name"], api=client, events=events + ) + result.endpoints_deleted += env_result.endpoints_deleted + result.failures.extend(env_result.failures) + + if not result.failures: + try: + await client.delete_app(app["id"]) + result.app_deleted = True + log.info("deleted app %s (%s)", app_name, app["id"]) + except Exception as exc: # noqa: BLE001 - collected for the caller + result.failures.append(f"app {app_name}: {exc}") + + return result diff --git a/runpod/apps/markers.py b/runpod/apps/markers.py new file mode 100644 index 00000000..9f8e1e09 --- /dev/null +++ b/runpod/apps/markers.py @@ -0,0 +1,89 @@ +"""route and lifecycle markers for @app.api classes. + +these decorators stamp metadata on methods; the ApiHandle collects them +when the class is registered. they do not wrap or change the function. + + @app.api(name="inference", gpu=GpuType.NVIDIA_GEFORCE_RTX_4090) + class Inference: + @init + def setup(self): + self.model = load_model() + + @post("/generate") + async def generate(self, body: dict): + return {"text": self.model.run(body["prompt"])} +""" + +from typing import Any, Callable + +ROUTE_ATTR = "__runpod_route__" +INIT_ATTR = "__runpod_init__" + +_VALID_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH"}) + +# paths used by the worker runtime; user routes must not collide +RESERVED_PATHS = frozenset({"/execute", "/ping"}) + + +def _route_marker(method: str, path: str) -> Callable[[Callable], Callable]: + if method not in _VALID_METHODS: + raise ValueError(f"method must be one of {sorted(_VALID_METHODS)}") + if not path.startswith("/"): + raise ValueError(f"path must start with '/', got: {path!r}") + if path in RESERVED_PATHS: + raise ValueError( + f"path {path!r} is reserved by the worker runtime " + f"(reserved: {', '.join(sorted(RESERVED_PATHS))})" + ) + + def marker(fn: Callable) -> Callable: + setattr(fn, ROUTE_ATTR, (method, path)) + return fn + + return marker + + +def get(path: str) -> Callable[[Callable], Callable]: + """mark a method as a GET route.""" + return _route_marker("GET", path) + + +def post(path: str) -> Callable[[Callable], Callable]: + """mark a method as a POST route.""" + return _route_marker("POST", path) + + +def put(path: str) -> Callable[[Callable], Callable]: + """mark a method as a PUT route.""" + return _route_marker("PUT", path) + + +def delete(path: str) -> Callable[[Callable], Callable]: + """mark a method as a DELETE route.""" + return _route_marker("DELETE", path) + + +def patch(path: str) -> Callable[[Callable], Callable]: + """mark a method as a PATCH route.""" + return _route_marker("PATCH", path) + + +def init(fn: Callable) -> Callable: + """mark a method (or function) as a worker-startup hook. + + on an @app.api class, the marked method runs after instantiation and + before the worker reports healthy. on a queue/task handle, use + `@handle.init` instead. + """ + setattr(fn, INIT_ATTR, True) + return fn + + +def route_of(fn: Any) -> "tuple[str, str] | None": + """return (method, path) if fn is marked as a route.""" + return getattr(fn, ROUTE_ATTR, None) + + +def is_init(fn: Any) -> bool: + """true if fn is marked as an init hook.""" + return getattr(fn, INIT_ATTR, False) is True diff --git a/runpod/apps/model.py b/runpod/apps/model.py new file mode 100644 index 00000000..d751b8d7 --- /dev/null +++ b/runpod/apps/model.py @@ -0,0 +1,91 @@ +"""platform-cached models: weights staged on hosts before workers start. + +a Model references huggingface weights by repo id. attached to a +resource, the platform stages the weights on the host and defers +worker start until they are ready, so cold starts never download +model files. + + llama = runpod.Model("meta-llama/Llama-3.1-8B-Instruct") + + @app.queue(gpu="H100", model=llama, env={"HF_TOKEN": runpod.Secret("hf")}) + def chat(prompt: str): + llm = vllm.LLM(model=str(llama.path)) + +inside the worker the weights appear in two layouts: + - runpod store: /runpod/model-store/huggingface/{org}/{name}/{revision} + (Model.path, revision from the MODEL_REVISION env var) + - hf cache: /runpod-volume/huggingface-cache/hub/models--{org}--{name} + (transformers/vllm find it via the standard cache convention) + +gated models need an HF_TOKEN env var on the same resource (a Secret +reference works; the platform decrypts it for validation). +""" + +import os +import re +from pathlib import Path +from typing import Optional + +from .errors import AppError + +MODEL_STORE_ROOT = Path("/runpod/model-store/huggingface") +HF_CACHE_ROOT = Path("/runpod-volume/huggingface-cache") + +_REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$") + + +class ModelError(AppError): + pass + + +class Model: + """a huggingface model reference, staged by the platform.""" + + def __init__(self, reference: str): + if not reference or not isinstance(reference, str): + raise ModelError("model reference must be a non-empty string") + repo, _, revision = reference.partition(":") + if not _REPO_RE.match(repo): + raise ModelError( + f"invalid model reference '{reference}': expected " + f"'owner/name' or 'owner/name:revision'" + ) + self.reference = reference + self.owner, self.name = repo.split("/") + self.revision = revision or None + + @property + def path(self) -> Path: + """the staged weights directory inside the worker. + + the platform resolves the exact revision at deploy time and + exposes it via MODEL_REVISION; outside a worker (or before the + mount exists) this still forms the correct path shape. + """ + revision = os.environ.get("MODEL_REVISION") or self.revision or "" + base = MODEL_STORE_ROOT / self.owner / self.name + return base / revision if revision else base + + @property + def hf_cache_path(self) -> Path: + """the huggingface-convention cache directory for this model.""" + return ( + HF_CACHE_ROOT / "hub" / f"models--{self.owner}--{self.name}" + ) + + def __repr__(self) -> str: + return f"" + + +def model_reference(model) -> Optional[str]: + """normalize a spec's model field to the api-facing reference.""" + if model is None: + return None + if isinstance(model, Model): + return model.reference + if isinstance(model, str): + return Model(model).reference + raise ModelError( + f"model must be a runpod.Model or 'owner/name' string, " + f"got {type(model).__name__}" + ) diff --git a/runpod/apps/monitor.py b/runpod/apps/monitor.py new file mode 100644 index 00000000..c8e8c25f --- /dev/null +++ b/runpod/apps/monitor.py @@ -0,0 +1,278 @@ +"""request-time worker observability for dev sessions. + +while a dev call is in flight, a WorkerMonitor watches the endpoint's +worker metrics for state transitions (initializing, throttled, ready) +and, once the job is assigned a worker, follows that worker's container +logs over the hapi sse stream. everything surfaces through a duck-typed +event sink; missing handlers are silently skipped. +""" + +import asyncio +import json +import logging +import re +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from .utils.events import emit + +log = logging.getLogger(__name__) + +METRICS_POLL_INTERVAL = 2.0 + +# worker states worth reporting, in display order +_TRACKED_STATES = ("initializing", "ready", "running", "throttled", "unhealthy") + +# leading iso timestamp on hapi log lines +_TS_PREFIX = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\s*" +) + + +def _worker_frame(line: str) -> Optional[Dict[str, Any]]: + """parse the serverless sdk's structured json log lines.""" + if not line.startswith("{"): + return None + try: + frame = json.loads(line) + except json.JSONDecodeError: + return None + if isinstance(frame, dict) and "message" in frame and "level" in frame: + return frame + return None + + +def _filter_line(line: str) -> Optional[str]: + """what to show from a raw container log line. + + the serverless sdk's own frames (fitness checks, queue counts, + started/finished) are runtime noise; user prints pass through + verbatim, and sdk error/warn frames surface as their message. + """ + if line.startswith("--- Starting Serverless Worker"): + return None + frame = _worker_frame(line) + if frame is None: + return line + if frame.get("level", "").upper() in ("ERROR", "WARN", "WARNING"): + return str(frame.get("message", "")) + return None + + +class PodLogStream: + """follows one pod's container logs and emits worker_log events. + + shared by live endpoint workers and task pods: attach() starts the + follow (retrying aggressively while the gateway warms up to a fresh + pod), stop() cancels it and falls back to a snapshot when the + stream never yielded a line (jobs shorter than the attach window). + lines dedup across reconnects and sdk runtime frames are filtered. + """ + + def __init__(self, pod_id: str, resource_name: str, events: object): + self.pod_id = pod_id + self.name = resource_name + self.events = events + self._task: Optional[asyncio.Task] = None + self._since = datetime.now(timezone.utc).isoformat() + self._lines_emitted = 0 + # (ts, line) pairs already shown; reconnect backfill overlaps + # the previous window and must not replay lines + self._seen: set = set() + + def attach(self) -> None: + if self._task is None: + self._task = asyncio.ensure_future(self._follow()) + + async def stop(self) -> None: + if self._task is not None: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + self._task = None + # the stream often cannot attach within a short job's lifetime + # (fresh pods 403 on the logs endpoint until the gateway can + # resolve them); a final snapshot recovers the output + if not self._lines_emitted: + await self._snapshot() + + def _emit_line(self, key: tuple, line: str) -> None: + if key in self._seen: + return + self._seen.add(key) + shown = _filter_line(line) + if shown: + emit(self.events, "worker_log", self.name, shown) + + async def _snapshot(self) -> None: + from .logs import pod_logs + + try: + logs = await pod_logs(self.pod_id, log_type="container") + except Exception: # noqa: BLE001 - observability is best-effort + log.debug("log snapshot for %s failed", self.pod_id, exc_info=True) + return + for raw in logs.get("container") or []: + if _line_before(raw, self._since): + continue + line = _TS_PREFIX.sub("", raw.rstrip()) + if line: + self._emit_line((raw.rstrip(), line), line) + + async def _follow(self) -> None: + from .logs import stream_pod_logs + + attempt = 0 + while True: + streamed = False + try: + # backfill (tail) + since: lines printed between request + # start and the attach finally succeeding (fresh pods + # 403 until the gateway can resolve them) are recovered + # from the backfill instead of dropped + async for event in stream_pod_logs( + self.pod_id, + log_type="container", + tail=1000, + since=self._since, + ): + streamed = True + attempt = 0 + raw = (event.get("line") or "").rstrip() + line = _TS_PREFIX.sub("", raw) + if line: + self._lines_emitted += 1 + self._emit_line((event.get("ts") or raw, line), line) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - observability is best-effort + log.debug( + "log stream attach for %s failed (attempt %d)", + self.pod_id, + attempt + 1, + exc_info=True, + ) + if streamed: + # the server closed a healthy stream (1h cap or worker + # teardown); resume immediately from where we left off + self._since = datetime.now(timezone.utc).isoformat() + continue + attempt += 1 + # attach latency is the product: retry aggressively while + # the gateway warms up to the fresh pod (sub-second polls), + # then ease off if it stays unreachable + if attempt <= 20: + await asyncio.sleep(0.5) + else: + await asyncio.sleep(min(attempt - 20, 15)) + + +class WorkerMonitor: + """observes one in-flight request against a live endpoint. + + start() launches a metrics poller; on_status(payload) should be fed + every job-status payload so the assigned worker's log stream can + attach as soon as a workerId appears. stop() tears everything down. + """ + + def __init__( + self, + endpoint_id: str, + resource_name: str, + events: object, + metrics_key: Optional[str] = None, + ): + self.endpoint_id = endpoint_id + self.name = resource_name + self.events = events + # /metrics is served on the data plane behind the endpoint's own + # ai key; the user api key is rejected there + self.metrics_key = metrics_key + self._tasks: List[asyncio.Task] = [] + self._streams: Dict[str, PodLogStream] = {} + self._last_counts: Optional[Dict[str, int]] = None + + async def start(self) -> None: + if self.metrics_key: + self._tasks.append(asyncio.ensure_future(self._poll_metrics())) + + def on_status(self, data: Dict[str, Any]) -> None: + """inspect a job-status payload for the assigned worker.""" + worker_id = data.get("workerId") + if worker_id and worker_id not in self._streams: + emit(self.events, "worker_ready", self.name, str(worker_id)) + stream = PodLogStream(str(worker_id), self.name, self.events) + stream.attach() + self._streams[str(worker_id)] = stream + + async def stop(self) -> None: + for task in self._tasks: + task.cancel() + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + self._tasks.clear() + for stream in self._streams.values(): + await stream.stop() + + async def _poll_metrics(self) -> None: + from .targets import _endpoint_url_base, _get_json + + url = f"{_endpoint_url_base()}/{self.endpoint_id}/metrics" + headers = {"Authorization": f"Bearer {self.metrics_key}"} + while True: + try: + data = await _get_json(url, headers, 10.0) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - observability is best-effort + await asyncio.sleep(METRICS_POLL_INTERVAL) + continue + self._report_counts(data) + await asyncio.sleep(METRICS_POLL_INTERVAL) + + def _report_counts(self, data: Dict[str, Any]) -> None: + # once a worker picked the job up, pool churn is noise + if self._streams: + return + workers = data.get("workers") + if not isinstance(workers, dict): + return + counts = { + state: workers.get(state, 0) or 0 for state in _TRACKED_STATES + } + if counts == self._last_counts: + return + first = self._last_counts is None + self._last_counts = counts + # the first snapshot is only interesting when workers are still + # coming up (or wedged); a steady ready/running pool is implied + if first and not ( + counts["initializing"] or counts["throttled"] or counts["unhealthy"] + ): + return + emit(self.events, "worker_status", self.name, counts) + + +def _line_before(raw: str, since_iso: str) -> bool: + """true when the log line's leading timestamp predates since.""" + match = _TS_PREFIX.match(raw) + if not match: + return False + ts = match.group(0).strip().replace("Z", "+00:00") + # docker timestamps carry nanoseconds; fromisoformat caps at micro + ts = re.sub(r"(\.\d{6})\d+", r"\1", ts) + try: + line_ts = datetime.fromisoformat(ts) + since = datetime.fromisoformat(since_iso) + except ValueError: + return False + return line_ts <= since + + +def format_worker_counts(counts: Dict[str, int]) -> str: + """human summary like '1 initializing, 2 ready'.""" + parts = [ + f"{count} {state}" + for state, count in counts.items() + if count + ] + return ", ".join(parts) if parts else "no workers" diff --git a/runpod/apps/placement.py b/runpod/apps/placement.py new file mode 100644 index 00000000..4f88ad77 --- /dev/null +++ b/runpod/apps/placement.py @@ -0,0 +1,216 @@ +"""datacenter placement: solving where volumes and resources can live. + +a network volume pins everything attached to it to one datacenter, so +placement is a constraint solve over the whole app: each resource's +schedulable datacenters (hardware stock ∩ user pins ∩ storage support) +intersected per volume, ranked maximin by stock so the chosen DC is +the one where the most-constrained resource has the best availability. +""" + +import asyncio +import logging +from typing import Dict, Iterable, List, Optional, Set, Tuple + +from .datacenter import DataCenter +from .errors import AppError +from .gpu import GpuGroup +from .utils.client import default_client + +log = logging.getLogger(__name__) + +# stock signal ranking; unknown/none scores zero +_STOCK_SCORE = {"HIGH": 3, "MEDIUM": 2, "LOW": 1} + + +class PlacementError(AppError): + pass + + +def _score(status: Optional[str]) -> int: + if not isinstance(status, str): + return 0 + return _STOCK_SCORE.get(status.strip().upper(), 0) + + +def _hardware_keys(spec) -> List[Tuple[str, str]]: + """(kind, id) stock lookup keys for a resource's hardware. + + gpu entries may be pool ids or device names; pools expand to their + device names since the stock api takes devices. tasks run on pods, + whose gpu availability differs from the serverless plane, so their + keys carry a distinct kind and query pod stock. + """ + from .spec import ResourceKind + + if spec.is_cpu: + return [("cpu", c) for c in spec.cpu or []] + gpu_kind = "gpu-pod" if spec.kind is ResourceKind.TASK else "gpu" + gpu = spec.gpu + if not gpu or any(str(g).lower() == "any" for g in gpu): + return [(gpu_kind, "*")] + keys: List[Tuple[str, str]] = [] + for entry in gpu: + try: + for device in GpuGroup(str(entry)).device_names(): + keys.append((gpu_kind, device)) + except ValueError: + keys.append((gpu_kind, str(entry))) + return keys + + +class StockMap: + """per-(hardware, datacenter) stock signals, fetched lazily in bulk.""" + + def __init__(self, api=None): + self._api = api + self._gpu: Dict[Tuple[str, str], int] = {} + self._gpu_pod: Dict[Tuple[str, str], int] = {} + self._cpu: Dict[Tuple[str, str], int] = {} + self._fetched_gpu: Set[str] = set() + self._fetched_gpu_pod: Set[str] = set() + self._fetched_cpu: Set[str] = set() + + async def _client(self): + self._api = default_client(self._api) + return self._api + + async def fetch(self, keys: Iterable[Tuple[str, str]]) -> None: + """populate stock for the given hardware keys across all DCs.""" + keys = list(keys) + gpu_ids = { + k[1] + for k in keys + if k[0] == "gpu" and k[1] != "*" and k[1] not in self._fetched_gpu + } + gpu_pod_ids = { + k[1] + for k in keys + if k[0] == "gpu-pod" + and k[1] != "*" + and k[1] not in self._fetched_gpu_pod + } + cpu_ids = { + k[1] for k in keys if k[0] == "cpu" and k[1] not in self._fetched_cpu + } + client = await self._client() + jobs = [] + for gpu_id in gpu_ids: + self._fetched_gpu.add(gpu_id) + for dc in DataCenter.all(): + jobs.append(self._fetch_gpu(client, gpu_id, dc.value, False)) + for gpu_id in gpu_pod_ids: + self._fetched_gpu_pod.add(gpu_id) + for dc in DataCenter.all(): + jobs.append(self._fetch_gpu(client, gpu_id, dc.value, True)) + for cpu_id in cpu_ids: + self._fetched_cpu.add(cpu_id) + for dc in DataCenter.all(): + jobs.append(self._fetch_cpu(client, cpu_id, dc.value)) + if jobs: + await asyncio.gather(*jobs) + + async def _fetch_gpu( + self, client, gpu_id: str, dc: str, pods: bool + ) -> None: + try: + status = await client.gpu_stock_status(gpu_id, dc, pods=pods) + except Exception: # noqa: BLE001 - stock is advisory + log.debug("gpu stock query failed for %s@%s", gpu_id, dc, exc_info=True) + status = None + target = self._gpu_pod if pods else self._gpu + target[(gpu_id, dc)] = _score(status) + + async def _fetch_cpu(self, client, instance_id: str, dc: str) -> None: + try: + status = await client.cpu_stock_status(instance_id, dc) + except Exception: # noqa: BLE001 - stock is advisory + log.debug("cpu stock query failed for %s@%s", instance_id, dc, exc_info=True) + status = None + self._cpu[(instance_id, dc)] = _score(status) + + def score(self, key: Tuple[str, str], dc: str) -> int: + kind, hw = key + if kind in ("gpu", "gpu-pod"): + table = self._gpu_pod if kind == "gpu-pod" else self._gpu + if hw == "*": + # any gpu: best signal among fetched devices, else assume ok + scores = [s for (g, d), s in table.items() if d == dc] + return max(scores, default=1) + return table.get((hw, dc), 0) + return self._cpu.get((hw, dc), 0) + + +def candidates(spec, stock: StockMap) -> Set[str]: + """datacenters where a resource is schedulable. + + hardware needs stock in the DC (any of the resource's acceptable + devices/flavors), intersected with an explicit datacenter pin. + """ + allowed = {dc.value for dc in DataCenter.all()} + if spec.datacenter: + allowed &= {str(d) for d in spec.datacenter} + + keys = _hardware_keys(spec) + if not keys: + return allowed + viable = set() + for dc in allowed: + if any(stock.score(key, dc) > 0 for key in keys): + viable.add(dc) + return viable + + +def _resource_best_score(spec, stock: StockMap, dc: str) -> int: + keys = _hardware_keys(spec) + if not keys: + return 1 + return max(stock.score(key, dc) for key in keys) + + +def solve_placement( + specs: List, + stock: StockMap, + *, + volume_name: str, + existing_dc: Optional[str] = None, +) -> str: + """pick the datacenter for one volume given every resource using it. + + an existing volume's DC is a hard constraint (verified schedulable); + a new volume lands in the intersection of every resource's candidate + set, ranked maximin: the DC where the most-constrained resource has + the best stock. + """ + per_resource = {spec.name: candidates(spec, stock) for spec in specs} + + if existing_dc is not None: + blocked = [ + name for name, dcs in per_resource.items() if existing_dc not in dcs + ] + if blocked: + raise PlacementError( + f"volume '{volume_name}' lives in {existing_dc}, but " + f"{', '.join(blocked)} cannot schedule there " + f"(no hardware stock or conflicting datacenter pin)" + ) + return existing_dc + + shared = set.intersection(*per_resource.values()) if per_resource else set() + if not shared: + lines = [ + f" {name:<12} schedulable in: {', '.join(sorted(dcs)) or '(nowhere)'}" + for name, dcs in per_resource.items() + ] + raise PlacementError( + f"cannot place volume '{volume_name}': no datacenter can host " + f"every resource using it\n" + "\n".join(lines) + "\n" + f"use separate volumes or compatible hardware" + ) + + # maximin: rank each DC by the worst resource's stock there, + # tiebreak by the aggregate + def rank(dc: str) -> Tuple[int, int]: + scores = [_resource_best_score(spec, stock, dc) for spec in specs] + return (min(scores), sum(scores)) + + return max(sorted(shared), key=rank) diff --git a/runpod/apps/protocol.py b/runpod/apps/protocol.py new file mode 100644 index 00000000..bc9f34bb --- /dev/null +++ b/runpod/apps/protocol.py @@ -0,0 +1,60 @@ +"""wire protocol for remote function execution. + +this is the contract between the sdk and the worker runtime images. +the worker repo imports these shapes from the runpod package so client +and worker can never drift. + +live mode ships function source with every request; deployed mode omits +the code (the worker unpacked the build and resolves the function by +name). args/kwargs are base64 cloudpickle unless serialization_format +is "json". +""" + +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Optional + +EXECUTION_FUNCTION = "function" + +FORMAT_CLOUDPICKLE = "cloudpickle" +FORMAT_JSON = "json" + + +@dataclass +class FunctionRequest: + """a request to execute one function on a worker.""" + + function_name: str + function_code: Optional[str] = None + args: List[Any] = field(default_factory=list) + kwargs: Dict[str, Any] = field(default_factory=dict) + dependencies: Optional[List[str]] = None + system_dependencies: Optional[List[str]] = None + execution_type: str = EXECUTION_FUNCTION + accelerate_downloads: bool = True + serialization_format: str = FORMAT_CLOUDPICKLE + + def to_input(self) -> Dict[str, Any]: + """serialize as the job input dict.""" + data = asdict(self) + return {k: v for k, v in data.items() if v is not None} + + +@dataclass +class FunctionResponse: + """the worker's result for one function execution.""" + + success: bool + result: Optional[str] = None + json_result: Any = None + error: Optional[str] = None + stdout: Optional[str] = None + + @classmethod + def from_output(cls, output: Dict[str, Any]) -> "FunctionResponse": + return cls( + success=output.get("success", False), + result=output.get("result"), + json_result=output.get("json_result"), + error=output.get("error"), + stdout=output.get("stdout"), + ) diff --git a/runpod/apps/registry.py b/runpod/apps/registry.py new file mode 100644 index 00000000..e237ef44 --- /dev/null +++ b/runpod/apps/registry.py @@ -0,0 +1,45 @@ +"""container registry credentials for private images. + +credentials are pure provision-time config referenced by name: + + @app.queue(image="ghcr.io/me/private:latest", registry_auth="my-ghcr") + def infer(): ... + +create and manage them with `rp registry add/list/delete`. resolution +(name -> containerRegistryAuthId) happens when the resource provisions. +""" + +import logging +from typing import Optional + +from .errors import AppError +from .utils.lookup import find_by_id_or_name + +log = logging.getLogger(__name__) + + +class RegistryAuthError(AppError): + pass + + +async def resolve_registry_auth( + name: Optional[str], api=None +) -> Optional[str]: + """resolve a credential name (or id) to a containerRegistryAuthId.""" + if not name: + return None + if api is None: + from .api import AppsApiClient + + api = AppsApiClient() + creds = await api.list_registry_auths() + match = find_by_id_or_name( + creds, name, noun="registry credentials", error=RegistryAuthError + ) + if match: + return match["id"] + available = ", ".join(sorted(c["name"] for c in creds)) or "(none)" + raise RegistryAuthError( + f"registry credential '{name}' not found. available: {available}. " + f"create one with `rp registry add {name}`" + ) diff --git a/runpod/apps/schedule.py b/runpod/apps/schedule.py new file mode 100644 index 00000000..c55d4bfc --- /dev/null +++ b/runpod/apps/schedule.py @@ -0,0 +1,41 @@ +"""the @schedule decorator: records a cron expression on a handle. + +schedules only take effect through `rp deploy` (the schedule must live +server-side). recurring schedule execution is gated on backend support; +deploying a scheduled resource raises ScheduleNotSupported until it lands. +""" + +from typing import Callable, TypeVar + +T = TypeVar("T") + +SCHEDULE_ATTR = "__runpod_schedule__" + +# flipped when the backend ships recurring schedule triggers +SCHEDULES_ENABLED = False + + +def schedule(*, cron: str) -> Callable[[T], T]: + """attach a cron schedule to a queue or task handle. + + @app.task(name="hourly", gpu=GpuType.NVIDIA_GEFORCE_RTX_4090) + @schedule(cron="0 * * * *") + async def hourly_job(): ... + + decorator order is flexible: @schedule can sit above or below the + app decorator. the cron string is validated at deploy time. + """ + if not cron or not isinstance(cron, str): + raise ValueError("cron must be a non-empty string") + + def decorator(target: T) -> T: + # works on both raw functions (before app decorator) and handles + # (after), since handles expose their spec and raw fns get stamped + spec = getattr(target, "spec", None) + if spec is not None: + spec.schedule = cron + else: + setattr(target, SCHEDULE_ATTR, cron) + return target + + return decorator diff --git a/runpod/apps/secret.py b/runpod/apps/secret.py new file mode 100644 index 00000000..d1742f96 --- /dev/null +++ b/runpod/apps/secret.py @@ -0,0 +1,95 @@ +"""platform secrets: encrypted values injected into worker env vars. + +a Secret marks an env value for reference-syntax rendering; the +platform decrypts it when the worker boots. values never travel +through the sdk. + + @app.queue(env={"HF_TOKEN": runpod.Secret("hf-token")}) + def download(): ... + +the worker sees a plain HF_TOKEN env var with the decrypted value. +create and manage secrets with `rp secret add/list/delete`. +""" + +import re +from typing import Any, Dict, List, Optional + +from .errors import AppError + +# the platform's env reference syntax (decrypted server-side at boot) +_REFERENCE_TEMPLATE = "{{{{ RUNPOD_SECRET_{name} }}}}" +_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") + + +class SecretError(AppError): + pass + + +class Secret: + """a reference to a platform secret by name.""" + + def __init__(self, name: str): + if not name or not isinstance(name, str): + raise SecretError("secret name must be a non-empty string") + if not _NAME_RE.match(name): + raise SecretError( + f"invalid secret name '{name}': letters, digits, " + f"underscores, dots, and dashes only" + ) + self.name = name + + @property + def reference(self) -> str: + """the env-var value the platform substitutes at boot.""" + return _REFERENCE_TEMPLATE.format(name=self.name) + + def __repr__(self) -> str: + return f"" + + +def render_env(env: Optional[Dict[str, Any]]) -> Dict[str, str]: + """render an env mapping, expanding Secret values to references. + + plain values stringify; Secret values become the platform's + reference syntax. + """ + if not env: + return {} + rendered: Dict[str, str] = {} + for key, value in env.items(): + if isinstance(value, Secret): + rendered[key] = value.reference + else: + rendered[key] = str(value) + return rendered + + +def secret_names(env: Optional[Dict[str, Any]]) -> List[str]: + """names of every Secret referenced in an env mapping.""" + if not env: + return [] + return [v.name for v in env.values() if isinstance(v, Secret)] + + +async def validate_secrets( + names: List[str], api=None +) -> None: + """fail fast when referenced secrets do not exist. + + workers with unresolvable references boot with the literal + template string in the env var, which is a confusing runtime + failure; checking up front turns it into a clear provision error. + """ + if not names: + return + if api is None: + from .api import AppsApiClient + + api = AppsApiClient() + existing = {s["name"] for s in await api.list_secrets()} + missing = sorted(set(names) - existing) + if missing: + raise SecretError( + f"secret{'s' if len(missing) > 1 else ''} not found: " + f"{', '.join(missing)}. create with `rp secret add `" + ) diff --git a/runpod/apps/serialization.py b/runpod/apps/serialization.py new file mode 100644 index 00000000..684ccd71 --- /dev/null +++ b/runpod/apps/serialization.py @@ -0,0 +1,101 @@ +"""argument and result serialization for remote execution. + +args/kwargs cross the wire as base64-encoded cloudpickle strings, the +format the worker runtime images expect. function source is extracted +with decorators stripped so the worker can exec it standalone. +""" + +import ast +import base64 +import inspect +import os +import textwrap +from typing import Any, Callable, Dict, List + +import cloudpickle + + +def serialize_arg(arg: Any) -> str: + return base64.b64encode(cloudpickle.dumps(arg)).decode("utf-8") + + +def serialize_args(args: tuple) -> List[str]: + return [serialize_arg(a) for a in args] + + +def serialize_kwargs(kwargs: dict) -> Dict[str, str]: + return {k: serialize_arg(v) for k, v in kwargs.items()} + + +def deserialize_result(result_b64: str) -> Any: + try: + return cloudpickle.loads(base64.b64decode(result_b64)) + except ModuleNotFoundError as exc: + from .errors import RemoteExecutionError + + raise RemoteExecutionError( + f"the remote result contains an object from the " + f"'{exc.name}' package, which is not installed locally. " + f"return plain python types (e.g. str(...) it) or install " + f"'{exc.name}' on this machine." + ) from exc + + +def require_json_args(fn: Callable, args: tuple, kwargs: dict) -> None: + """reject arguments that cannot cross the queue data plane. + + queue jobs are json on the wire in every mode, so non-json + arguments fail at call time with a pointed error instead of a + worker-side decode failure. + """ + import json + + try: + json.dumps(list(args)) + json.dumps(kwargs) + except (TypeError, ValueError) as exc: + raise TypeError( + f"{fn.__name__}() arguments must be json-serializable " + f"(queue jobs are plain json): {exc}" + ) from exc + + +def get_function_source(fn: Callable) -> str: + """the full source of the function's module. + + the worker execs this and resolves the function by name, exactly + mirroring deployed mode (which imports the user's module from the + build artifact): module-level imports, globals, and decorators + behave identically in dev and deploy. + """ + fn = inspect.unwrap(fn) + module = inspect.getmodule(fn) + if module is not None: + try: + return inspect.getsource(module) + except (OSError, TypeError): + pass + # exec'd module (nested hop inside a live worker): the runner + # materialized the shipped module to a real file; re-ship it whole + # so decorators and globals keep working on the next worker + filename = getattr(getattr(fn, "__code__", None), "co_filename", "") + if filename and os.path.isfile(filename): + with open(filename) as f: + return f.read() + # last resort (repl): the bare function body with decorators + # stripped, since their context cannot travel + return _bare_function_source(fn) + + +def _bare_function_source(fn: Callable) -> str: + """a function's own def, decorators removed.""" + source = textwrap.dedent(inspect.getsource(fn)) + tree = ast.parse(source) + for node in ast.walk(tree): + if ( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == fn.__name__ + ): + lines = source.split("\n") + return textwrap.dedent("\n".join(lines[node.lineno - 1 :])) + return source diff --git a/runpod/apps/shim.py b/runpod/apps/shim.py new file mode 100644 index 00000000..13cfa8f5 --- /dev/null +++ b/runpod/apps/shim.py @@ -0,0 +1,51 @@ +"""shell launcher for booting injected runtime scripts on custom images. + +builds the dockerArgs command that materializes a base64 env payload +into a file and runs it with the image's python. constraints: + + - POSIX sh only (no bash on busybox/alpine images) + - python may not be on sh's PATH (conda/venv images), so well-known + interpreter locations are probed + - no base64 binary assumed: the discovered python does the decode + - no python at all is a configuration error: the whole feature is + running the user's python function on their image, so a pythonless + image gets a loud, clear failure + +the launcher must stay single-quote-free internally: the host parses +dockerArgs with a shell lexer and the whole script rides inside one +pair of single quotes. +""" + +# well-known interpreter locations beyond PATH +_PYTHON_CANDIDATES = ( + "python3", + "python", + "/usr/local/bin/python3", + "/usr/bin/python3", + "/opt/conda/bin/python", + "/opt/venv/bin/python", + "/venv/bin/python", + "/root/.venv/bin/python", + "/app/.venv/bin/python", + "/usr/local/bin/python", +) + + +def shell_launcher(env_var: str, dest: str) -> str: + """dockerArgs command that decodes $env_var into dest and execs it.""" + probes = " ".join(_PYTHON_CANDIDATES) + script = ( + f'PY=""; ' + f"for c in {probes}; do " + f'if command -v "$c" >/dev/null 2>&1; then PY="$c"; break; fi; ' + f"done; " + f'if [ -z "$PY" ]; then ' + f'echo "[shim] FATAL: no python interpreter found in this image. "' + f'"custom images must include python3." >&2; ' + f"exit 1; fi; " + f'echo "${env_var}" | "$PY" -c ' + f'"import base64,sys;sys.stdout.buffer.write(base64.b64decode(sys.stdin.read()))" ' + f"> {dest} && " + f'exec "$PY" {dest}' + ) + return f"sh -c '{script}'" diff --git a/runpod/apps/spec.py b/runpod/apps/spec.py new file mode 100644 index 00000000..e87e9b03 --- /dev/null +++ b/runpod/apps/spec.py @@ -0,0 +1,289 @@ +"""resource specifications produced by app decorators. + +a ResourceSpec is the declarative description of one deployable resource. +it is what the manifest serializes, what `rp deploy` ships to the backend, +and what dev-session provisioning consumes. it holds no live state. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple, Union + +from .errors import InvalidResourceError +from .gpu import GpuGroup, GpuType + +DEFAULT_WORKERS: Tuple[int, int] = (0, 3) + +SCALER_TYPES = frozenset({"QUEUE_DELAY", "REQUEST_COUNT"}) +DEFAULT_SCALER_VALUE = 4 + +# cuda versions the platform can filter hosts by +CUDA_VERSIONS = frozenset( + { + "11.8", + "12.0", + "12.1", + "12.2", + "12.3", + "12.4", + "12.5", + "12.6", + "12.7", + "12.8", + "12.9", + "13.0", + } +) + + +class ResourceKind(str, Enum): + QUEUE = "queue" + """queue-based serverless endpoint.""" + + API = "api" + """load-balanced serverless endpoint with http routes.""" + + TASK = "task" + """ephemeral pod compute, provisioned per call.""" + + +@dataclass(frozen=True) +class RouteSpec: + """one http route on an api resource.""" + + method: str + path: str + handler_name: str + + +def normalize_workers(workers: Union[int, Tuple[int, int], None]) -> Tuple[int, int]: + """int n -> (0, n); (min, max) passes through; None -> default.""" + if workers is None: + return DEFAULT_WORKERS + if isinstance(workers, int): + parsed = (DEFAULT_WORKERS[0], workers) + elif isinstance(workers, (tuple, list)) and len(workers) == 2: + parsed = (int(workers[0]), int(workers[1])) + else: + raise InvalidResourceError( + f"workers must be an int or (min, max) tuple, got {workers!r}" + ) + min_w, max_w = parsed + if min_w < 0 or max_w < 1: + raise InvalidResourceError(f"invalid worker range ({min_w}, {max_w})") + if min_w > max_w: + raise InvalidResourceError( + f"workers min ({min_w}) cannot exceed max ({max_w})" + ) + return (min_w, max_w) + + +def normalize_gpu( + gpu: Union[GpuGroup, GpuType, str, List[Union[GpuGroup, GpuType, str]], None], +) -> Optional[List[str]]: + """normalize gpu input to a list of api-facing string values.""" + if gpu is None: + return None + if not isinstance(gpu, list): + gpu = [gpu] + from .gpu import resolve_gpu_string + + out: List[str] = [] + for g in gpu: + if isinstance(g, (GpuGroup, GpuType)): + out.append(g.value) + elif isinstance(g, str): + # strings resolve strictly: pool ids and device names pass + # through, shorthands ("4090", "B200") expand, and typos + # fail at decoration time instead of at the api + out.extend(resolve_gpu_string(g)) + else: + raise InvalidResourceError( + f"gpu must be a GpuGroup, GpuType, or string, got {type(g).__name__}" + ) + return out + + +def normalize_cpu( + cpu: Union[str, List[str], None], +) -> Optional[List[str]]: + """normalize cpu instance ids to a list of strings.""" + if cpu is None: + return None + if isinstance(cpu, str): + return [cpu] + if isinstance(cpu, list): + return [str(c) for c in cpu] + raise InvalidResourceError( + f"cpu must be an instance id string or list, got {type(cpu).__name__}" + ) + + +def normalize_scaler_type(scaler_type: Optional[str]) -> Optional[str]: + """validate a scaler type string, tolerating lowercase.""" + if scaler_type is None: + return None + normalized = str(scaler_type).strip().upper() + if normalized not in SCALER_TYPES: + raise InvalidResourceError( + f"scaler_type must be one of {sorted(SCALER_TYPES)}, " + f"got {scaler_type!r}" + ) + return normalized + + +def normalize_cuda_version(version: Optional[str]) -> Optional[str]: + """validate a minimum cuda version string.""" + if version is None: + return None + normalized = str(version).strip() + if normalized not in CUDA_VERSIONS: + raise InvalidResourceError( + f"min_cuda_version must be one of " + f"{', '.join(sorted(CUDA_VERSIONS))}, got {version!r}" + ) + return normalized + + +@dataclass +class ResourceSpec: + """declarative config for one app resource.""" + + kind: ResourceKind + name: str + gpu: Optional[List[str]] = None + cpu: Optional[List[str]] = None + gpu_count: int = 1 + workers: Tuple[int, int] = DEFAULT_WORKERS + idle_timeout: int = 60 + dependencies: Optional[List[str]] = None + system_dependencies: Optional[List[str]] = None + volume: Optional[Any] = None + env: Optional[Dict[str, Any]] = None + datacenter: Optional[List[str]] = None + image: Optional[str] = None + registry_auth: Optional[str] = None + model: Optional[Any] = None + schedule: Optional[str] = None + max_concurrency: int = 1 + execution_timeout_ms: int = 0 + flashboot: bool = True + scaler_type: Optional[str] = None + scaler_value: int = DEFAULT_SCALER_VALUE + min_cuda_version: Optional[str] = None + accelerate_downloads: bool = True + container_disk_gb: Optional[int] = None + routes: List[RouteSpec] = field(default_factory=list) + asgi_factory: Optional[str] = None + + def __post_init__(self) -> None: + if self.gpu is not None and self.cpu is not None: + raise InvalidResourceError( + f"resource '{self.name}': gpu and cpu are mutually exclusive" + ) + if not self.name: + raise InvalidResourceError("resource name must not be empty") + if self.max_concurrency < 1: + raise InvalidResourceError( + f"resource '{self.name}': max_concurrency must be >= 1, " + f"got {self.max_concurrency}" + ) + if self.execution_timeout_ms < 0: + raise InvalidResourceError( + f"resource '{self.name}': execution_timeout_ms must be >= 0" + ) + if self.scaler_value < 1: + raise InvalidResourceError( + f"resource '{self.name}': scaler_value must be >= 1" + ) + if self.container_disk_gb is not None and self.container_disk_gb < 1: + raise InvalidResourceError( + f"resource '{self.name}': container_disk_gb must be >= 1" + ) + if self.min_cuda_version is not None and self.is_cpu: + raise InvalidResourceError( + f"resource '{self.name}': min_cuda_version has no effect " + f"on cpu resources" + ) + if self.model is not None and self.kind is ResourceKind.TASK: + raise InvalidResourceError( + f"resource '{self.name}': platform-cached models are only " + f"available on queue and api resources; tasks download " + f"weights themselves" + ) + + @property + def is_cpu(self) -> bool: + return self.cpu is not None + + @property + def effective_scaler_type(self) -> str: + """the scaler the endpoint deploys with: explicit or kind default.""" + if self.scaler_type: + return self.scaler_type + return ( + "REQUEST_COUNT" if self.kind is ResourceKind.API else "QUEUE_DELAY" + ) + + def to_manifest(self) -> Dict[str, Any]: + """serialize for the deploy manifest.""" + data: Dict[str, Any] = { + "kind": self.kind.value, + "name": self.name, + "gpuCount": self.gpu_count, + "workersMin": self.workers[0], + "workersMax": self.workers[1], + "idleTimeout": self.idle_timeout, + } + if self.gpu is not None: + data["gpus"] = self.gpu + if self.cpu is not None: + data["instanceIds"] = self.cpu + if self.dependencies: + data["dependencies"] = self.dependencies + if self.system_dependencies: + data["systemDependencies"] = self.system_dependencies + if self.volume: + data["networkVolume"] = getattr( + self.volume, "name", None + ) or str(self.volume) + if self.env: + from .secret import render_env + + data["env"] = render_env(self.env) + if self.datacenter: + data["locations"] = ",".join(self.datacenter) + if self.image: + data["imageName"] = self.image + if self.registry_auth: + data["registryAuth"] = self.registry_auth + if self.model: + from .model import model_reference + + data["model"] = model_reference(self.model) + if self.schedule: + data["schedule"] = self.schedule + if self.max_concurrency != 1: + data["maxConcurrency"] = self.max_concurrency + if self.execution_timeout_ms: + data["executionTimeoutMs"] = self.execution_timeout_ms + if not self.flashboot: + data["flashboot"] = False + if self.scaler_type: + data["scalerType"] = self.scaler_type + if self.scaler_value != DEFAULT_SCALER_VALUE: + data["scalerValue"] = self.scaler_value + if self.min_cuda_version: + data["minCudaVersion"] = self.min_cuda_version + if not self.accelerate_downloads: + data["accelerateDownloads"] = False + if self.container_disk_gb: + data["containerDiskGb"] = self.container_disk_gb + if self.routes: + data["routes"] = [ + {"method": r.method, "path": r.path, "handler": r.handler_name} + for r in self.routes + ] + if self.asgi_factory: + data["asgiFactory"] = self.asgi_factory + return data diff --git a/runpod/apps/stubs.py b/runpod/apps/stubs.py new file mode 100644 index 00000000..04579d01 --- /dev/null +++ b/runpod/apps/stubs.py @@ -0,0 +1,150 @@ +"""stubs for resources deployed from other codebases. + +when you know a resource exists but don't have its source, a stub gives +you the same invocation surface as a local handle: + + external_queue = runpod.Queue(app="other-app", name="transcribe") + external_queue.remote(audio_url="https://...") + + external_api = runpod.Api(app="other-app", name="inference") + external_api.post("/generate", {"prompt": "hi"}) + +resolution goes through the same server-side sentinel path as deployed +handles, so a stub is just a handle without a function body. +""" + +import os +from typing import Any, AsyncIterator, Optional + +from .invoker import Invoker, StreamInvoker +from .job import Job +from .targets import SentinelTarget + +DEFAULT_ENV = "default" + + +class _StubBase: + def __init__( + self, + *, + app: str, + name: Optional[str] = None, + id: Optional[str] = None, + env: Optional[str] = None, + ): + if (name is None) == (id is None): + raise ValueError("provide exactly one of name= or id=") + self.app_name = app + self.resource_name = name + self.resource_id = id + self.env = env or os.getenv("FLASH_ENVIRONMENT") or DEFAULT_ENV + + def _target(self) -> SentinelTarget: + # id-based stubs still route through the sentinel; the id is + # passed as the resource key and resolved server-side + resource = self.resource_name or self.resource_id or "" + return SentinelTarget(self.app_name, self.env, resource) + + +class Queue(_StubBase): + """client for a queue resource deployed elsewhere.""" + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + self._job_options: dict = {} + self.remote = Invoker(self._remote_async) + self.stream = StreamInvoker(self._stream_async) + self.spawn = Invoker(self._spawn_async) + self.job = Invoker(self._job_async) + + def with_options( + self, + *, + webhook: Optional[str] = None, + execution_timeout: Optional[int] = None, + ttl: Optional[int] = None, + low_priority: Optional[bool] = None, + s3_config: Optional[dict] = None, + ) -> "Queue": + """bind per-job options for the next call, returning a new stub. + + options apply per invocation and stack across chained calls; the + original stub is untouched. + """ + from .targets import build_job_options, merge_job_options + + new_options = build_job_options( + webhook, execution_timeout, ttl, low_priority, s3_config + ) + clone = Queue( + app=self.app_name, + name=self.resource_name, + id=self.resource_id, + env=self.env, + ) + clone._job_options = merge_job_options(self._job_options, new_options) + return clone + + def _payload(self, kwargs: dict) -> dict: + payload = {"input": kwargs or {"__empty": True}} + if self._job_options: + payload.update(self._job_options) + return payload + + async def _remote_async(self, **kwargs: Any) -> Any: + return await self._target().invoke(self._payload(kwargs)) + + async def _spawn_async(self, **kwargs: Any) -> Job: + target = self._target() + data = await target.submit(self._payload(kwargs)) + return Job(data, target) + + async def _stream_async(self, **kwargs: Any) -> AsyncIterator[Any]: + target = self._target() + data = await target.submit(self._payload(kwargs)) + async for chunk in target.stream_job(data["id"]): + yield chunk + + async def _job_async(self, job_id: str) -> Job: + return Job( + {"id": job_id, "status": "UNKNOWN"}, + self._target(), + ) + + def __repr__(self) -> str: + ref = self.resource_name or self.resource_id + return f"" + + +class _StubRouteCaller: + __slots__ = ("_stub", "_method") + + def __init__(self, stub: "Api", method: str): + self._stub = stub + self._method = method + + def __call__(self, path: str, body: Any = None, **kwargs: Any) -> Any: + from .context import block + + return block(self.aio(path, body, **kwargs)) + + async def aio(self, path: str, body: Any = None, **kwargs: Any) -> Any: + return await self._stub._target().request( + self._method, path, body, **kwargs + ) + + +class Api(_StubBase): + """client for an api resource deployed elsewhere.""" + + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + self.get = _StubRouteCaller(self, "GET") + self.post = _StubRouteCaller(self, "POST") + self.put = _StubRouteCaller(self, "PUT") + self.delete = _StubRouteCaller(self, "DELETE") + self.patch = _StubRouteCaller(self, "PATCH") + + def __repr__(self) -> str: + ref = self.resource_name or self.resource_id + return f"" diff --git a/runpod/apps/targets.py b/runpod/apps/targets.py new file mode 100644 index 00000000..08a9136c --- /dev/null +++ b/runpod/apps/targets.py @@ -0,0 +1,869 @@ +"""invocation targets: where a `.remote()` call actually goes. + +a target is resolved per-call by App._resolve and knows how to reach one +deployed resource. all resolution is server-side (sentinel headers or the +runpod api); no local state is kept. +""" + +import inspect +import json +import os +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator, Callable, Dict, Optional + +import aiohttp + +from ..user_agent import USER_AGENT +from .errors import EndpointNotFound, RemoteExecutionError +from .protocol import FORMAT_JSON, FunctionRequest, FunctionResponse +from .spec import ResourceSpec + +# the sentinel pseudo-endpoint id; ai-api resolves the real endpoint from +# the X-Flash-App / X-Flash-Environment / X-Flash-Endpoint headers. +SENTINEL_ID = "flash" + +DEFAULT_TIMEOUT_SECONDS = 300.0 + +# terminal job statuses on the queue data plane +FINAL_STATUSES = frozenset({"COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"}) + + +def _api_key() -> str: + key = os.getenv("RUNPOD_API_KEY") + if not key: + import runpod + + key = runpod.api_key + if not key: + raise RuntimeError( + "no api key configured. run `rp login` or set RUNPOD_API_KEY." + ) + return key + + +def _endpoint_url_base() -> str: + import runpod + + return runpod.endpoint_url_base.rstrip("/") + + +def _lb_domain() -> str: + """host portion of the data-plane base url, for lb subdomain urls.""" + base = _endpoint_url_base() + host = base.split("://", 1)[-1] + return host.split("/", 1)[0] + + +def _headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + headers = { + "Authorization": f"Bearer {_api_key()}", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + } + if extra: + headers.update(extra) + return headers + + +def args_to_input(fn: Callable, args: tuple, kwargs: dict) -> Dict[str, Any]: + """map positional args onto parameter names so the job input is a dict.""" + sig = inspect.signature(fn) + params = [n for n in sig.parameters if n not in ("self", "cls")] + body: Dict[str, Any] = {} + for i, arg in enumerate(args): + if i >= len(params): + raise TypeError( + f"{fn.__name__}() got {len(args)} positional args, " + f"expected at most {len(params)}" + ) + body[params[i]] = arg + body.update(kwargs) + # the platform strips empty input dicts from jobs; keep one field so + # the worker's job polling never sees a missing input + if not body: + body = {"__empty": True} + return body + + +def build_job_options( + webhook: Optional[str] = None, + execution_timeout: Optional[int] = None, + ttl: Optional[int] = None, + low_priority: Optional[bool] = None, + s3_config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """shape per-job queue options into the wire payload. + + these ride alongside `input` on the run/runsync request, matching + the raw data-plane api's job payload. + """ + options: Dict[str, Any] = {} + if webhook is not None: + options["webhook"] = webhook + policy: Dict[str, Any] = {} + if execution_timeout is not None: + policy["executionTimeout"] = execution_timeout + if ttl is not None: + policy["ttl"] = ttl + if low_priority is not None: + policy["lowPriority"] = low_priority + if policy: + options["policy"] = policy + if s3_config is not None: + options["s3Config"] = s3_config + return options + + +def merge_job_options( + base: Dict[str, Any], new: Dict[str, Any] +) -> Dict[str, Any]: + """combine two option sets; policy fields accumulate rather than + overwrite so chained with_options calls compose.""" + merged = dict(base) + for key, value in new.items(): + if key == "policy" and isinstance(merged.get("policy"), dict): + merged["policy"] = {**merged["policy"], **value} + else: + merged[key] = value + return merged + + +def unwrap_job_output(data: Dict[str, Any]) -> Any: + """extract output from a runsync-style response, raising on failure.""" + if data.get("status") == "FAILED" or data.get("error"): + err = data.get("error") or data.get("output", {}).get("error", "unknown") + raise RemoteExecutionError(f"remote execution failed: {err}") + output = data.get("output", data) + if isinstance(output, dict) and "error" in output: + raise RemoteExecutionError(f"remote execution failed: {output['error']}") + return output + + +class InvocationTarget(ABC): + """one resolved destination for remote calls. + + each target owns its payload shape: sentinel targets send plain + kwargs (the deployed worker resolves functions from the unpacked + build), live targets send the full FunctionRequest with source. + """ + + def build_payload( + self, fn: Callable, spec: ResourceSpec, args: tuple, kwargs: dict + ) -> Dict[str, Any]: + return {"input": args_to_input(fn, args, kwargs)} + + def unwrap(self, data: Dict[str, Any]) -> Any: + return unwrap_job_output(data) + + @abstractmethod + async def invoke(self, payload: Dict[str, Any], *, timeout: float) -> Any: + """submit and wait for the result.""" + + @abstractmethod + async def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """submit without waiting; returns the raw job data.""" + + async def job_status(self, job_id: str) -> Dict[str, Any]: + """fetch the current state of a submitted job.""" + raise NotImplementedError( + f"{type(self).__name__} does not support job status" + ) + + async def cancel_job(self, job_id: str) -> Dict[str, Any]: + """cancel a submitted job.""" + raise NotImplementedError( + f"{type(self).__name__} does not support job cancellation" + ) + + async def retry_job(self, job_id: str) -> Dict[str, Any]: + """retry a failed or timed-out job.""" + raise NotImplementedError( + f"{type(self).__name__} does not support job retries" + ) + + def stream_job( + self, job_id: str, *, timeout: float + ) -> AsyncIterator[Any]: + """yield partial outputs of a generator job as they arrive.""" + raise NotImplementedError( + f"{type(self).__name__} does not support job streaming" + ) + + async def wait( + self, + job_data: Dict[str, Any], + *, + timeout: float, + on_status: Optional[Callable[[Dict[str, Any]], None]] = None, + ) -> Any: + """wait for a submitted job to finish and return its output.""" + raise NotImplementedError( + f"{type(self).__name__} does not support job polling" + ) + + async def request( + self, method: str, path: str, body: Any = None, *, timeout: float + ) -> Any: + """plain http call (lb resources only).""" + raise NotImplementedError(f"{type(self).__name__} does not serve http routes") + + +# transient statuses worth retrying: gateway/edge errors that occur +# while the sentinel cache warms or an edge node hiccups. 4xx (other +# than 429) are never retried; the request itself is wrong. +RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504, 520, 522, 524}) +RETRY_ATTEMPTS = 4 +RETRY_BASE_DELAY = 0.5 + + +async def _request_json( + method: str, + url: str, + headers: Dict[str, str], + timeout: float, + *, + payload: Any = None, + app_name: str = "", + resource_name: str = "", +) -> Dict[str, Any]: + """http json call with exponential backoff on transient failures.""" + import asyncio + + client_timeout = aiohttp.ClientTimeout(total=timeout) + last_exc: Optional[Exception] = None + for attempt in range(RETRY_ATTEMPTS): + if attempt: + await asyncio.sleep(RETRY_BASE_DELAY * (2 ** (attempt - 1))) + try: + async with aiohttp.ClientSession(timeout=client_timeout) as session: + async with session.request( + method, url, json=payload, headers=headers + ) as resp: + if resp.status == 404: + raise EndpointNotFound(app_name, resource_name) + if resp.status in RETRYABLE_STATUSES: + last_exc = aiohttp.ClientResponseError( + resp.request_info, + resp.history, + status=resp.status, + message=await resp.text(), + ) + continue + resp.raise_for_status() + return await resp.json() + except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as exc: + # connection-level failures (reset, ssl hiccup, dns) are + # transient by nature; response-level errors above already + # decided retryability + last_exc = exc + continue + if last_exc is None: # pragma: no cover - loop always runs once + raise RuntimeError("request retry loop exited cleanly") + raise last_exc + + +async def _post_json( + url: str, + payload: Any, + headers: Dict[str, str], + timeout: float, + *, + app_name: str = "", + resource_name: str = "", +) -> Dict[str, Any]: + return await _request_json( + "POST", + url, + headers, + timeout, + payload=payload, + app_name=app_name, + resource_name=resource_name, + ) + + +async def _get_json( + url: str, headers: Dict[str, str], timeout: float +) -> Dict[str, Any]: + return await _request_json("GET", url, headers, timeout) + + +async def _wait_terminal( + base_url: str, + data: Dict[str, Any], + headers: Dict[str, str], + timeout: float, + on_status: Optional[Callable[[Dict[str, Any]], None]] = None, +) -> Dict[str, Any]: + """poll /status until the job reaches a terminal state. + + runsync returns early (e.g. IN_QUEUE) when the job outlives the sync + window, typically on cold starts; polling covers the rest. on_status, + when given, sees every payload (observability hooks read workerId). + """ + import asyncio + import time + + if on_status is not None: + on_status(data) + deadline = time.monotonic() + timeout + interval = 0.5 + # observed polls (dev sessions) stay tight so the worker id + # surfaces fast enough for log streams to attach in realtime + max_interval = 1.0 if on_status is not None else 5.0 + while data.get("status") not in FINAL_STATUSES: + job_id = data.get("id") + if not job_id: + return data + if time.monotonic() >= deadline: + raise TimeoutError( + f"job {job_id} did not complete within {timeout}s " + f"(last status: {data.get('status', 'UNKNOWN')})" + ) + await asyncio.sleep(interval) + interval = min(interval * 1.5, max_interval) + data = await _get_json(f"{base_url}/status/{job_id}", headers, 30.0) + if on_status is not None: + on_status(data) + return data + + +class QueueClient: + """queue data-plane client for a single endpoint id. + + owns the run/runsync/status/cancel/retry routes and the lb subdomain + for http resources. targets compose it with their own routing + headers: the sentinel client carries flash headers, a live client + carries plain auth headers. + """ + + def __init__( + self, + endpoint_id: str, + headers: Callable[[], Dict[str, str]], + *, + app_name: str = "", + resource_name: str = "", + ): + self.endpoint_id = endpoint_id + self._headers = headers + self._app_name = app_name + self._resource_name = resource_name + + @property + def base_url(self) -> str: + return f"{_endpoint_url_base()}/{self.endpoint_id}" + + async def _call( + self, + method: str, + path: str, + payload: Any = None, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> Dict[str, Any]: + return await _request_json( + method, + f"{self.base_url}/{path}", + self._headers(), + timeout, + payload=payload, + app_name=self._app_name, + resource_name=self._resource_name, + ) + + async def run(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return await self._call("POST", "run", payload) + + async def runsync( + self, payload: Dict[str, Any], *, timeout: float + ) -> Dict[str, Any]: + return await self._call("POST", "runsync", payload, timeout) + + async def status(self, job_id: str) -> Dict[str, Any]: + return await self._call("GET", f"status/{job_id}") + + async def cancel(self, job_id: str) -> Dict[str, Any]: + return await self._call("POST", f"cancel/{job_id}") + + async def retry(self, job_id: str) -> Dict[str, Any]: + return await self._call("POST", f"retry/{job_id}") + + async def stream( + self, job_id: str, *, timeout: float + ) -> AsyncIterator[Any]: + """yield partial outputs from /stream until the job is terminal. + + the route long-polls (wait=) and drains buffered chunks, so each + call returns quickly with whatever is available. + """ + import time + + deadline = time.monotonic() + timeout + while True: + if time.monotonic() >= deadline: + raise TimeoutError( + f"job {job_id} stream did not complete within {timeout}s" + ) + data = await self._call("GET", f"stream/{job_id}") + if data.get("status") == "FAILED" or data.get("error"): + unwrap_job_output(data) + for chunk in data.get("stream") or []: + yield chunk.get("output") + if data.get("status") in FINAL_STATUSES: + return + + async def wait( + self, + data: Dict[str, Any], + *, + timeout: float, + on_status: Optional[Callable[[Dict[str, Any]], None]] = None, + ) -> Dict[str, Any]: + return await _wait_terminal( + self.base_url, data, self._headers(), timeout, on_status + ) + + async def http( + self, + method: str, + path: str, + body: Any = None, + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> Any: + """plain http call through the endpoint's lb subdomain.""" + url = f"https://{self.endpoint_id}.{_lb_domain()}{path}" + client_timeout = aiohttp.ClientTimeout(total=timeout) + async with aiohttp.ClientSession(timeout=client_timeout) as session: + async with session.request( + method, url, json=body, headers=self._headers() + ) as resp: + if resp.status == 404 and self._app_name: + raise EndpointNotFound(self._app_name, self._resource_name) + resp.raise_for_status() + text = await resp.text() + try: + return json.loads(text) + except json.JSONDecodeError: + return text + + +class SentinelTarget(InvocationTarget): + """routes through the sentinel endpoint; ai-api resolves the real + endpoint server-side from app/env/resource headers. + + used for deployed resources from every context (worker-to-worker and + local caller alike), which is what makes resolution stateless. + """ + + def __init__(self, app_name: str, env_name: str, resource_name: str): + self.app_name = app_name + self.env_name = env_name + self.resource_name = resource_name + self._client = QueueClient( + SENTINEL_ID, + self._sentinel_headers, + app_name=app_name, + resource_name=resource_name, + ) + + def _sentinel_headers(self) -> Dict[str, str]: + return _headers( + { + "X-Flash-App": self.app_name, + "X-Flash-Environment": self.env_name, + "X-Flash-Endpoint": self.resource_name, + } + ) + + async def invoke( + self, payload: Dict[str, Any], *, timeout: float = DEFAULT_TIMEOUT_SECONDS + ) -> Any: + data = await self._client.runsync(payload, timeout=timeout) + data = await self._client.wait(data, timeout=timeout) + return self.unwrap(data) + + async def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return await self._client.run(payload) + + async def job_status(self, job_id: str) -> Dict[str, Any]: + return await self._client.status(job_id) + + async def cancel_job(self, job_id: str) -> Dict[str, Any]: + return await self._client.cancel(job_id) + + async def retry_job(self, job_id: str) -> Dict[str, Any]: + return await self._client.retry(job_id) + + def stream_job( + self, job_id: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS + ) -> AsyncIterator[Any]: + return self._client.stream(job_id, timeout=timeout) + + async def wait( + self, + job_data: Dict[str, Any], + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + on_status: Optional[Callable[[Dict[str, Any]], None]] = None, + ) -> Any: + data = await self._client.wait( + job_data, timeout=timeout, on_status=on_status + ) + return self.unwrap(data) + + async def request( + self, + method: str, + path: str, + body: Any = None, + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> Any: + return await self._client.http(method, path, body, timeout=timeout) + + +class LiveTarget(InvocationTarget): + """a dev-session live endpoint: source code ships with every request, + so the worker executes whatever is currently on disk. + + provisioning of the live endpoint itself is owned by the dev session + (see runpod.apps.dev); this target only speaks to an endpoint id. + when an event sink is attached, each call gets a dispatch line, + live worker status/log feed, and a completion line. + """ + + def __init__( + self, + endpoint_id: str, + resource_name: str = "", + events: Optional[object] = None, + metrics_key: Optional[str] = None, + ): + self.endpoint_id = endpoint_id + self.resource_name = resource_name + self.events = events + self.metrics_key = metrics_key + self._client = QueueClient(endpoint_id, _headers) + self._source_target: Optional[Any] = None + self._source_resource: str = "" + self._source_spec: Optional[ResourceSpec] = None + # source hash the worker last confirmed; None forces a sync + self._synced_hash: Optional[str] = None + + def attach_source( + self, target: Any, resource: str, spec: Optional[ResourceSpec] = None + ) -> None: + """register the object whose module backs this api resource. + + live api endpoints materialize their routes from module source + pushed over /_runpod/sync (once per source change), keeping dev + behavior identical to deployed. the spec rides along so the + worker can install the resource's dependencies before @init. + """ + self._source_target = target + self._source_resource = resource + self._source_spec = spec + + async def _sync_source(self, timeout: float) -> None: + """push the current module source to the worker if it changed.""" + if self._source_target is None: + return + import hashlib + + from .serialization import get_function_source + + source = get_function_source(self._source_target) + digest = hashlib.sha256(source.encode()).hexdigest() + if digest == self._synced_hash: + return + url = f"https://{self.endpoint_id}.{_lb_domain()}/_runpod/sync" + payload: Dict[str, Any] = { + "source": source, + "resource": self._source_resource, + } + if self._source_spec is not None: + payload["dependencies"] = self._source_spec.dependencies + payload["system_dependencies"] = ( + self._source_spec.system_dependencies + ) + await _post_json(url, payload, _headers(), timeout) + self._synced_hash = digest + + def build_payload( + self, fn: Callable, spec: ResourceSpec, args: tuple, kwargs: dict + ) -> Dict[str, Any]: + from .serialization import get_function_source, require_json_args + + # queue arguments are plain json, the same contract deployed + # workers serve; non-json arguments fail here in dev exactly as + # they would against a deployed endpoint + require_json_args(fn, args, kwargs) + request = FunctionRequest( + function_name=fn.__name__, + function_code=get_function_source(fn), + args=list(args), + kwargs=dict(kwargs), + dependencies=spec.dependencies, + system_dependencies=spec.system_dependencies, + accelerate_downloads=spec.accelerate_downloads, + serialization_format=FORMAT_JSON, + ) + return {"input": request.to_input()} + + def unwrap(self, data: Dict[str, Any]) -> Any: + output = unwrap_job_output(data) + # the live handler is a generator handler, so job output is an + # aggregated list: one plain response for normal functions, + # __stream__-marked chunk envelopes for generator functions + if isinstance(output, list): + if ( + len(output) == 1 + and isinstance(output[0], dict) + and not output[0].get("__stream__") + ): + return self._unwrap_response(output[0]) + return [self._unwrap_response(o) for o in output] + return self._unwrap_response(output) + + def _unwrap_response(self, output: Any) -> Any: + if isinstance(output, dict) and "success" in output: + response = FunctionResponse.from_output(output) + if not response.success: + raise RemoteExecutionError( + f"remote execution failed: {response.error or 'unknown'}" + ) + if response.result is not None: + from .serialization import deserialize_result + + return deserialize_result(response.result) + return response.json_result + return output + + def _monitor(self): + if self.events is None: + return None + from .monitor import WorkerMonitor + + return WorkerMonitor( + self.endpoint_id, + self.resource_name, + self.events, + metrics_key=self.metrics_key, + ) + + async def invoke( + self, payload: Dict[str, Any], *, timeout: float = DEFAULT_TIMEOUT_SECONDS + ) -> Any: + import time + + from .monitor import emit + + monitor = self._monitor() + emit(self.events, "dispatch", self.resource_name) + start = time.monotonic() + if monitor is not None: + await monitor.start() + try: + if monitor is not None: + # async submit + status polling: runsync would hold the + # connection, hiding the workerId until completion and + # starving the live worker feed + data = await self._client.run(payload) + else: + data = await self._client.runsync(payload, timeout=timeout) + data = await self._client.wait( + data, + timeout=timeout, + on_status=monitor.on_status if monitor else None, + ) + result = self.unwrap(data) + except Exception: + emit( + self.events, + "request_failed", + self.resource_name, + time.monotonic() - start, + ) + raise + finally: + if monitor is not None: + await monitor.stop() + emit( + self.events, + "request_completed", + self.resource_name, + time.monotonic() - start, + ) + return result + + async def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return await self._client.run(payload) + + async def job_status(self, job_id: str) -> Dict[str, Any]: + return await self._client.status(job_id) + + async def cancel_job(self, job_id: str) -> Dict[str, Any]: + return await self._client.cancel(job_id) + + async def retry_job(self, job_id: str) -> Dict[str, Any]: + return await self._client.retry(job_id) + + async def stream_job( + self, job_id: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS + ) -> AsyncIterator[Any]: + # live workers stream serialized chunk envelopes through the + # same /stream route deployed workers use + async for chunk in self._client.stream(job_id, timeout=timeout): + yield self._unwrap_response(chunk) + + async def wait( + self, + job_data: Dict[str, Any], + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + on_status: Optional[Callable[[Dict[str, Any]], None]] = None, + ) -> Any: + data = await self._client.wait( + job_data, timeout=timeout, on_status=on_status + ) + return self.unwrap(data) + + async def request( + self, + method: str, + path: str, + body: Any = None, + *, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + ) -> Any: + import time + + from .monitor import emit + + emit(self.events, "dispatch", self.resource_name, f"{method} {path}") + start = time.monotonic() + try: + await self._sync_source(timeout) + result = await self._client.http(method, path, body, timeout=timeout) + except Exception: + emit( + self.events, + "request_failed", + self.resource_name, + time.monotonic() - start, + ) + raise + emit( + self.events, + "request_completed", + self.resource_name, + time.monotonic() - start, + ) + return result + + +class PodTarget(InvocationTarget): + """ephemeral pod execution for @app.task: provision a pod, run the + function body, collect the result, terminate. + + tasks run to completion far beyond the queue data plane's sync + window, so the transport is a direct http channel to a single-shot + runner on the pod (see runpod.apps.tasks). + """ + + # tasks default to a long window; the pod's terminateAfter is the backstop + TASK_TIMEOUT_SECONDS = 3600.0 + + def __init__( + self, + spec: ResourceSpec, + fn: Callable, + events: Optional[object] = None, + ): + self.spec = spec + self.fn = fn + self.events = events + + def build_payload( + self, fn: Callable, spec: ResourceSpec, args: tuple, kwargs: dict + ) -> Dict[str, Any]: + from .serialization import ( + get_function_source, + serialize_args, + serialize_kwargs, + ) + + request = FunctionRequest( + function_name=fn.__name__, + function_code=get_function_source(fn), + args=serialize_args(args), + kwargs=serialize_kwargs(kwargs), + dependencies=spec.dependencies, + system_dependencies=spec.system_dependencies, + accelerate_downloads=spec.accelerate_downloads, + ) + return request.to_input() + + async def invoke( + self, payload: Dict[str, Any], *, timeout: float = TASK_TIMEOUT_SECONDS + ) -> Any: + import time + + from .monitor import emit + from .tasks import TaskExecution, unwrap_task_response + + name = self.spec.name + hardware = ",".join(self.spec.cpu or self.spec.gpu or ["any"]) + emit(self.events, "dispatch", name) + emit(self.events, "task_status", name, f"provisioning pod on {hardware}") + start = time.monotonic() + + stream = None + execution = TaskExecution(self.spec) + try: + await execution.start() + if execution.pod_id: + emit( + self.events, + "task_status", + name, + f"pod {execution.pod_id[:12]} waiting for runtime", + ) + if self.events is not None: + from .monitor import PodLogStream + + stream = PodLogStream(execution.pod_id, name, self.events) + stream.attach() + await execution.wait_ready() + emit(self.events, "worker_ready", name, execution.pod_id or "") + response = await execution.execute(payload, timeout) + except Exception: + emit( + self.events, + "request_failed", + name, + time.monotonic() - start, + ) + raise + finally: + if stream is not None: + await stream.stop() + await execution.terminate() + result = unwrap_task_response(response) + emit( + self.events, + "request_completed", + name, + time.monotonic() - start, + ) + return result + + async def submit(self, payload: Dict[str, Any]) -> Any: + from .tasks import TaskExecution, TaskJob + + execution = TaskExecution(self.spec) + await execution.start() + await execution.wait_ready() + await execution.submit(payload) + return TaskJob(execution) diff --git a/runpod/apps/tasks.py b/runpod/apps/tasks.py new file mode 100644 index 00000000..cb46fc6d --- /dev/null +++ b/runpod/apps/tasks.py @@ -0,0 +1,376 @@ +"""ephemeral pod execution for @app.task. + +lifecycle per call: + 1. deploy a pod on a task runtime image (runpod/task*, built from + runtimes/task in this repo). custom images get the runner source + delivered base64-encoded in the pod env and booted via dockerArgs, + so any image with a python3 binary works. + 2. wait for the runner's /ping via the pod http proxy + 3. POST the FunctionRequest to /execute (remote) or /submit (spawn) + 4. collect the response, terminate the pod + +`terminateAfter` is set at deploy time as a server-side safety net so a +crashed client cannot leak a running pod indefinitely. +""" + +import asyncio +import base64 +import logging +import secrets +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +import aiohttp + +from .api import AppsApiClient +from .errors import RemoteExecutionError +from .spec import ResourceSpec + +log = logging.getLogger(__name__) + +TASK_PORT = 8080 + +# safety net: pods self-terminate server-side after this long +DEFAULT_MAX_LIFETIME = timedelta(hours=1) + +import os as _os + +READY_POLL_INTERVAL = 2.0 +READY_TIMEOUT = 600.0 +RESULT_POLL_INTERVAL = 2.0 + + +def _runner_source() -> str: + """single-file runner for bare-image bootstrap. + + the shared executor is concatenated ahead of the task server so the + shipped file has no runpod imports; the server's executor import + fails on the pod and falls through to the names already in scope. + """ + runtimes = Path(__file__).parent.parent / "runtimes" + executor = (runtimes / "executor.py").read_text() + server = (runtimes / "task" / "runner.py").read_text() + return f"{executor}\n\n{server}" + + +def _bootstrap_command() -> str: + """shell command that materializes and starts the runner.""" + from .shim import shell_launcher + + return shell_launcher("RUNPOD_TASK_RUNNER_B64", "/task_runner.py") + + +def cuda_versions_at_least(minimum: str) -> List[str]: + """all platform cuda versions >= minimum, ascending.""" + from .spec import CUDA_VERSIONS + + def _key(version: str): + major, minor = version.split(".") + return (int(major), int(minor)) + + floor = _key(minimum) + return sorted( + (v for v in CUDA_VERSIONS if _key(v) >= floor), key=_key + ) + + +def _device_names(gpu: Optional[List[str]]) -> List[str]: + """pods take exact device names, not pool ids; expand pool ids + (e.g. ADA_24 -> NVIDIA GeForce RTX 4090) and pass device names + through. no gpu constraint means any device, which the pod api + only understands as the full device list.""" + from .gpu import POOLS_TO_TYPES, GpuGroup + + if not gpu: + return sorted( + {t.value for types in POOLS_TO_TYPES.values() for t in types} + ) + names: List[str] = [] + for entry in gpu: + try: + names.extend(GpuGroup(entry).device_names() or [entry]) + except ValueError: + names.append(entry) + return names + + +def _proxy_url(pod_id: str) -> str: + return f"https://{pod_id}-{TASK_PORT}.proxy.runpod.net" + + +def _pod_input(spec: ResourceSpec, token: str, task_name: str) -> Dict[str, Any]: + """build the pod deploy input for one task execution. + + runtime images have the runner baked in (CMD starts it); custom + images bootstrap the runner from a base64 env payload via dockerArgs. + """ + terminate_after = ( + datetime.now(timezone.utc) + DEFAULT_MAX_LIFETIME + ).isoformat() + + from .secret import render_env + + env = { + "RUNPOD_TASK_TOKEN": token, + "RUNPOD_TASK_PORT": str(TASK_PORT), + **render_env(spec.env), + } + + from .images import image_for_spec, local_python_version + + pod: Dict[str, Any] = { + "name": f"task-{task_name}-{secrets.token_hex(4)}", + "imageName": image_for_spec( + spec, python_version=local_python_version() + ), + "ports": f"{TASK_PORT}/http", + "containerDiskInGb": spec.container_disk_gb + or (10 if spec.is_cpu else 30), + "terminateAfter": terminate_after, + "supportPublicIp": True, + } + + if spec.image: + # custom image: inject the runner source and boot it explicitly + env["RUNPOD_TASK_RUNNER_B64"] = base64.b64encode( + _runner_source().encode() + ).decode() + package_spec = _os.environ.get("RUNPOD_PACKAGE_SPEC") + if package_spec: + env["RUNPOD_PACKAGE_SPEC"] = package_spec + pod["dockerArgs"] = _bootstrap_command() + + pod["env"] = [{"key": k, "value": v} for k, v in env.items()] + if spec.datacenter: + pod["dataCenterIds"] = spec.datacenter + else: + # spread across the storage-supported set; a null DC can pin + # repeated deploys to one (possibly broken) machine + from .datacenter import CPU3_DATACENTERS, DataCenter + + pool = CPU3_DATACENTERS if spec.is_cpu else DataCenter.all() + pod["dataCenterIds"] = [dc.value for dc in pool] + if spec.is_cpu: + pod["instanceIds"] = spec.cpu + else: + pod["gpuTypeIdList"] = _device_names(spec.gpu) + pod["gpuCount"] = spec.gpu_count + if spec.min_cuda_version: + # pods have no min-version filter; allow every version at + # or above the requested floor + pod["allowedCudaVersions"] = cuda_versions_at_least( + spec.min_cuda_version + ) + return pod + + +class TaskExecution: + """one pod running one function.""" + + def __init__(self, spec: ResourceSpec, api: Optional[AppsApiClient] = None): + self.spec = spec + self.api = api or AppsApiClient() + self.token = secrets.token_urlsafe(32) + self.pod_id: Optional[str] = None + + @property + def _headers(self) -> Dict[str, str]: + return {"Authorization": f"Bearer {self.token}"} + + async def start(self) -> None: + pod = _pod_input(self.spec, self.token, self.spec.name) + if self.spec.registry_auth: + from .registry import resolve_registry_auth + + pod["containerRegistryAuthId"] = await resolve_registry_auth( + self.spec.registry_auth, api=self.api + ) + if self.spec.volume: + pod = await self._attach_volume(pod) + result = await self.api.deploy_task_pod( + pod, is_cpu=self.spec.is_cpu + ) + self.pod_id = result["id"] + log.info("task pod %s deployed for %s", self.pod_id, self.spec.name) + + async def _attach_volume(self, pod: Dict[str, Any]) -> Dict[str, Any]: + """resolve the task's volume and pin the pod to its datacenter.""" + from .volume import VolumeError, VolumeResolver, volume_list + + volumes = volume_list(self.spec.volume) + if len(volumes) > 1: + raise VolumeError( + f"task '{self.spec.name}': pods mount exactly one volume " + f"({len(volumes)} given)" + ) + resolver = VolumeResolver(self.api) + resolved = await resolver.resolve(volumes[0], [self.spec]) + from .volume import POD_MOUNT_PATH + + pod["networkVolumeId"] = resolved["id"] + # the host bind-mounts to this target; empty means a broken + # mount config and the container never starts + pod["volumeMountPath"] = str(POD_MOUNT_PATH) + pod["dataCenterIds"] = [resolved["dataCenterId"]] + return pod + + async def wait_ready(self, timeout: float = READY_TIMEOUT) -> None: + """poll the runner's /ping through the pod proxy until it answers.""" + url = f"{_proxy_url(self.pod_id)}/ping" + deadline = time.monotonic() + timeout + async with aiohttp.ClientSession() as session: + while time.monotonic() < deadline: + try: + async with session.get( + url, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: + if resp.status == 200: + return + except (aiohttp.ClientError, asyncio.TimeoutError): + # pod not reachable yet; the surrounding deadline + # decides when to stop trying + pass + await asyncio.sleep(READY_POLL_INTERVAL) + raise TimeoutError( + f"task pod {self.pod_id} did not become ready within {timeout}s" + ) + + async def execute(self, request: Dict[str, Any], timeout: float) -> Dict[str, Any]: + """run to completion: submit, then poll for the result. + + the pod proxy caps how long a single request can stay open + (long jobs would 524), so completion always goes through the + background slot + short /result polls. + """ + await self.submit(request) + deadline = time.monotonic() + timeout + while True: + if time.monotonic() >= deadline: + raise TimeoutError( + f"task on pod {self.pod_id} did not finish in {timeout}s" + ) + response = await self.poll_result() + if response is not None: + return response + await asyncio.sleep(RESULT_POLL_INTERVAL) + + async def submit(self, request: Dict[str, Any]) -> None: + """start in the background via /submit. + + the proxy answers 404 for pods an edge node has not learned + about yet; since this pod demonstrably exists (we created it + and /ping succeeded), 404s here are propagation races and are + retried briefly rather than surfaced. + """ + url = f"{_proxy_url(self.pod_id)}/submit" + attempts = 6 + async with aiohttp.ClientSession() as session: + for attempt in range(attempts): + async with session.post( + url, + json=request, + headers=self._headers, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 404 and attempt < attempts - 1: + await asyncio.sleep(2 * (attempt + 1)) + continue + resp.raise_for_status() + return + + async def poll_result(self) -> Optional[Dict[str, Any]]: + """fetch /result; returns the response dict once DONE, else None. + + the proxy answers 404 for pods an edge node has not learned + about yet (the same race /submit tolerates), so 404s are + retried briefly; a persistent 404 means the pod is gone and + surfaces as an error. + """ + url = f"{_proxy_url(self.pod_id)}/result" + attempts = 6 + data: Dict[str, Any] = {} + async with aiohttp.ClientSession() as session: + for attempt in range(attempts): + async with session.get( + url, + headers=self._headers, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 404 and attempt < attempts - 1: + await asyncio.sleep(2 * (attempt + 1)) + continue + resp.raise_for_status() + data = await resp.json() + break + if data.get("status") == "DONE": + return data.get("response") + return None + + async def terminate(self) -> None: + if self.pod_id is None: + return + try: + await self.api.terminate_pod(self.pod_id) + log.info("task pod %s terminated", self.pod_id) + except Exception as exc: # noqa: BLE001 - terminateAfter is the backstop + log.warning( + "failed to terminate task pod %s (terminateAfter is the " + "backstop): %s", + self.pod_id, + exc, + ) + self.pod_id = None + + +def unwrap_task_response(response: Dict[str, Any]) -> Any: + """extract the function result from a runner response.""" + if not response.get("success"): + raise RemoteExecutionError( + f"task execution failed: {response.get('error', 'unknown')}" + ) + if response.get("result") is not None: + from .serialization import deserialize_result + + return deserialize_result(response["result"]) + return response.get("json_result") + + +class TaskJob: + """handle for a spawned task; owns the pod until the result is read.""" + + def __init__(self, execution: TaskExecution): + self._execution = execution + self._result: Any = None + self._done = False + + @property + def pod_id(self) -> Optional[str]: + return self._execution.pod_id + + async def wait(self, timeout: Optional[float] = None) -> Any: + """block until the task finishes; terminates the pod and returns + the result.""" + deadline = time.monotonic() + timeout if timeout is not None else None + try: + while not self._done: + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError( + f"task on pod {self.pod_id} did not finish in {timeout}s" + ) + response = await self._execution.poll_result() + if response is not None: + self._result = unwrap_task_response(response) + self._done = True + break + await asyncio.sleep(RESULT_POLL_INTERVAL) + finally: + if self._done: + await self._execution.terminate() + return self._result + + async def cancel(self) -> None: + """terminate the pod, abandoning the task.""" + await self._execution.terminate() + self._done = True diff --git a/runpod/apps/utils/__init__.py b/runpod/apps/utils/__init__.py new file mode 100644 index 00000000..5d1311e9 --- /dev/null +++ b/runpod/apps/utils/__init__.py @@ -0,0 +1 @@ +"""shared helpers for the apps surface.""" diff --git a/runpod/apps/utils/client.py b/runpod/apps/utils/client.py new file mode 100644 index 00000000..44cca305 --- /dev/null +++ b/runpod/apps/utils/client.py @@ -0,0 +1,16 @@ +"""lazy control-plane client construction.""" + +from typing import Optional + + +def default_client(api: Optional[object] = None): + """return api when given, else a fresh AppsApiClient. + + the import is deferred so modules that only sometimes touch the + control plane do not pull it in at import time. + """ + if api is not None: + return api + from ..api import AppsApiClient + + return AppsApiClient() diff --git a/runpod/apps/utils/events.py b/runpod/apps/utils/events.py new file mode 100644 index 00000000..90cef362 --- /dev/null +++ b/runpod/apps/utils/events.py @@ -0,0 +1,20 @@ +"""duck-typed event sink dispatch.""" + +import logging +from typing import Any, Optional + +log = logging.getLogger(__name__) + + +def emit(sink: Optional[object], event: str, *args: Any) -> None: + """invoke the named handler on the sink if it exists. + + handler failures are swallowed and logged: a broken event + renderer must never break the operation it observes. + """ + handler = getattr(sink, event, None) + if handler is not None: + try: + handler(*args) + except Exception: # noqa: BLE001 - rendering must never break calls + log.debug("event sink %s failed", event, exc_info=True) diff --git a/runpod/apps/utils/lookup.py b/runpod/apps/utils/lookup.py new file mode 100644 index 00000000..7200dc94 --- /dev/null +++ b/runpod/apps/utils/lookup.py @@ -0,0 +1,26 @@ +"""shared name-or-id resolution for named platform resources.""" + +from typing import Any, Callable, Dict, List, Optional + + +def find_by_id_or_name( + items: List[Dict[str, Any]], + ref: str, + *, + noun: str, + error: Callable[[str], Exception], +) -> Optional[Dict[str, Any]]: + """find the item whose id equals ref, else the unique name match. + + returns None when nothing matches. raises error(message) when a + name matches more than one item, since the id is then needed to + disambiguate. + """ + for item in items: + if item["id"] == ref: + return item + matches = [item for item in items if item.get("name") == ref] + if len(matches) > 1: + ids = ", ".join(match["id"] for match in matches) + raise error(f"multiple {noun} named '{ref}' ({ids}); reference by id") + return matches[0] if matches else None diff --git a/runpod/apps/volume.py b/runpod/apps/volume.py new file mode 100644 index 00000000..610b19f8 --- /dev/null +++ b/runpod/apps/volume.py @@ -0,0 +1,205 @@ +"""network volumes: durable storage shared across resources. + +a Volume is a lazy reference by name, resolved (and created when +missing) at provision time. workers see the volume at /runpod-volume. + + models = runpod.Volume("models") # create if missing, 50GB + models = runpod.Volume("models", size=100) + + @app.task(gpu="4090", volume=models) + def train(): + torch.save(sd, models.path / "model.pt") + +placement: a volume lives in exactly one datacenter, so everything +attached to it must schedule there. resolution runs the placement +solve over every resource sharing the volume (see runpod.apps.placement). +tasks/pods take one volume; endpoints may take several (one per +datacenter, locations derived from the volumes). +""" + +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .errors import AppError +from .utils.client import default_client +from .utils.events import emit +from .utils.lookup import find_by_id_or_name + +log = logging.getLogger(__name__) + +DEFAULT_SIZE_GB = 50 +# platform mount conventions: pods bind network volumes at /workspace, +# serverless endpoint workers at /runpod-volume +POD_MOUNT_PATH = Path("/workspace") +ENDPOINT_MOUNT_PATH = Path("/runpod-volume") + + +class VolumeError(AppError): + pass + + +class Volume: + """a named network volume reference, created on first use.""" + + def __init__( + self, + name: str, + *, + size: int = DEFAULT_SIZE_GB, + datacenter: Optional[str] = None, + create: bool = True, + ): + if not name or not isinstance(name, str): + raise VolumeError("volume name must be a non-empty string") + self.name = name + self.size = size + self.datacenter = datacenter + self.create = create + + @property + def path(self) -> Path: + """where the volume appears in the current worker. + + context-sensitive because the platform mounts differ: task + pods use /workspace, endpoint workers /runpod-volume. resolved + at access time inside the worker, so the same function body + works from either. + """ + import os + + if os.environ.get("RUNPOD_ENDPOINT_ID"): + return ENDPOINT_MOUNT_PATH + return POD_MOUNT_PATH + + def __repr__(self) -> str: + return f"" + + +def _as_volume(ref: Any) -> Volume: + if isinstance(ref, Volume): + return ref + if isinstance(ref, str): + return Volume(ref) + raise VolumeError( + f"volume must be a runpod.Volume or name/id string, " + f"got {type(ref).__name__}" + ) + + +def volume_list(spec_volume: Any) -> List[Volume]: + """normalize a spec's volume field to a list of Volume refs.""" + if spec_volume is None: + return [] + if isinstance(spec_volume, (list, tuple)): + return [_as_volume(v) for v in spec_volume] + return [_as_volume(spec_volume)] + + +class VolumeResolver: + """resolves every volume in an app once per provision run. + + resolution: find by id or name; when missing, run the placement + solve over all resources sharing the volume and create it in the + chosen datacenter. results cache by name so each volume resolves + exactly once regardless of how many resources reference it. + """ + + def __init__(self, api=None, events: Optional[object] = None): + self._api = api + self.events = events + self._resolved: Dict[str, Dict[str, Any]] = {} + self._stock = None + + async def _client(self): + self._api = default_client(self._api) + return self._api + + async def resolve( + self, volume: Volume, specs: List + ) -> Dict[str, Any]: + """resolve one volume to {'id', 'dataCenterId'}. + + specs are all resource specs that attach this volume; they + drive placement for creation and validate an existing DC. + """ + cached = self._resolved.get(volume.name) + if cached is not None: + return cached + + from .placement import PlacementError, StockMap, solve_placement + + client = await self._client() + existing = await client.list_network_volumes() + + record = find_by_id_or_name( + existing, volume.name, noun="volumes", error=VolumeError + ) + + if self._stock is None: + self._stock = StockMap(client) + from .placement import _hardware_keys + + keys = [k for spec in specs for k in _hardware_keys(spec)] + await self._stock.fetch(keys) + + if record is not None: + # existing volume: its DC is a hard constraint + dc = solve_placement( + specs, + self._stock, + volume_name=volume.name, + existing_dc=record["dataCenterId"], + ) + resolved = {"id": record["id"], "dataCenterId": dc} + self._resolved[volume.name] = resolved + return resolved + + if not volume.create: + raise VolumeError( + f"volume '{volume.name}' not found and create=False" + ) + + if volume.datacenter: + dc = solve_placement( + specs, + self._stock, + volume_name=volume.name, + existing_dc=volume.datacenter, + ) + else: + dc = solve_placement( + specs, self._stock, volume_name=volume.name + ) + + created = await client.create_network_volume( + name=volume.name, size=volume.size, data_center_id=dc + ) + emit( + self.events, + "volume_created", + volume.name, + volume.size, + dc, + ) + log.info( + "created volume %s (%s, %dGB, %s)", + volume.name, + created["id"], + volume.size, + dc, + ) + resolved = {"id": created["id"], "dataCenterId": dc} + self._resolved[volume.name] = resolved + return resolved + + +def specs_sharing_volume(apps: List, name: str) -> List: + """every resource spec (across apps) attaching the named volume.""" + out = [] + for app in apps: + for handle in app.resources.values(): + for ref in volume_list(handle.spec.volume): + if ref.name == name: + out.append(handle.spec) + return out diff --git a/runpod/apps/watch.py b/runpod/apps/watch.py new file mode 100644 index 00000000..5f931bda --- /dev/null +++ b/runpod/apps/watch.py @@ -0,0 +1,60 @@ +"""filesystem change detection for dev sessions. + +a simple mtime poller: no extra dependency, no platform-specific event +apis, good enough for the dev loop's granularity. +""" + +import time +from pathlib import Path +from typing import Dict, Iterable, Optional + +from .discovery import _SKIP_DIRS + + +def _snapshot(roots: Iterable[Path]) -> Dict[str, float]: + state: Dict[str, float] = {} + for root in roots: + if root.is_file(): + try: + state[str(root)] = root.stat().st_mtime + except OSError: + # file deleted mid-scan; picked up on the next pass + pass + continue + for path in root.rglob("*.py"): + if any(part in _SKIP_DIRS for part in path.parts): + continue + try: + state[str(path)] = path.stat().st_mtime + except OSError: + continue + return state + + +class FileWatcher: + """tracks python file changes under a set of roots.""" + + def __init__(self, roots: Iterable[Path]): + self.roots = [Path(r) for r in roots] + self._state = _snapshot(self.roots) + + def changed(self) -> bool: + """true if any python file was added, removed, or modified since + the last call that returned true (or since construction).""" + current = _snapshot(self.roots) + if current != self._state: + self._state = current + return True + return False + + def wait_for_change( + self, poll_interval: float = 0.5, timeout: Optional[float] = None + ) -> bool: + """block until a change is detected. returns false on timeout.""" + deadline = time.monotonic() + timeout if timeout is not None else None + while True: + if self.changed(): + return True + if deadline is not None and time.monotonic() >= deadline: + return False + time.sleep(poll_interval) diff --git a/runpod/cli/__init__.py b/runpod/cli/__init__.py index 08c52a52..a7613489 100644 --- a/runpod/cli/__init__.py +++ b/runpod/cli/__init__.py @@ -2,8 +2,6 @@ import threading -from .groups import config as config, ssh as ssh - STOP_EVENT = threading.Event() diff --git a/runpod/cli/entry.py b/runpod/cli/entry.py index 6fefa2dd..f2ddb9c2 100644 --- a/runpod/cli/entry.py +++ b/runpod/cli/entry.py @@ -6,10 +6,7 @@ import click -from .groups.config.commands import config_wizard -from .groups.exec.commands import exec_cli from .groups.pod.commands import pod_cli -from .groups.project.commands import project_cli from .groups.ssh.commands import ssh_cli @@ -18,9 +15,5 @@ def runpod_cli(): """A collection of CLI functions for Runpod.""" -runpod_cli.add_command(config_wizard) # runpod config - runpod_cli.add_command(ssh_cli) # runpod ssh runpod_cli.add_command(pod_cli) # runpod pod -runpod_cli.add_command(exec_cli) # runpod exec -runpod_cli.add_command(project_cli) # runpod project diff --git a/runpod/cli/groups/config/commands.py b/runpod/cli/groups/config/commands.py deleted file mode 100644 index cae08c1b..00000000 --- a/runpod/cli/groups/config/commands.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Commands for the config command group -""" - -import sys - -import click - -from .functions import check_credentials, set_credentials - - -@click.command("config", help="Configures the Runpod CLI with the user's API key.") -@click.argument("api-key", required=False, default=None) -@click.option( - "--profile", default="default", help="The profile to set the credentials for." -) -@click.option("--check", is_flag=True, help="Check if credentials are already set.") -def config_wizard(api_key, profile, check): - """Starts the configuration wizard to set up the Runpod CLI. - If credentials are already set, prompts the user to overwrite them. - """ - valid, error = check_credentials(profile) - - if check and valid: - click.echo("Credentials already set for profile: " + profile) - sys.exit(0) - elif check and not valid: - click.echo(error) - sys.exit(1) - - if valid: - click.confirm( - f"Credentials already set for profile: {profile}. Overwrite?", abort=True - ) - - if api_key is None: - click.echo("Please enter your Runpod API Key.") - click.echo("You can find it at https://console.runpod.io/user/settings") - api_key = click.prompt( - " > Runpod API Key", hide_input=False, confirmation_prompt=False - ) - - set_credentials(api_key, profile, overwrite=True) - - click.echo(f"Credentials set for profile: {profile} in ~/.runpod/config.toml") diff --git a/runpod/cli/groups/exec/__init__.py b/runpod/cli/groups/exec/__init__.py deleted file mode 100644 index b3a934dc..00000000 --- a/runpod/cli/groups/exec/__init__.py +++ /dev/null @@ -1 +0,0 @@ -""" A collection of CLI execution tools """ diff --git a/runpod/cli/groups/exec/commands.py b/runpod/cli/groups/exec/commands.py deleted file mode 100644 index 1d372269..00000000 --- a/runpod/cli/groups/exec/commands.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Runpod | CLI | Exec | Commands -""" - -import click - -from .functions import python_over_ssh -from .helpers import get_session_pod - - -@click.group("exec", help="Execute commands in a pods.") -def exec_cli(): - """A collection of CLI functions for Exec.""" - - -@exec_cli.command("python") -@click.option("--pod_id", default=None, help="The pod ID to run the command on.") -@click.argument("file", type=click.Path(exists=True), required=True) -def remote_python(pod_id, file): - """ - Runs a remote Python shell. - """ - if pod_id is None: - pod_id = get_session_pod() - - click.echo("Running remote Python shell...") - python_over_ssh(pod_id, file) diff --git a/runpod/cli/groups/exec/functions.py b/runpod/cli/groups/exec/functions.py deleted file mode 100644 index f5bc0a8f..00000000 --- a/runpod/cli/groups/exec/functions.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Runpod | CLI | Exec | Functions -""" - -from runpod.cli.utils import ssh_cmd - - -def python_over_ssh(pod_id, file): - """ - Runs a Python file over SSH. - """ - ssh = ssh_cmd.SSHConnection(pod_id) - ssh.put_file(file, f"/root/{file}") - ssh.run_commands([f"python3.10 /root/{file}"]) - ssh.close() diff --git a/runpod/cli/groups/exec/helpers.py b/runpod/cli/groups/exec/helpers.py deleted file mode 100644 index 687c2b97..00000000 --- a/runpod/cli/groups/exec/helpers.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Helper functions for the runpod cli group exec command""" - -import os - -import click - -from runpod import get_pod - -POD_ID_FILE = os.path.join(os.path.expanduser("~"), ".runpod", "pod_id") - - -def get_session_pod(): - """Returns the pod_id for the current session. - - Session pod is used to execute commands and run scripts remotely. - - - If the pod_id is already set, return it. - - If the pod_id is not set, prompt the user for it. - - Save the pod_id to a file so that the user doesn't have to enter it again. - """ - pod_id = None - - if os.path.exists(POD_ID_FILE): - with open(POD_ID_FILE, "r", encoding="UTF-8") as pod_file: - pod_id = pod_file.read().strip() - - # Confirm that the pod_id is valid - if get_pod(pod_id) is not None: - return pod_id - - # If file doesn't exist or is empty, prompt user for the pod_id - pod_id = click.prompt("Please provide the pod ID") - os.makedirs(os.path.dirname(POD_ID_FILE), exist_ok=True) - with open(POD_ID_FILE, "w", encoding="UTF-8") as pod_file: - pod_file.write(pod_id) - - return pod_id diff --git a/runpod/cli/groups/pod/commands.py b/runpod/cli/groups/pod/commands.py index e6f37c81..805744ef 100644 --- a/runpod/cli/groups/pod/commands.py +++ b/runpod/cli/groups/pod/commands.py @@ -69,10 +69,15 @@ def create_new_pod( @click.argument("pod_id") def connect_to_pod(pod_id): """ - Connects to a pod. + Open an interactive terminal on a pod over SSH. + + Requires an SSH key on your account (rp ssh add). """ click.echo(f"Connecting to pod {pod_id}...") - ssh = ssh_cmd.SSHConnection(pod_id) + try: + ssh = ssh_cmd.SSHConnection(pod_id) + except (ValueError, TimeoutError) as exc: + raise click.ClickException(str(exc)) from exc ssh.launch_terminal() @@ -92,8 +97,8 @@ def sync_pods(source_pod_id, dest_pod_id, source_workspace, dest_workspace): 1. SSH Key Setup: • You must have an SSH key configured in your Runpod account - • If you don't have one, create it with: runpod ssh add-key - • List your keys with: runpod ssh list-keys + • If you don't have one, create it with: rp ssh add + • List your keys with: rp ssh list 2. Pod Configuration: • Both pods must have SSH access enabled @@ -130,10 +135,10 @@ def sync_pods(source_pod_id, dest_pod_id, source_workspace, dest_workspace): click.echo("❌ No SSH keys found in your Runpod account!") click.echo("") click.echo("🔑 To create an SSH key, run:") - click.echo(" runpod ssh add-key") + click.echo(" rp ssh add") click.echo("") click.echo("📖 For more help, see:") - click.echo(" runpod ssh add-key --help") + click.echo(" rp ssh add --help") return else: click.echo(f"✅ Found {len(user_keys)} SSH key(s) in your account") @@ -234,7 +239,7 @@ def sync_pods(source_pod_id, dest_pod_id, source_workspace, dest_workspace): click.echo("") click.echo("🔧 Troubleshooting tips:") click.echo("• Ensure both pods have SSH access enabled") - click.echo("• Check that your SSH key is added to your Runpod account: runpod ssh list-keys") + click.echo("• Check that your SSH key is added to your Runpod account: rp ssh list") click.echo("• For running pods, you may need to add PUBLIC_KEY env var and restart") click.echo("• Verify the source and destination paths exist") finally: diff --git a/runpod/cli/groups/project/__init__.py b/runpod/cli/groups/project/__init__.py deleted file mode 100644 index 0f41264e..00000000 --- a/runpod/cli/groups/project/__init__.py +++ /dev/null @@ -1 +0,0 @@ -""" Allows project import """ diff --git a/runpod/cli/groups/project/commands.py b/runpod/cli/groups/project/commands.py deleted file mode 100644 index b5df4a3f..00000000 --- a/runpod/cli/groups/project/commands.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -Runpod | CLI | Project | Commands -""" - -import os -import sys - -import click -from InquirerPy import prompt as cli_select - -from runpod import get_user - -from .functions import create_new_project, create_project_endpoint, start_project -from .helpers import validate_project_name - - -@click.group("project", help="Create, deploy, and manage Runpod projects.") -def project_cli(): - """Create and deploy projects on Runpod.""" - - -# -------------------------------- New Project ------------------------------- # -@project_cli.command("new") -@click.option( - "--name", "-n", "project_name", type=str, default=None, help="The project name." -) -@click.option( - "--type", - "-t", - "model_type", - type=click.Choice(["llama2"], case_sensitive=False), - default=None, - help="The type of Hugging Face model.", -) -@click.option( - "--model", - "-m", - "model_name", - type=str, - default=None, - help="The name of the Hugging Face model. (e.g. meta-llama/Llama-2-7b)", -) -@click.option("--init", "-i", "init_current_dir", is_flag=True, default=False) -def new_project_wizard(project_name, model_type, model_name, init_current_dir): - """Create a new project.""" - network_volumes = get_user()["networkVolumes"] - if len(network_volumes) == 0: - click.echo("You do not have any network volumes.") - click.echo( - "Please create a network volume (https://console.runpod.io/user/storage) and try again." - ) # pylint: disable=line-too-long - sys.exit(1) - - click.echo("Creating a new project...") - - if init_current_dir: - project_name = os.path.basename(os.getcwd()) - - if project_name is None: - project_name = click.prompt(" > Enter the project name", type=str) - - validate_project_name(project_name) - - def print_net_vol(vol): - return { - "name": f"{vol['id']}: {vol['name']} ({vol['size']} GB, {vol['dataCenterId']})", - "value": vol["id"], - } - - network_volumes = list(map(print_net_vol, network_volumes)) - questions = [ - { - "type": "rawlist", - "name": "volume-id", - "qmark": "", - "amark": "", - "message": " > Select a Network Volume:", - "choices": network_volumes, - } - ] - runpod_volume_id = cli_select(questions)["volume-id"] - - cuda_version = click.prompt( - " > Select a CUDA version, or press enter to use the default", - type=click.Choice(["11.1.1", "11.8.0", "12.1.0"], case_sensitive=False), - default="11.8.0", - ) - - python_version = click.prompt( - " > Select a Python version, or press enter to use the default", - type=click.Choice(["3.8", "3.9", "3.10", "3.11"], case_sensitive=False), - default="3.10", - ) - - click.echo("") - click.echo("Project Summary:") - - click.echo(f" - Project Name: {project_name}") - click.echo(f" - Runpod Network Storage ID: {runpod_volume_id}") - click.echo(f" - CUDA Version: {cuda_version}") - click.echo(f" - Python Version: {python_version}") - - click.echo("") - click.echo("The project will be created in the current directory.") - click.confirm("Do you want to continue?", abort=True) - - create_new_project( - project_name, - runpod_volume_id, - cuda_version, - python_version, - model_type, - model_name, - init_current_dir, - ) # pylint: disable=too-many-arguments - - click.echo(f"Project {project_name} created successfully!") - click.echo("") - click.echo( - "From your project root run `runpod project start` to start a development pod." - ) - - -# ------------------------------- Start Project ------------------------------ # -@project_cli.command("start") -def start_project_pod(): - """ - Start the project development pod from runpod.toml - """ - click.echo( - "Starting the project will create a development pod on Runpod, if one does not already exist." - ) # pylint: disable=line-too-long - click.echo( - " - You will be charged based on the GPU type specified in runpod.toml." - ) - click.echo(" - When you are finished close the connection with CTRL+C.") - click.echo("") - click.confirm("Do you want to continue?", abort=True) - - click.echo("Starting project development pod...") - - start_project() - - -# ------------------------------ Deploy Project ------------------------------ # -@project_cli.command("deploy") -def deploy_project(): - """Deploy the project to Runpod.""" - click.echo("Deploying project...") - - endpoint_id = create_project_endpoint() - - click.echo(f"Project deployed successfully! Endpoint ID: {endpoint_id}") - click.echo("The following urls are available:") - click.echo(f" - https://api.runpod.ai/v2/{endpoint_id}/runsync") - click.echo(f" - https://api.runpod.ai/v2/{endpoint_id}/run") - click.echo(f" - https://api.runpod.ai/v2/{endpoint_id}/health") diff --git a/runpod/cli/groups/project/functions.py b/runpod/cli/groups/project/functions.py deleted file mode 100644 index 4fe01157..00000000 --- a/runpod/cli/groups/project/functions.py +++ /dev/null @@ -1,429 +0,0 @@ -""" -Runpod | CLI | Project | Functions -""" - -import os -import sys -import uuid -from datetime import datetime - -import tomlkit -from tomlkit import comment, document, nl, table - -from runpod import ( - __version__, - create_endpoint, - create_template, - get_pod, - update_endpoint_template, -) -from runpod.cli import BASE_DOCKER_IMAGE, ENV_VARS, GPU_TYPES -from runpod.cli.utils.ssh_cmd import SSHConnection - -from ...utils.rp_sync import sync_directory -from .helpers import ( - attempt_pod_launch, - copy_template_files, - get_project_endpoint, - get_project_pod, - load_project_config, -) - -STARTER_TEMPLATES = os.path.join(os.path.dirname(__file__), "starter_templates") - - -def _launch_dev_pod(): - """Launch a development pod.""" - config = load_project_config() # Load runpod.toml - - print("Deploying development pod on Runpod...") - - # Prepare the environment variables - environment_variables = {"RUNPOD_PROJECT_ID": config["project"]["uuid"]} - for variable in config["project"].get("env_vars", {}): - environment_variables[variable] = config["project"]["env_vars"][variable] - - # Prepare the GPU types - selected_gpu_types = config["project"].get("gpu_types", []) - if config["project"].get("gpu", None): - selected_gpu_types.append(config["project"]["gpu"]) - - # Attempt to launch a pod with the given configuration - new_pod = attempt_pod_launch(config, environment_variables) - if new_pod is None: - print( - "Selected GPU types unavailable, try again later or use a different type." - ) - return None - - print("Waiting for pod to come online... ", end="") - sys.stdout.flush() - - # Wait for the pod to come online - while ( - new_pod.get("desiredStatus", None) != "RUNNING" - or new_pod.get("runtime") is None - ): - new_pod = get_pod(new_pod["id"]) - - project_pod_id = new_pod["id"] - - print( - f"Project {config['project']['name']} pod ({project_pod_id}) created.", - end="\n\n", - ) - return project_pod_id - - -# -------------------------------- New Project ------------------------------- # -def create_new_project( - project_name, - runpod_volume_id, - cuda_version, - python_version, # pylint: disable=too-many-locals, too-many-arguments, too-many-statements - model_type=None, - model_name=None, - init_current_dir=False, -): - """Create a new project.""" - if not init_current_dir: - project_folder = os.path.join(os.getcwd(), project_name) - if not os.path.exists(project_folder): - os.makedirs(project_folder) - - if model_type is None: - model_type = "default" - - template_dir = os.path.join(STARTER_TEMPLATES, model_type) - - copy_template_files(template_dir, project_folder) - - # Replace placeholders in requirements.txt - requirements_path = os.path.join(project_folder, "builder/requirements.txt") - with open(requirements_path, "r", encoding="utf-8") as requirements_file: - requirements_content = requirements_file.read() - - if "dev" in __version__: - requirements_content = requirements_content.replace( - "<>", "git+https://github.com/runpod/runpod-python.git" - ) - else: - requirements_content = requirements_content.replace( - "<>", f"runpod=={__version__}" - ) - - with open(requirements_path, "w", encoding="utf-8") as requirements_file: - requirements_file.write(requirements_content) - - # If there's a model_name, replace placeholders in handler.py - if model_name: - handler_path = os.path.join(project_name, "src/handler.py") - with open(handler_path, "r", encoding="utf-8") as file: - handler_content = file.read() - handler_content = handler_content.replace("<>", model_name) - - with open(handler_path, "w", encoding="utf-8") as file: - file.write(handler_content) - else: - project_folder = os.getcwd() - - project_uuid = str(uuid.uuid4())[:8] - - toml_config = document() - toml_config.add(comment("Runpod Project Configuration")) - toml_config.add(nl()) - toml_config.add("title", project_name) - - project_table = table() - project_table.add("uuid", project_uuid) - project_table.add("name", project_name) - project_table.add("base_image", BASE_DOCKER_IMAGE.format(cuda_version=cuda_version)) - project_table.add("gpu_types", GPU_TYPES) - project_table.add("gpu_count", 1) - project_table.add("storage_id", runpod_volume_id) - project_table.add("volume_mount_path", "/runpod-volume") - project_table.add("ports", "8080/http, 22/tcp") - project_table.add("container_disk_size_gb", 10) - project_table.add("env_vars", ENV_VARS) - toml_config.add("project", project_table) - - template_table = table() - template_table.add("model_type", str(model_type)) - template_table.add("model_name", str(model_name)) - toml_config.add("template", template_table) - - runtime_table = table() - runtime_table.add("python_version", python_version) - runtime_table.add("handler_path", "src/handler.py") - runtime_table.add("requirements_path", "builder/requirements.txt") - toml_config.add("runtime", runtime_table) - - with open( - os.path.join(project_folder, "runpod.toml"), "w", encoding="UTF-8" - ) as config_file: - tomlkit.dump(toml_config, config_file) - - -# ------------------------------- Start Project ------------------------------ # -def start_project(): # pylint: disable=too-many-locals, too-many-branches - """ - Start the project development environment from runpod.toml - - Check if the project pod already exists. - - - If the project pod does not exist: - # SSH into the pod and create a project folder within the volume - # Create a folder in the volume for the project that matches the project uuid - # Create a folder in the project folder that matches the project name - # Copy the project files into the project folder - # crate a virtual environment using the python version specified in the project config - # install the requirements.txt file - - - If the project pod does exist: - - """ - config = load_project_config() # Load runpod.toml - - for config_item in config["project"]: - print(f' - {config_item}: {config["project"][config_item]}') - print("") - - project_pod_id = get_project_pod(config["project"]["uuid"]) - - # Check if the project pod already exists, if not create it. - if not project_pod_id: - project_pod_id = _launch_dev_pod() - - if project_pod_id is None: - return - - with SSHConnection(project_pod_id) as ssh_conn: - - project_path_uuid = ( - f'{config["project"]["volume_mount_path"]}/{config["project"]["uuid"]}' - ) - project_path_uuid_dev = os.path.join(project_path_uuid, "dev") - project_path_uuid_prod = os.path.join(project_path_uuid, "prod") - remote_project_path = os.path.join( - project_path_uuid_dev, config["project"]["name"] - ) - - # Create the project folder on the pod - print( - f"Checking pod project folder: {remote_project_path} on pod {project_pod_id}" - ) - ssh_conn.run_commands( - [f"mkdir -p {remote_project_path} {project_path_uuid_prod}"] - ) - - # Copy local files to the pod project folder - print(f"Syncing files to pod {project_pod_id}") - ssh_conn.rsync(os.getcwd(), project_path_uuid_dev) - - # Create the virtual environment - venv_path = os.path.join(project_path_uuid_dev, "venv") - print( - f"Activating Python virtual environment: {venv_path} on pod {project_pod_id}" - ) - commands = [ - f'python{config["runtime"]["python_version"]} -m virtualenv {venv_path}', - f"source {venv_path}/bin/activate &&" - f"cd {remote_project_path} &&" - "python -m pip install --upgrade pip &&" - f'python -m pip install -v --requirement {config["runtime"]["requirements_path"]}', - ] - ssh_conn.run_commands(commands) - - # Start the watcher and then start the API development server - sync_directory(ssh_conn, os.getcwd(), project_path_uuid_dev) - - project_name = config["project"]["name"] - pip_req_path = os.path.join( - remote_project_path, config["runtime"]["requirements_path"] - ) - handler_path = os.path.join( - remote_project_path, config["runtime"]["handler_path"] - ) - - launch_api_server = [ - f""" - pkill inotify - - function force_kill {{ - kill $1 2>/dev/null - sleep 1 - - if ps -p $1 > /dev/null; then - echo "Graceful kill failed, attempting SIGKILL..." - kill -9 $1 2>/dev/null - sleep 1 - - if ps -p $1 > /dev/null; then - echo "Failed to kill process with PID: $1" - exit 1 - else - echo "Killed process with PID: $1 using SIGKILL" - fi - - else - echo "Killed process with PID: $1" - fi - }} - - function cleanup {{ - echo "Cleaning up..." - force_kill $last_pid - }} - - trap cleanup EXIT SIGINT - - if source {project_path_uuid_dev}/venv/bin/activate; then - echo -e "- Activated virtual environment." - else - echo "Failed to activate virtual environment." - exit 1 - fi - - if cd {project_path_uuid_dev}/{project_name}; then - echo -e "- Changed to project directory." - else - echo "Failed to change directory." - exit 1 - fi - - exclude_pattern='(__pycache__|\\.pyc$)' - function update_exclude_pattern {{ - exclude_pattern='(__pycache__|\\.pyc$)' - if [[ -f .runpodignore ]]; then - while IFS= read -r line; do - line=$(echo "$line" | tr -d '[:space:]') - [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue # Skip comments and empty lines - exclude_pattern="${{exclude_pattern}}|(${{line}})" - done < .runpodignore - echo -e "- Ignoring files matching pattern: $exclude_pattern" - fi - }} - update_exclude_pattern - - # Start the API server in the background, and save the PID - python {handler_path} --rp_serve_api --rp_api_host="0.0.0.0" --rp_api_port=8080 --rp_api_concurrency=1 & - last_pid=$! - - echo -e "- Started API server with PID: $last_pid" && echo "" - echo "Connect to the API server at:" - echo "> https://$RUNPOD_POD_ID-8080.proxy.runpod.net" && echo "" - - while true; do - if changed_file=$(inotifywait -q -r -e modify,create,delete --exclude "$exclude_pattern" {remote_project_path} --format '%w%f'); then - echo "Detected changes in: $changed_file" - else - echo "Failed to detect changes." - exit 1 - fi - - force_kill $last_pid - - if [[ $changed_file == *"requirements"* ]]; then - echo "Installing new requirements..." - python -m pip install --upgrade pip && python -m pip install -r {pip_req_path} - fi - - if [[ $changed_file == *".runpodignore"* ]]; then - update_exclude_pattern - fi - - python {handler_path} --rp_serve_api --rp_api_host="0.0.0.0" --rp_api_port=8080 --rp_api_concurrency=1 & - last_pid=$! - - echo "Restarted API server with PID: $last_pid" - done - """ - ] - - print("") - print("Starting project development endpoint...") - ssh_conn.run_commands(launch_api_server) - - -# ------------------------------ Deploy Project ------------------------------ # -def create_project_endpoint(): - """Create a project endpoint. - - Move code in dev to prod folder - - TODO: git commit the diff from current state to new state - - Create a serverless template for the project - - Create a new endpoint using the template - """ - config = load_project_config() - project_pod_id = get_project_pod(config["project"]["uuid"]) - - # Check if the project pod already exists, if not create it. - if not project_pod_id: - project_pod_id = _launch_dev_pod() - - if project_pod_id is None: - return None - - with SSHConnection(project_pod_id) as ssh_conn: - project_path_uuid = ( - f'{config["project"]["volume_mount_path"]}/{config["project"]["uuid"]}' - ) - project_path_uuid_prod = os.path.join(project_path_uuid, "prod") - remote_project_path = os.path.join( - project_path_uuid_prod, config["project"]["name"] - ) - - # Copy local files to the pod project folder - ssh_conn.run_commands([f"mkdir -p {remote_project_path}"]) - print(f"Syncing files to pod {project_pod_id} prod") - ssh_conn.rsync(os.getcwd(), project_path_uuid_prod) - - # Create the virtual environment - venv_path = os.path.join(project_path_uuid_prod, "venv") - print( - f"Activating Python virtual environment: {venv_path} on pod {project_pod_id}" - ) - commands = [ - f'python{config["runtime"]["python_version"]} -m venv {venv_path}', - f"source {venv_path}/bin/activate &&" - f"cd {remote_project_path} &&" - "python -m pip install --upgrade pip &&" - f'python -m pip install -v --requirement {config["runtime"]["requirements_path"]}', - ] - ssh_conn.run_commands(commands) - ssh_conn.close() - - environment_variables = {} - for variable in config["project"]["env_vars"]: - environment_variables[variable] = config["project"]["env_vars"][variable] - - # Construct the docker start command - activate_cmd = ( - f'. /runpod-volume/{config["project"]["uuid"]}/prod/venv/bin/activate' - ) - python_cmd = f'python -u /runpod-volume/{config["project"]["uuid"]}/prod/{config["project"]["name"]}/{config["runtime"]["handler_path"]}' # pylint: disable=line-too-long - docker_start_cmd = 'bash -c "' + activate_cmd + " && " + python_cmd + '"' - - project_endpoint_template = create_template( - name=f'{config["project"]["name"]}-endpoint | {config["project"]["uuid"]} | {datetime.now()}', # pylint: disable=line-too-long - image_name=config["project"]["base_image"], - container_disk_in_gb=config["project"]["container_disk_size_gb"], - docker_start_cmd=docker_start_cmd, - env=environment_variables, - is_serverless=True, - ) - - deployed_endpoint = get_project_endpoint(config["project"]["uuid"]) - if not deployed_endpoint: - deployed_endpoint = create_endpoint( - name=f'{config["project"]["name"]}-endpoint | {config["project"]["uuid"]}', - template_id=project_endpoint_template["id"], - network_volume_id=config["project"]["storage_id"], - ) - else: - deployed_endpoint = update_endpoint_template( - endpoint_id=deployed_endpoint["id"], - template_id=project_endpoint_template["id"], - ) - - # does user want to tear down and recreate workers immediately? - - return deployed_endpoint["id"] diff --git a/runpod/cli/groups/project/helpers.py b/runpod/cli/groups/project/helpers.py deleted file mode 100644 index be7c7d98..00000000 --- a/runpod/cli/groups/project/helpers.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Helper functions for project group commands.""" - -import os -import re -import shutil - -import click -import tomlkit - -from runpod import create_pod -from runpod import error as rp_error -from runpod import get_endpoints, get_pods - - -def validate_project_name(name): - """ - Validate the project name. - """ - match = re.search(r"[<>:\"/\\|?*\s]", name) - if match: - raise click.BadParameter( - f"Project name contains an invalid character: '{match.group()}'." - ) - return name - - -def get_project_pod(project_id: str): - """Check if a project pod exists. - Return the pod_id if it exists, else return None. - """ - for pod in get_pods(): - if project_id in pod["name"]: - return pod["id"] - - return None - - -def get_project_endpoint(project_id: str): - """Check if a project endpoint exists. - Return the endpoint if it exists, else return None. - """ - for endpoint in get_endpoints(): - if project_id in endpoint["name"]: - return endpoint - - return None - - -def copy_template_files(template_dir, destination): - """Copy the template files to the destination directory.""" - for item in os.listdir(template_dir): - source_item = os.path.join(template_dir, item) - destination_item = os.path.join(destination, item) - if os.path.isdir(source_item): - shutil.copytree(source_item, destination_item) - else: - shutil.copy2(source_item, destination_item) - - -def attempt_pod_launch(config, environment_variables): - """Attempt to launch a pod with the given configuration.""" - for gpu_type in config["project"].get("gpu_types", []): - print(f"Trying to get a pod with {gpu_type}... ", end="") - try: - created_pod = create_pod( - f'{config["project"]["name"]}-dev ({config["project"]["uuid"]})', - config["project"]["base_image"], - gpu_type, - gpu_count=int(config["project"]["gpu_count"]), - support_public_ip=True, - ports=f'{config["project"]["ports"]}', - network_volume_id=f'{config["project"]["storage_id"]}', - volume_mount_path=f'{config["project"]["volume_mount_path"]}', - container_disk_in_gb=int(config["project"]["container_disk_size_gb"]), - env=environment_variables, - ) - print("Success!") - return created_pod - except rp_error.QueryError: - print("Unavailable.") - return None - - -def load_project_config(): - """Load the project config file.""" - project_config_file = os.path.join(os.getcwd(), "runpod.toml") - if not os.path.exists(project_config_file): - raise FileNotFoundError("runpod.toml not found in the current directory.") - with open(project_config_file, "r", encoding="UTF-8") as config_file: - return tomlkit.load(config_file) diff --git a/runpod/cli/groups/project/starter_templates/default/.runpodignore b/runpod/cli/groups/project/starter_templates/default/.runpodignore deleted file mode 100644 index fef4dcc0..00000000 --- a/runpod/cli/groups/project/starter_templates/default/.runpodignore +++ /dev/null @@ -1,2 +0,0 @@ -# Similar to .gitignore -# Matches will not be synced to the development pod or cause the development pod to reload. diff --git a/runpod/cli/groups/project/starter_templates/default/builder/requirements.txt b/runpod/cli/groups/project/starter_templates/default/builder/requirements.txt deleted file mode 100644 index 8fa962d6..00000000 --- a/runpod/cli/groups/project/starter_templates/default/builder/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Required Python packages get listed here, one per line. -# Reccomended to lock the version number to avoid unexpected changes. - -# You can also install packages from a git repository, e.g.: -# git+https://github.com/runpod/runpod-python.git -# To learn more, see https://pip.pypa.io/en/stable/reference/requirements-file-format/ - -<> diff --git a/runpod/cli/groups/project/starter_templates/default/src/handler.py b/runpod/cli/groups/project/starter_templates/default/src/handler.py deleted file mode 100644 index bd26d7ec..00000000 --- a/runpod/cli/groups/project/starter_templates/default/src/handler.py +++ /dev/null @@ -1,15 +0,0 @@ -""" A template for a handler file. """ - -import runpod - - -def handler(job): - """ - This is the handler function for the job. - """ - job_input = job["input"] - name = job_input.get("name", "World") - return f"Hello, {name}!" - - -runpod.serverless.start({"handler": handler}) diff --git a/runpod/cli/groups/project/starter_templates/llama2/builder/requirements.txt b/runpod/cli/groups/project/starter_templates/llama2/builder/requirements.txt deleted file mode 100644 index 5a94046a..00000000 --- a/runpod/cli/groups/project/starter_templates/llama2/builder/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -# List your python dependencies here. See https://pip.pypa.io/en/stable/user_guide/#requirements-files - -runpod==1.2.0 -transformers>=4.33.0 diff --git a/runpod/cli/groups/project/starter_templates/llama2/src/handler.py b/runpod/cli/groups/project/starter_templates/llama2/src/handler.py deleted file mode 100644 index 00048abc..00000000 --- a/runpod/cli/groups/project/starter_templates/llama2/src/handler.py +++ /dev/null @@ -1,42 +0,0 @@ -""" A template for a Llama2 handler file. """ - -# pylint: skip-file -# ruff: noqa - - -from transformers import HfApi - -import runpod - -SELECTED_MODEL = "<>" - - -def get_model_framework(model_name): - api = HfApi() - model_files = api.model_info(model_name).files - - # Check the files associated with the model - if "pytorch_model.bin" in model_files: - return "PyTorch" - elif "tf_model.h5" in model_files: - return "TensorFlow" - else: - return "Unknown" - - -def prepare_inputs(text, **kwargs): - # Filter kwargs based on what the tokenizer accepts - filtered_args = {k: v for k, v in kwargs.items() if k in valid_args} - - inputs = tokenizer(text, return_tensors="pt", **filtered_args) - return inputs - - -def handle_request(text, **input_args): - inputs = prepare_inputs(text, **input_args) - with torch.no_grad(): - outputs = model(**inputs) - return process_outputs(outputs) - - -runpod.serverless.start({"handler": handle_request}) diff --git a/runpod/cli/groups/ssh/commands.py b/runpod/cli/groups/ssh/commands.py index bf0d19ab..1c6e4376 100644 --- a/runpod/cli/groups/ssh/commands.py +++ b/runpod/cli/groups/ssh/commands.py @@ -8,12 +8,46 @@ from .functions import generate_ssh_key_pair, get_user_pub_keys -@click.group("ssh", help="Manage and configure SSH keys for secure access to pods.") +class SSHGroup(click.Group): + """dispatches unknown first arguments to connect. + + `rp ssh ` opens a terminal on the pod, exactly like plain + `ssh `; named subcommands (add, list, connect) still resolve + normally. + """ + + def resolve_command(self, ctx, args): + try: + return super().resolve_command(ctx, args) + except click.UsageError: + return "connect", self.commands["connect"], args + + +@click.group( + "ssh", + cls=SSHGroup, + help="SSH into a pod (rp ssh POD_ID) and manage the keys pods trust.", + invoke_without_command=False, +) def ssh_cli(): - """Manage and configure SSH keys.""" + """SSH into pods and manage account SSH keys.""" + + +@ssh_cli.command("connect", hidden=True) +@click.argument("pod_id") +def connect(pod_id): + """Open an interactive terminal on a pod.""" + from runpod.cli.utils import ssh_cmd + + click.echo(f"Connecting to pod {pod_id}...") + try: + ssh = ssh_cmd.SSHConnection(pod_id) + except (ValueError, TimeoutError) as exc: + raise click.ClickException(str(exc)) from exc + ssh.launch_terminal() -@ssh_cli.command("list-keys") +@ssh_cli.command("list") def list_keys(): """ Lists the SSH keys for the current user. @@ -25,7 +59,7 @@ def list_keys(): click.echo(table) -@ssh_cli.command("add-key") +@ssh_cli.command("add") @click.option("--key", default=None, help="The public key to add.") @click.option( "--key-file", default=None, help="The file containing the public key to add." diff --git a/runpod/cli/utils/rp_info.py b/runpod/cli/utils/rp_info.py index 545f7916..6464bfb6 100644 --- a/runpod/cli/utils/rp_info.py +++ b/runpod/cli/utils/rp_info.py @@ -18,6 +18,8 @@ def get_pod_ssh_ip_port(pod_id, timeout=300): while time.time() - start_time < timeout and (pod_ip is None or pod_port is None): pod = get_pod(pod_id) + if not pod: + raise ValueError(f"pod '{pod_id}' not found") desired_status = pod.get("desiredStatus", None) runtime = pod.get("runtime", None) diff --git a/runpod/cli/utils/rp_sync.py b/runpod/cli/utils/rp_sync.py deleted file mode 100644 index fa7fe290..00000000 --- a/runpod/cli/utils/rp_sync.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Watches a directory for changes and syncs them to a remote directory. -""" - -import threading -import time - -from watchdog.events import FileSystemEventHandler -from watchdog.observers.polling import PollingObserver as Observer - -from runpod.cli import STOP_EVENT - -from .rp_runpodignore import get_ignore_list, should_ignore - - -class WatcherHandler(FileSystemEventHandler): - """Watches a directory for changes and syncs them to a remote directory.""" - - def __init__(self, action_function, local_path): - self.action_function = action_function - self.local_path = local_path - - self.ignore_list = get_ignore_list() - self.debouncer = None - - def on_any_event(self, event): - """Called on any event.""" - if event.is_directory or should_ignore(event.src_path, self.ignore_list): - return - - if self.debouncer is not None: - self.debouncer.cancel() # Cancel any existing timer - - # Start a new timer that will call the action function after 1 second - self.debouncer = threading.Timer(0.5, self.action_function) - self.debouncer.start() - - -def start_watcher(action_function, local_path): - """ - Starts the watcher. - """ - event_handler = WatcherHandler(action_function, local_path) - observer = Observer() - observer.schedule(event_handler, local_path, recursive=True) - observer.start() - - try: - while not STOP_EVENT.is_set(): - time.sleep(0.5) - finally: - observer.stop() - observer.join() - - -def sync_directory(ssh_client, local_path, remote_path): - """ - Syncs a local directory to a remote directory. - """ - - def sync(): - print("Syncing files...") - ssh_client.rsync(local_path, remote_path, quiet=True) - - threading.Thread(target=start_watcher, daemon=True, args=(sync, local_path)).start() - - return sync # For testing purposes diff --git a/runpod/rp_cli/__init__.py b/runpod/rp_cli/__init__.py new file mode 100644 index 00000000..de9fc580 --- /dev/null +++ b/runpod/rp_cli/__init__.py @@ -0,0 +1 @@ +"""the `rp` command line interface.""" diff --git a/runpod/rp_cli/console.py b/runpod/rp_cli/console.py new file mode 100644 index 00000000..8b766a12 --- /dev/null +++ b/runpod/rp_cli/console.py @@ -0,0 +1,597 @@ +"""console rendering for the rp cli. + +live, animated lifecycle output: spinner-driven phase tracking for +deploys (with byte-level upload progress), per-resource provisioning +animation for dev sessions, and color-coded resource badges. engine +modules stay renderer-free; they emit duck-typed events and this +module turns them into pixels. +""" + +import time +from typing import Dict, Iterable, List, Optional, Tuple + +from rich.console import Console +from rich.progress import ( + Progress, + SpinnerColumn, + TaskID, + TextColumn, +) +from rich.spinner import SPINNERS +from rich.text import Text +from rich.theme import Theme + +# -- brand ---------------------------------------------------------------- + +ACCENT = "#673de6" # runpod purple +ACCENT_LIGHT = "#a78bfa" +OK = "#34d399" +ERR = "#f87171" +WARN = "#fbbf24" +DIM = "grey58" + +RUNPOD_SAND_SPINNER = "runpod_sand" +RUNPOD_SAND_SPINNER_FRAMES = [ + "⠁", + "⠂", + "⠄", + "⡀", + "⡈", + "⡐", + "⡠", + "⣀", + "⣁", + "⣂", + "⣄", + "⣌", + "⣔", + "⣤", + "⣥", + "⣦", + "⣮", + "⣶", + "⣷", + "⣿", + "⡿", + "⠿", + "⢟", + "⠟", + "⡛", + "⠛", + "⠫", + "⢋", + "⠋", + "⠍", + "⡉", + "⠉", + "⠑", + "⠡", + "⢁", +] +SPINNERS[RUNPOD_SAND_SPINNER] = { + "interval": 1000 / 16, + "frames": RUNPOD_SAND_SPINNER_FRAMES, +} + +KIND_STYLES: Dict[str, str] = { + "queue": "bold cyan", + "api": "bold magenta", + "task": "bold yellow", +} + +theme = Theme( + { + "accent": ACCENT, + "accent.light": ACCENT_LIGHT, + "ok": OK, + "err": ERR, + "warn": WARN, + "dim": DIM, + "progress.elapsed": DIM, + } +) + +console = Console(highlight=False, theme=theme) + + +def _spinner_column(finished_text: str = "[ok]✓[/ok]") -> SpinnerColumn: + return SpinnerColumn( + RUNPOD_SAND_SPINNER, + style=ACCENT, + finished_text=finished_text, + ) + + +CONSOLE_URL = "https://console.runpod.io/serverless/user/endpoint" + + +def endpoint_url(endpoint_id: str) -> str: + return f"{CONSOLE_URL}/{endpoint_id}?tab=overview" + + +def endpoint_link(endpoint_id: str) -> str: + """short clickable endpoint reference (osc8 hyperlink).""" + return ( + f"[link={endpoint_url(endpoint_id)}]" + f"[accent.light]{endpoint_id} ↗[/accent.light][/link]" + ) + + + + +_name_width: int = 0 + + +def set_name_width(names: Iterable[str]) -> None: + global _name_width + _name_width = max((len(n) for n in names), default=0) + + +def _padded(name: str) -> str: + return f"{name:<{_name_width}}" if _name_width else name + + +def _pipe(name: str) -> str: + return f"[accent.light]{_padded(name)}[/accent.light] [dim]│[/dim]" + + +def kind_badge(kind: str) -> str: + style = KIND_STYLES.get(kind, "bold white") + return f"[{style}]{kind:<5}[/{style}]" + + +# -- generic lines --------------------------------------------------------- + + +def info(message: str) -> None: + console.print(f"{message}") + + +def success(message: str) -> None: + console.print(f"[ok]✓[/ok] {message}") + + +def error(message: str) -> None: + console.print(f"[err]✗[/err] {message}") + + +def warn(message: str) -> None: + console.print(f"[warn]![/warn] {message}") + + +def rule(title: str = "") -> None: + console.rule(f"[accent]{title}[/accent]" if title else None, style="dim") + + +# -- request lifecycle ----------------------------------------------------- + + +def request_started(name: str, label: str = "") -> None: + console.print( + f"[bold white]CALL[/bold white] [accent.light]/{name}[/accent.light]" + f" [dim]{label}[/dim]" + ) + + +def request_completed(name: str, elapsed_s: float) -> None: + console.print( + f"[ok]✓[/ok] {_padded(name)} [dim]{elapsed_s:.1f}s[/dim]" + ) + + +def request_failed(name: str, elapsed_s: float, err: Optional[str] = None) -> None: + console.print( + f"[err]✗[/err] {_padded(name)} [dim]{elapsed_s:.1f}s[/dim]" + ) + if err: + console.print(f" [err dim]{err}[/err dim]") + + +# -- banners --------------------------------------------------------------- + + +def dev_banner(app_names: Iterable[str], module: str) -> None: + console.print( + f"[bold white]dev[/bold white] [dim]{', '.join(app_names)} in {module}[/dim]" + ) + + +def deploy_plan(apps: Iterable[Tuple[str, str, int]]) -> None: + """upfront summary for multi-app deploys: (name, source, n_resources).""" + apps = list(apps) + console.print( + f"[bold white]deploy[/bold white] [dim]{len(apps)} apps[/dim]" + ) + width = max(len(name) for name, _, _ in apps) + for name, source, count in apps: + detail = f"{source}, " if source else "" + console.print( + f" [white]{name:<{width}}[/white] " + f"[dim]{detail}{count} resource{'s' if count != 1 else ''}[/dim]" + ) + + +def deploy_banner( + app_name: str, + env: str, + resources: Iterable[Tuple[str, str]], + source: str = "", +) -> None: + """resources: (name, kind) pairs.""" + names = ", ".join(name for name, _ in resources) or "(no resources)" + origin = f" from {source}" if source else "" + console.print( + f"[bold white]deploy[/bold white] [white]{app_name}[/white] " + f"[dim]→ {env}{origin}, {names}[/dim]" + ) + + +def resources_table(rows: Iterable[tuple]) -> None: + """rows of (name, kind, hardware, endpoint_id).""" + rows = list(rows) + if not rows: + return + w_name = max(len(r[0]) for r in rows) + w_hw = max(len(r[2]) for r in rows) + for name, kind, hardware, endpoint_id in rows: + tail = ( + "[dim]per-call[/dim]" + if not endpoint_id or endpoint_id == "per-call" + else endpoint_link(endpoint_id) + ) + console.print( + f" [white]{name:<{w_name}}[/white]" + f" {kind_badge(kind)}" + f" [dim]{hardware:<{w_hw}}[/dim]" + f" {tail}" + ) + + +def reload_banner(module: str = "") -> None: + console.print() + detail = f" [dim]{module} changed[/dim]" if module else "" + console.print(f" [accent]↻[/accent] [white]reloading[/white]{detail}") + + +# -- live deploy phases ------------------------------------------------------ + +_PHASE_LABELS = { + "vendor": "resolving dependencies", + "package": "packaging artifact", + "upload": "uploading build", + "endpoints": "reconciling endpoints", +} + + +_BAR_WIDTH = 22 + + +def _bar(fraction: float) -> str: + """slim one-line progress bar.""" + fraction = min(max(fraction, 0.0), 1.0) + filled = int(fraction * _BAR_WIDTH) + return ( + f"[accent]{'━' * filled}[/accent]" + f"[grey30]{'━' * (_BAR_WIDTH - filled)}[/grey30]" + ) + + +class DeployEvents: + """deploy_app event sink: animated phase list with live progress. + + each phase renders a spinner while active and collapses to a ✓ with + a short summary when the next begins. vendoring streams package + names as pip resolves them; upload renders a slim in-line bar. + """ + + def __init__(self) -> None: + self._progress = Progress( + _spinner_column(), + TextColumn("[white]{task.description}[/white]"), + TextColumn("{task.fields[detail]}"), + console=console, + transient=False, + ) + self._current: Optional[TaskID] = None + self._current_name = "" + self._started = False + self._package_count = 0 + self._upload_total = 0 + + def _ensure_started(self) -> None: + if not self._started: + self._progress.start() + self._started = True + + def _finish_current(self) -> None: + if self._current is None: + return + # collapse the live detail into a short summary + if self._current_name == "vendor" and self._package_count: + summary = f"{self._package_count} packages" + elif self._current_name == "upload" and self._upload_total: + summary = f"{self._upload_total / 1048576:.1f} MB" + else: + summary = "" + self._progress.update( + self._current, + detail=f"[dim]{summary}[/dim]", + total=1, + completed=1, + ) + self._current = None + + def phase(self, name: str, detail: str = "") -> None: + self._ensure_started() + self._finish_current() + self._current_name = name + self._current = self._progress.add_task( + _PHASE_LABELS.get(name, name), + detail=f"[dim]{detail}[/dim]" if detail else "", + total=None, + ) + + def vendor_progress(self, count: int, package: str) -> None: + if self._current is None: + return + self._package_count = count + self._progress.update( + self._current, + detail=f"[accent.light]{package}[/accent.light] [dim]({count})[/dim]", + ) + + def upload_progress(self, sent: int, total: int) -> None: + if self._current is None: + return + self._upload_total = total + mb = 1048576 + self._progress.update( + self._current, + detail=( + f"{_bar(sent / total)} " + f"[dim]{sent / mb:.1f} / {total / mb:.1f} MB[/dim]" + ), + ) + + def endpoint_ready(self, name: str, endpoint_id: str) -> None: + # collected, not printed: endpoint urls render in the final + # summary after the progress display stops + self.endpoints: Dict[str, str] = getattr(self, "endpoints", {}) + self.endpoints[name] = endpoint_id + + def close(self) -> None: + self._finish_current() + if self._started: + self._progress.stop() + self._started = False + + +# -- live dev provisioning ---------------------------------------------------- + + +class DevEvents: + """DevSession event sink. + + provisioning renders transient spinner rows that vanish once the + session is up (the resource table is the durable record). request + lifecycle renders as a block: an accent dispatch line, a dim + indented worker feed, and a check/cross verdict. + """ + + def __init__(self) -> None: + self._progress: Optional[Progress] = None + self._tasks: Dict[str, TaskID] = {} + + def session_starting(self) -> None: + self._progress = Progress( + _spinner_column(), + TextColumn("[white]{task.description}[/white]"), + TextColumn("[dim]{task.fields[detail]}[/dim]"), + console=console, + transient=True, + ) + self._progress.start() + + def session_started(self) -> None: + if self._progress is not None: + self._progress.stop() + self._progress = None + self._tasks.clear() + + def _row(self, name: str, detail: str) -> None: + if self._progress is None: + info(f"{_pipe(name)} {detail}") + return + if name not in self._tasks: + self._tasks[name] = self._progress.add_task( + _padded(name), detail=detail, total=None + ) + else: + self._progress.update(self._tasks[name], detail=detail) + + def provisioning(self, name: str, kind: str, hardware: str) -> None: + self._row(name, f"provisioning {kind} on {hardware}") + + def adopted(self, name: str, endpoint_id: str) -> None: + self._row(name, "adopting") + + def ready(self, name: str, endpoint_id: str) -> None: + if self._progress is not None and name in self._tasks: + self._progress.update( + self._tasks[name], detail="ready", total=1, completed=1 + ) + + def deleted(self, name: str) -> None: + console.print(f" [dim]− {name}[/dim]") + + # -- refresh diff -- + + def resource_added(self, name: str, kind: str, hardware: str) -> None: + console.print( + f" [ok]+[/ok] [white]{name}[/white] " + f"[dim]{kind} on {hardware}[/dim]" + ) + + def resource_changed(self, name: str, fields: List[str]) -> None: + what = ", ".join(fields) if fields else "config" + console.print( + f" [warn]~[/warn] [white]{name}[/white] [dim]{what}[/dim]" + ) + + def resource_removed(self, name: str) -> None: + console.print(f" [err]−[/err] [white]{name}[/white]") + + # -- request lifecycle (LiveTarget events) -- + # next.js/wrangler-style event lines: leading status glyph, the + # function call in normal weight, everything else dim, timing at + # the end. function stdout renders as `name │ line` so concurrent + # calls interleave without losing attribution. + + def dispatch(self, name: str, label: str = "") -> None: + detail = f" [dim]{label}[/dim]" if label else "" + console.print(f" [accent]→[/accent] [white]{name}()[/white]{detail}") + + def worker_status(self, name: str, counts: Dict[str, int]) -> None: + from runpod.apps.monitor import format_worker_counts + + console.print( + f" [dim]↳ {name}() waiting: {format_worker_counts(counts)}[/dim]" + ) + + def task_status(self, name: str, detail: str) -> None: + console.print(f" [dim]↳ {name}() {detail}[/dim]") + + def volume_created(self, name: str, size: int, dc: str) -> None: + console.print( + f" [ok]+[/ok] [white]volume {name}[/white] [dim]{size}GB in {dc}[/dim]" + ) + + def worker_ready(self, name: str, worker_id: str) -> None: + worker = worker_id[:12] if worker_id else "pod" + console.print( + f" [accent]↳[/accent] [white]{name}()[/white] [dim]worker " + f"{worker} ready[/dim]" + ) + + def worker_log(self, name: str, line: str) -> None: + from rich.markup import escape + + console.print(f" {_pipe(name)} {escape(line)}") + + def request_completed(self, name: str, elapsed_s: float) -> None: + console.print( + f" [ok]✓[/ok] [white]{name}()[/white] " + f"[dim]in {_fmt_elapsed(elapsed_s)}[/dim]" + ) + + def request_failed(self, name: str, elapsed_s: float) -> None: + console.print( + f" [err]✗[/err] [white]{name}()[/white] " + f"[dim]failed in {_fmt_elapsed(elapsed_s)}[/dim]" + ) + + +class CleanupEvents: + """session teardown sink: one spinner line with delete progress.""" + + def __init__(self) -> None: + self._progress: Optional[Progress] = None + self._task: Optional[TaskID] = None + self._total = 0 + self._done = 0 + + def cleanup_started(self, total: int) -> None: + self._total = total + if total == 0: + return + console.print() + self._progress = Progress( + _spinner_column(), + TextColumn("[white]{task.description}[/white]"), + TextColumn("[dim]{task.fields[detail]}[/dim]"), + console=console, + ) + self._progress.start() + self._task = self._progress.add_task( + "cleaning up", detail="", total=total + ) + + def deleting(self, name: str) -> None: + if self._progress is not None and self._task is not None: + self._progress.update( + self._task, detail=f"{name} ({self._done}/{self._total})" + ) + + def deleted(self, name: str) -> None: + self._done += 1 + if self._progress is not None and self._task is not None: + self._progress.update( + self._task, + completed=self._done, + detail=f"{name} ({self._done}/{self._total})", + ) + + def delete_failed(self, name: str) -> None: + self._done += 1 + if self._progress is not None and self._task is not None: + self._progress.update(self._task, completed=self._done) + console.print(f" [warn]![/warn] [dim]could not delete {name}[/dim]") + + def close(self) -> None: + if self._progress is not None and self._task is not None: + self._progress.update( + self._task, + completed=self._total, + detail=f"{self._done} deleted", + ) + self._progress.stop() + self._progress = None + + +class Timer: + """context timer for elapsed reporting.""" + + def __enter__(self) -> "Timer": + self._start = time.monotonic() + return self + + def __exit__(self, *exc) -> None: + self.elapsed = time.monotonic() - self._start + + @property + def so_far(self) -> float: + return time.monotonic() - self._start + + +def _fmt_elapsed(elapsed: float) -> str: + if elapsed < 1: + return f"{elapsed * 1000:.0f}ms" + if elapsed < 120: + return f"{elapsed:.1f}s" + return f"{int(elapsed // 60)}m{int(elapsed % 60):02d}s" + + +def entrypoint_header(fn_name: str = "") -> None: + console.print() + if fn_name: + console.print( + f" [accent]◆[/accent] [white]{fn_name}()[/white] " + f"[dim]local entrypoint[/dim]" + ) + + +def entrypoint_success(elapsed: float) -> None: + console.print() + console.print( + f" [ok]✓[/ok] [white]done[/white] [dim]in {_fmt_elapsed(elapsed)}, " + f"enter re-run, edit reload, ^C quit[/dim]" + ) + + +def entrypoint_failure(elapsed: float, err: str) -> None: + console.print() + console.print(f" [err]✗ {err}[/err]") + console.print( + f" [err]✗[/err] [white]failed[/white] [dim]in {_fmt_elapsed(elapsed)}, " + f"enter re-run, edit reload, ^C quit[/dim]" + ) diff --git a/runpod/rp_cli/main.py b/runpod/rp_cli/main.py new file mode 100644 index 00000000..16e396ed --- /dev/null +++ b/runpod/rp_cli/main.py @@ -0,0 +1,1018 @@ +"""the `rp` / `runpod` cli. + +a single plain-click command tree: app commands (deploy, dev, logs, +login) and the pod/ssh/exec/config groups all hang off one root, so +help, errors, and exit codes behave identically at every level. rich +is used only for runtime output (progress, lifecycle lines), never for +help rendering. +""" + +import asyncio +import os +import sys +from pathlib import Path + +import click + + +# commands whose output would be polluted by (or that manage) the +# update notice +_UPDATE_CHECK_EXCLUDED = frozenset({"dev", "update"}) + + +@click.group() +@click.pass_context +def cli(ctx): + """Runpod CLI: deploy and manage apps, endpoints, and pods.""" + if ctx.invoked_subcommand not in _UPDATE_CHECK_EXCLUDED: + from runpod.rp_cli.update import start_background_check + + start_background_check() + + +def _fail(message: str) -> None: + raise click.ClickException(message) + + +def _app_source(found, project_root: Path) -> str: + """the file an app was discovered in, relative to the project.""" + source = getattr(found, "_source_file", None) + if source is None: + return "" + try: + return str(Path(source).resolve().relative_to(project_root)) + except ValueError: + return str(source) + + +# ------------------------------------------------------------------ init + + +@cli.command() +@click.argument("project_name", required=False) +@click.option("--force", "-f", is_flag=True, help="Overwrite existing files.") +def init(project_name, force): + """Create a new app project (PROJECT_NAME, or '.' for the cwd).""" + from runpod.apps.init import create_project, detect_conflicts + from runpod.rp_cli import console as ui + + if project_name is None or project_name == ".": + project_dir = Path.cwd() + name = project_dir.name + else: + project_dir = Path(project_name) + name = project_name + + conflicts = detect_conflicts(project_dir) + if conflicts and not force: + listing = ", ".join(conflicts) + _fail( + f"{listing} already exist{'s' if len(conflicts) == 1 else ''} " + f"in {project_dir}. pass --force to overwrite." + ) + + written = create_project(project_dir, name, overwrite=force) + ui.success( + f"initialized [white]{name}[/white] " + f"[dim]{len(written)} files[/dim]" + ) + ui.console.print() + if project_dir != Path.cwd(): + ui.console.print(f" [dim]cd {project_name}[/dim]") + ui.console.print(" [dim]rp login[/dim]") + ui.console.print(" [dim]rp dev main.py[/dim]") + ui.console.print() + + +# ---------------------------------------------------------------- update + + +@cli.command() +@click.option( + "--version", "-V", "version_opt", default=None, help="Target version." +) +def update(version_opt): + """Update the runpod package to the latest (or a given) version.""" + from runpod.rp_cli import console as ui + from runpod.rp_cli.update import ( + compare_versions, + current_version, + fetch_pypi_metadata, + parse_version, + run_install, + ) + + current = current_version() + ui.console.print(f"current version: [white]{current}[/white]") + + with ui.console.status("[dim]checking pypi ...[/dim]"): + try: + latest, releases = fetch_pypi_metadata() + except (ConnectionError, RuntimeError) as exc: + _fail(str(exc)) + + target = version_opt or latest + if target not in releases: + _fail(f"version '{target}' not found on pypi") + if current == target: + ui.console.print(f"already on [white]{target}[/white], nothing to do") + return + if ( + current != "unknown" + and compare_versions(parse_version(target), parse_version(current)) < 0 + ): + ui.warn(f"downgrading from {current} to {target}") + + with ui.console.status(f"[dim]installing runpod {target} ...[/dim]"): + try: + run_install(target) + except Exception as exc: # noqa: BLE001 - surface installer errors cleanly + _fail(str(exc)) + ui.success(f"updated to [white]{target}[/white]") + + +# ---------------------------------------------------------------- deploy + + +@cli.command() +@click.argument("target", required=False, type=click.Path(path_type=Path)) +@click.option("--env", "-e", "env", default=None, help="Target environment name.") +@click.option( + "--python-version", + default=None, + help="Worker python version for dependency wheels (3.10-3.14). " + "Defaults to the python running the cli.", +) +@click.option( + "--exclude", + default=None, + help="Comma-separated packages to exclude from the artifact " + "(they must then come from the worker image). Torch packages are " + "excluded automatically on builtin gpu images.", +) +@click.option( + "--build-only", + is_flag=True, + help="Build the artifact without deploying; writes " + "-artifact.tar.gz to the current directory.", +) +def deploy(target, env, python_version, exclude, build_only): + """Package and deploy all apps found in TARGET (default: cwd).""" + import logging + + from runpod.apps.deploy import build_artifact, deploy_app + from runpod.apps.discovery import DiscoveryError, discover_apps + from runpod.rp_cli import console as ui + + logging.getLogger("runpod.apps").setLevel(logging.WARNING) + + if python_version is None: + from runpod.apps.images import ( + DEFAULT_PYTHON_VERSION, + local_python_version, + ) + + try: + python_version = local_python_version() + except RuntimeError: + # deployed calls are plain json (no pickle compat needed), + # so an unsupported local interpreter falls back instead of + # failing like dev does + ui.warn( + f"local python has no runtime image; building for " + f"{DEFAULT_PYTHON_VERSION} (override with --python-version)" + ) + python_version = DEFAULT_PYTHON_VERSION + + target = (target or Path.cwd()).resolve() + if not target.exists(): + _fail(f"{target} does not exist") + + project_root = target if target.is_dir() else target.parent + + try: + apps = discover_apps(target) + except DiscoveryError as exc: + _fail(str(exc)) + + if not apps: + _fail( + "no runpod.App found. define one with:\n\n" + " from runpod import App\n" + ' app = App("my-app")' + ) + + if build_only: + for found in apps: + ui.set_name_width(list(found.resources)) + events = ui.DeployEvents() + try: + artifact = build_artifact( + found, + project_root, + python_version=python_version, + exclude=exclude.split(",") if exclude else None, + events=events, + output=Path.cwd() / f"{found.name}-artifact.tar.gz", + ) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + finally: + events.close() + size_mb = artifact.stat().st_size / (1024 * 1024) + ui.success( + f"built [white]{found.name}[/white] " + f"[dim]{artifact.name} ({size_mb:.1f} MB)[/dim]" + ) + return + + if len(apps) > 1: + ui.deploy_plan( + [ + ( + found.name, + _app_source(found, project_root), + len(found.resources), + ) + for found in apps + ] + ) + + for index, found in enumerate(apps): + if index or len(apps) > 1: + ui.console.print() + ui.set_name_width(list(found.resources)) + ui.deploy_banner( + found.name, + env or found.env, + [(h.spec.name, h.spec.kind.value) for h in found.resources.values()], + source=_app_source(found, project_root), + ) + events = ui.DeployEvents() + with ui.Timer() as t: + try: + result = asyncio.run( + deploy_app( + found, + project_root, + env_name=env, + python_version=python_version, + exclude=exclude.split(",") if exclude else None, + events=events, + ) + ) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + finally: + events.close() + ui.success( + f"[bold white]{result.app_name}/{env or found.env}[/bold white] " + f"is live [dim]{t.elapsed:.1f}s[/dim]" + ) + if result.endpoints: + ui.console.print() + w = max(len(n) for n in result.endpoints) + for name, endpoint_id in sorted(result.endpoints.items()): + ui.console.print( + f" [white]{name:<{w}}[/white] " + f"{ui.endpoint_link(endpoint_id)}" + ) + ui.console.print() + + +# ------------------------------------------------------------------- dev + + +@cli.command() +@click.argument("module", type=click.Path(exists=True, path_type=Path)) +@click.option( + "--once", + is_flag=True, + help="Run the entrypoint once, tear down, and exit (for scripts/CI).", +) +def dev(module, once): + """Start an interactive dev session for MODULE. + + Provisions temporary live endpoints, runs the module's + @runpod.local_entrypoint, watches for file changes (re-scanning and + refreshing endpoints so requests run fresh code), and deletes the + endpoints on exit. + """ + import logging + + from runpod.apps.dev import DevSession + from runpod.apps.discovery import DiscoveryError, discover_apps + from runpod.apps.entrypoint import get_entrypoint, run_entrypoint + from runpod.apps.watch import FileWatcher + from runpod.rp_cli import console as ui + + logging.getLogger("runpod.apps").setLevel(logging.WARNING) + + module = module.resolve() + if not module.is_file(): + _fail(f"{module} is not a file") + + os.environ["RUNPOD_DEV_SESSION"] = "1" + + def _scan(): + # each rescan registers entrypoints anew; clearing first keeps + # get_entrypoint() reflecting the current file, not stale runs + from runpod.apps.entrypoint import _clear_entrypoints + + _clear_entrypoints() + try: + apps = discover_apps(module) + except DiscoveryError as exc: + _fail(str(exc)) + if not apps: + _fail("no runpod.App found in module") + entrypoint = get_entrypoint() + if entrypoint is None: + _fail( + "no @runpod.local_entrypoint found. define one:\n\n" + " @runpod.local_entrypoint\n" + " async def main(): ..." + ) + return apps, entrypoint + + apps, entrypoint = _scan() + + def _all_resource_names(app_list) -> list: + return [ + handle.spec.name + for a in app_list + for handle in a.resources.values() + ] + + def _table_rows(session: "DevSession") -> list: + rows = [] + for a in session.apps: + for handle in a.resources.values(): + spec = handle.spec + hardware = ",".join(spec.cpu or spec.gpu or ["any"]) + endpoint_id = session._endpoints.get( + f"dev-{a.name}-{spec.name}", "" + ) or ("per-call" if spec.kind.value == "task" else "") + rows.append((spec.name, spec.kind.value, hardware, endpoint_id)) + return rows + + # daemon stdin reader: a blocked readline must never keep the + # process alive after ctrl-c (executor threads are joined at + # loop shutdown; a daemon thread is not) + import queue as _queue + import threading + + stdin_lines: "_queue.Queue[str]" = _queue.Queue() + + def _stdin_reader() -> None: + for line in sys.stdin: + stdin_lines.put(line) + stdin_lines.put("") # eof marker + + threading.Thread(target=_stdin_reader, daemon=True).start() + + async def _wait_for_rerun(watcher: "FileWatcher") -> str: + """race the enter key against a file change.""" + eof = False + while True: + if watcher.changed(): + return "changed" + if not eof: + try: + line = stdin_lines.get_nowait() + if line == "": + # piped stdin / ci: no interactive re-runs, + # keep watching files only + eof = True + else: + return "enter" + except _queue.Empty: + # no keypress this tick; fall through to the sleep + pass + await asyncio.sleep(0.5) + + async def _run_entrypoint_cancellable(fn) -> None: + """drive the entrypoint on a daemon thread. + + the entrypoint is user code full of blocking .remote() calls; + running it inline would pin the main loop and make ctrl-c + undeliverable (asyncio's sigint handler cancels the main task, + which needs an await point). a daemon thread keeps the loop + free, and cancellation simply abandons the in-flight call. + """ + loop = asyncio.get_running_loop() + done: asyncio.Future = loop.create_future() + + def _finish(exc: "BaseException | None") -> None: + if done.done(): + return + if exc is None: + done.set_result(None) + else: + done.set_exception(exc) + + def _runner() -> None: + try: + run_entrypoint(fn) + # BaseException on purpose: SystemExit/KeyboardInterrupt from + # the entrypoint must reach the loop, not die on this thread + except BaseException as exc: # noqa: BLE001 - marshalled to the loop + # bind the exception now: `as exc` is unbound once the + # except block exits, before the loop callback runs + loop.call_soon_threadsafe(_finish, exc) + else: + loop.call_soon_threadsafe(_finish, None) + + threading.Thread(target=_runner, daemon=True).start() + await done + + async def _session() -> int: + nonlocal apps, entrypoint + ui.set_name_width(_all_resource_names(apps)) + session = DevSession(apps, events=ui.DevEvents()) + watcher = FileWatcher([module.parent]) + + ui.dev_banner([a.name for a in apps], str(module.name)) + await session.start() + ui.resources_table(_table_rows(session)) + + try: + while True: + ui.entrypoint_header(getattr(entrypoint, "__name__", "")) + with ui.Timer() as t: + try: + await _run_entrypoint_cancellable(entrypoint) + ui.entrypoint_success(t.so_far) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - dev loop survives user errors + ui.entrypoint_failure(t.so_far, str(exc)) + if once: + return 1 + if once: + return 0 + reason = await _wait_for_rerun(watcher) + if reason == "changed": + ui.reload_banner(str(module.name)) + apps, entrypoint = _scan() + ui.set_name_width(_all_resource_names(apps)) + await session.refresh(apps) + finally: + events = ui.CleanupEvents() + await session.stop(events=events) + events.close() + + try: + sys.exit(asyncio.run(_session())) + except (KeyboardInterrupt, EOFError, asyncio.CancelledError): + ui.console.print() + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + + +# ------------------------------------------------------------------ logs + + +@cli.command() +@click.argument("pod_id") +@click.option("--follow", "-f", is_flag=True, help="Stream logs as they arrive.") +@click.option( + "--type", + "log_type", + default="all", + show_default=True, + type=click.Choice(["all", "container", "system"]), + help="Log source.", +) +@click.option("--tail", default=100, show_default=True, help="Lines of backfill.") +def logs(pod_id, follow, log_type, tail): + """Show a pod's container and system logs.""" + from runpod.apps.logs import pod_logs, stream_pod_logs + + async def _run(): + if follow: + async for entry in stream_pod_logs( + pod_id, log_type=log_type, tail=tail + ): + source = entry.get("source", "?") + click.echo(f"[{source}] {entry.get('line', '')}") + else: + logs_data = await pod_logs(pod_id, log_type=log_type) + for source in ("system", "container"): + for line in logs_data.get(source) or []: + click.echo(f"[{source}] {line}") + + try: + asyncio.run(_run()) + except KeyboardInterrupt: + # ctrl-c ends the log follow; not an error + pass + + +# ----------------------------------------------------------------- login + + +@cli.command() +@click.option( + "--no-open", is_flag=True, help="Print the url instead of opening a browser." +) +@click.option( + "--api-key", + "api_key_opt", + default=None, + help="Store this API key directly (skips the browser flow).", +) +def login(no_open, api_key_opt): + """Authenticate with Runpod and store the API key. + + Opens the Runpod console for browser approval by default; pass + --api-key to store a key directly. + """ + from runpod.apps.auth import LoginError, browser_login + from runpod.cli.groups.config.functions import set_credentials + from runpod.rp_cli import console as ui + + if api_key_opt: + try: + set_credentials(api_key_opt, overwrite=True) + except ValueError as exc: + _fail(str(exc)) + ui.success("credentials saved to [dim]~/.runpod/config.toml[/dim]") + return + + def _show_url(url: str) -> None: + ui.console.print() + ui.console.print(" [white]authorize in your browser:[/white]") + ui.console.print(f" [accent.light][link={url}]{url}[/link][/accent.light]") + ui.console.print() + if not no_open: + click.launch(url) + + try: + with ui.console.status("[dim]waiting for approval ...[/dim]"): + api_key = asyncio.run(browser_login(on_url=_show_url)) + set_credentials(api_key, overwrite=True) + except (LoginError, ValueError) as exc: + _fail(str(exc)) + ui.success("logged in, credentials saved to [dim]~/.runpod/config.toml[/dim]") + + +# ---------------------------------------------------------- app / env + + +def _fmt_ts(value) -> str: + if not value: + return "-" + text = str(value) + return text[:10] if len(text) >= 10 else text + + +@cli.group() +def app(): + """Manage deployed apps.""" + + +@app.command(name="list") +def app_list(): + """List all apps and their environments.""" + from runpod.apps.manage import list_apps + from runpod.rp_cli import console as ui + + try: + apps = asyncio.run(list_apps()) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + + if not apps: + ui.console.print("\n no apps deployed. run [white]rp deploy[/white]\n") + return + + width = max(len(a["name"]) for a in apps) + ui.console.print() + for entry in apps: + envs = entry.get("flashEnvironments") or [] + names = ", ".join(e["name"] for e in envs) or "-" + ui.console.print( + f" [white]{entry['name']:<{width}}[/white] [dim]{names}[/dim]" + ) + ui.console.print() + + +@app.command(name="get") +@click.argument("app_name") +def app_get(app_name): + """Show one app with its environments.""" + from runpod.apps.manage import get_app + from runpod.rp_cli import console as ui + + try: + entry = asyncio.run(get_app(app_name)) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + + envs = entry.get("flashEnvironments") or [] + ui.console.print() + ui.console.print(f" [white]{entry['name']}[/white]") + if envs: + width = max(len(e["name"]) for e in envs) + for env_entry in envs: + build = (env_entry.get("activeBuildId") or "no build")[:12] + ui.console.print( + f" [white]{env_entry['name']:<{width}}[/white] [dim]{build}[/dim]" + ) + ui.console.print() + + +@app.command(name="delete") +@click.argument("app_name") +@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.") +def app_delete(app_name, yes): + """Delete an app, undeploying every environment in it.""" + from runpod.apps.manage import delete_app + from runpod.rp_cli import console as ui + + if not yes: + click.confirm( + f"delete app '{app_name}' and all its environments?", abort=True + ) + + events = ui.CleanupEvents() + try: + result = asyncio.run(delete_app(app_name, events=events)) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + finally: + events.close() + + if result.failures: + for failure in result.failures: + ui.error(failure) + raise click.ClickException("undeploy incomplete; app kept") + ui.success( + f"deleted [white]{app_name}[/white] " + f"[dim]{result.endpoints_deleted} endpoints removed[/dim]" + ) + + +@cli.group() +def registry(): + """Manage container registry credentials (private images).""" + + +@registry.command(name="add") +@click.argument("name", required=False) +@click.option("--username", default=None, help="Registry username (prompted if omitted).") +@click.option("--password", default=None, help="Registry password/token (prompted if omitted).") +def registry_add(name, username, password): + """Create a registry credential. Reference it with registry_auth=NAME.""" + from runpod.apps.api import AppsApiClient + from runpod.rp_cli import console as ui + + if name is None: + name = click.prompt("name") + if username is None: + username = click.prompt("username") + if password is None: + password = click.prompt("password", hide_input=True) + + try: + asyncio.run( + AppsApiClient().create_registry_auth(name, username, password) + ) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + ui.success( + f"registry credential [white]{name}[/white] created, " + f'[dim]use registry_auth="{name}"[/dim]' + ) + + +@registry.command(name="list") +def registry_list(): + """List registry credential names.""" + from runpod.apps.api import AppsApiClient + from runpod.rp_cli import console as ui + + try: + creds = asyncio.run(AppsApiClient().list_registry_auths()) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + + if not creds: + ui.console.print( + "\n no registry credentials. run [white]rp registry add [/white]\n" + ) + return + ui.console.print() + for entry in sorted(creds, key=lambda c: c["name"]): + ui.console.print(f" [white]{entry['name']}[/white] [dim]{entry['id']}[/dim]") + ui.console.print() + + +@registry.command(name="delete") +@click.argument("name") +@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.") +def registry_delete(name, yes): + """Delete a registry credential by name.""" + from runpod.apps.api import AppsApiClient + from runpod.rp_cli import console as ui + + async def _delete(): + client = AppsApiClient() + creds = await client.list_registry_auths() + match = next((c for c in creds if c["name"] == name), None) + if match is None: + _fail(f"no registry credential named '{name}'") + await client.delete_registry_auth(match["id"]) + + if not yes: + click.confirm(f"delete registry credential '{name}'?", abort=True) + try: + asyncio.run(_delete()) + except click.ClickException: + raise + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + ui.success(f"deleted registry credential [white]{name}[/white]") + + +@cli.group() +def secret(): + """Manage platform secrets (encrypted env values).""" + + +@secret.command(name="add") +@click.argument("name", required=False) +@click.option("--value", default=None, help="Secret value (prompted if omitted).") +@click.option("--description", default="", help="Optional description.") +def secret_add(name, value, description): + """Create a secret. Reference it with runpod.Secret(NAME). + + Prompts for anything not provided: bare `rp secret add` asks for + name and value, `rp secret add NAME` asks for just the value. + """ + from runpod.apps.api import AppsApiClient + from runpod.rp_cli import console as ui + + if name is None: + name = click.prompt("name") + if value is None: + value = click.prompt("value", hide_input=True) + + try: + asyncio.run(AppsApiClient().create_secret(name, value, description)) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + ui.success( + f"secret [white]{name}[/white] created, " + f"[dim]use runpod.Secret(\"{name}\")[/dim]" + ) + + +@secret.command(name="list") +def secret_list(): + """List secret names (values are never shown).""" + from runpod.apps.api import AppsApiClient + from runpod.rp_cli import console as ui + + try: + secrets = asyncio.run(AppsApiClient().list_secrets()) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + + if not secrets: + ui.console.print("\n no secrets. run [white]rp secret add [/white]\n") + return + ui.console.print() + width = max(len(s["name"]) for s in secrets) + for entry in sorted(secrets, key=lambda s: s["name"]): + description = entry.get("description") or "" + ui.console.print( + f" [white]{entry['name']:<{width}}[/white] [dim]{description}[/dim]" + ) + ui.console.print() + + +@secret.command(name="delete") +@click.argument("name") +@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.") +def secret_delete(name, yes): + """Delete a secret by name.""" + from runpod.apps.api import AppsApiClient + from runpod.rp_cli import console as ui + + async def _delete(): + client = AppsApiClient() + secrets = await client.list_secrets() + match = next((s for s in secrets if s["name"] == name), None) + if match is None: + _fail(f"no secret named '{name}'") + await client.delete_secret(match["id"]) + + if not yes: + click.confirm(f"delete secret '{name}'?", abort=True) + try: + asyncio.run(_delete()) + except click.ClickException: + raise + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + ui.success(f"deleted secret [white]{name}[/white]") + + +@cli.group() +def env(): + """Manage app environments.""" + + +def _resolve_app_name(app_name) -> str: + """--app flag or the single app discovered in cwd.""" + if app_name: + return app_name + from runpod.apps.discovery import DiscoveryError, discover_apps + + try: + apps = discover_apps(Path.cwd()) + except DiscoveryError: + apps = [] + if len(apps) == 1: + return apps[0].name + _fail("pass --app (no unique app found in the current directory)") + + +@env.command(name="list") +@click.option("--app", "-a", "app_name", default=None, help="App name.") +def env_list(app_name): + """List environments for an app.""" + from runpod.apps.manage import get_app + from runpod.rp_cli import console as ui + + name = _resolve_app_name(app_name) + try: + entry = asyncio.run(get_app(name)) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + + envs = entry.get("flashEnvironments") or [] + if not envs: + ui.console.print( + f"\n no environments in [white]{name}[/white]. run [white]rp deploy[/white]\n" + ) + return + ui.console.print() + width = max(len(e["name"]) for e in envs) + for env_entry in envs: + build = (env_entry.get("activeBuildId") or "-")[:12] + ui.console.print( + f" [white]{env_entry['name']:<{width}}[/white] " + f"[dim]{build}, {_fmt_ts(env_entry.get('createdAt'))}[/dim]" + ) + ui.console.print() + + +@env.command(name="get") +@click.argument("env_name") +@click.option("--app", "-a", "app_name", default=None, help="App name.") +def env_get(env_name, app_name): + """Show an environment with its endpoints.""" + from runpod.apps.manage import get_environment + from runpod.rp_cli import console as ui + + name = _resolve_app_name(app_name) + try: + entry = asyncio.run(get_environment(name, env_name)) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + + ui.console.print() + ui.console.print( + f" [white]{name}/{entry['name']}[/white] " + f"[dim]build {(entry.get('activeBuildId') or '-')[:12]}[/dim]" + ) + endpoints = entry.get("endpoints") or [] + if endpoints: + width = max(len(e["name"]) for e in endpoints) + for endpoint in endpoints: + ui.console.print( + f" [white]{endpoint['name']:<{width}}[/white] " + f"{ui.endpoint_link(endpoint['id'])}" + ) + ui.console.print() + + +@env.command(name="add") +@click.argument("env_name") +@click.option("--app", "-a", "app_name", default=None, help="App name.") +def env_add(env_name, app_name): + """Create a new environment in an app.""" + from runpod.apps.api import AppsApiClient + from runpod.apps.manage import get_app + from runpod.rp_cli import console as ui + + name = _resolve_app_name(app_name) + + async def _create(): + client = AppsApiClient() + entry = await get_app(name, api=client) + return await client.create_environment(entry["id"], env_name) + + try: + asyncio.run(_create()) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + ui.success(f"created [white]{name}/{env_name}[/white]") + + +@env.command(name="delete") +@click.argument("env_name") +@click.option("--app", "-a", "app_name", default=None, help="App name.") +@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.") +def env_delete(env_name, app_name, yes): + """Undeploy and delete an environment.""" + from runpod.apps.manage import undeploy_environment + from runpod.rp_cli import console as ui + + name = _resolve_app_name(app_name) + if not yes: + click.confirm( + f"undeploy and delete '{name}/{env_name}'?", abort=True + ) + + events = ui.CleanupEvents() + try: + result = asyncio.run( + undeploy_environment(name, env_name, events=events) + ) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + finally: + events.close() + + if result.failures: + for failure in result.failures: + ui.error(failure) + raise click.ClickException("undeploy incomplete; environment kept") + ui.success( + f"deleted [white]{name}/{env_name}[/white] " + f"[dim]{result.endpoints_deleted} endpoints removed[/dim]" + ) + + +@cli.command() +@click.option("--app", "-a", "app_name", default=None, help="App name.") +@click.option("--env", "-e", "env_name", default="default", show_default=True) +@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.") +def undeploy(app_name, env_name, yes): + """Tear down a deployed environment's endpoints. + + Deletes the environment's endpoints and the environment itself; + the app and its build history remain. + """ + from runpod.apps.manage import undeploy_environment + from runpod.rp_cli import console as ui + + name = _resolve_app_name(app_name) + if not yes: + click.confirm(f"undeploy '{name}/{env_name}'?", abort=True) + + events = ui.CleanupEvents() + try: + result = asyncio.run( + undeploy_environment(name, env_name, events=events) + ) + except Exception as exc: # noqa: BLE001 - surface engine errors cleanly + raise click.ClickException(str(exc)) from exc + finally: + events.close() + + if result.failures: + for failure in result.failures: + ui.error(failure) + raise click.ClickException("undeploy incomplete") + ui.success( + f"undeployed [white]{name}/{env_name}[/white] " + f"[dim]{result.endpoints_deleted} endpoints removed[/dim]" + ) + + +# -------------------------------------------------------- legacy groups + + +def _mount_groups() -> None: + from runpod.cli.groups.pod.commands import pod_cli + from runpod.cli.groups.ssh.commands import ssh_cli + + for command in (ssh_cli, pod_cli): + cli.add_command(command) + + +_mount_groups() + + +def run() -> None: + """console-script entry point.""" + cli() + + +if __name__ == "__main__": + run() diff --git a/runpod/rp_cli/update.py b/runpod/rp_cli/update.py new file mode 100644 index 00000000..5097bd9e --- /dev/null +++ b/runpod/rp_cli/update.py @@ -0,0 +1,223 @@ +"""self-update for the rp cli and a passive new-version notice. + +`rp update` installs the latest (or a pinned) runpod release from pypi +using uv when available, pip otherwise. every other command starts a +daemon thread that checks pypi at most once per day (cached under +~/.runpod) and prints a one-line notice at exit when a newer version +exists. the check never blocks or crashes a command. +""" + +from __future__ import annotations + +import atexit +import json +import shutil +import subprocess +import sys +import threading +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional, Set, Tuple + +PYPI_URL = "https://pypi.org/pypi/runpod/json" +INSTALL_TIMEOUT_SECONDS = 120 +CACHE_PATH = Path.home() / ".runpod" / "update_check.json" +CHECK_INTERVAL_HOURS = 24 + +_newer_version: Optional[str] = None +_check_done = threading.Event() +_started = False +_start_lock = threading.Lock() + + +def current_version() -> str: + try: + from runpod import __version__ + + return __version__ + except Exception: # noqa: BLE001 - version must never break the cli + return "unknown" + + +def parse_version(version: str) -> Tuple[int, ...]: + """leading numeric release segments of a version string. + + dev/local suffixes (e.g. 1.8.0.dev3) are ignored so a dev build of + the next release does not count as older than the current one. + """ + parts = [] + for part in version.split("."): + if not part.isdigit(): + break + parts.append(int(part)) + return tuple(parts) + + +def compare_versions(a: Tuple[int, ...], b: Tuple[int, ...]) -> int: + """negative when a < b, zero when equal, positive when a > b.""" + width = max(len(a), len(b)) + a_padded = a + (0,) * (width - len(a)) + b_padded = b + (0,) * (width - len(b)) + return (a_padded > b_padded) - (a_padded < b_padded) + + +def fetch_pypi_metadata() -> Tuple[str, Set[str]]: + """(latest version, all release versions) from pypi.""" + try: + with urllib.request.urlopen(PYPI_URL, timeout=15) as resp: # noqa: S310 + data = json.loads(resp.read().decode()) + except urllib.error.URLError as exc: + if isinstance(exc, urllib.error.HTTPError): + raise RuntimeError( + f"pypi returned HTTP {exc.code}; try again later" + ) from exc + raise ConnectionError( + "could not reach pypi; check your network connection" + ) from exc + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise RuntimeError("pypi returned an unexpected response") from exc + + try: + latest = data["info"]["version"] + except (KeyError, TypeError) as exc: + raise RuntimeError("pypi response missing version info") from exc + return latest, set(data.get("releases", {}).keys()) + + +def install_command(version: str) -> list: + """the installer invocation targeting the running interpreter. + + `uv pip install` is used only with an explicit --python pointing at + this interpreter: bare uv resolves against an activated venv, which + is not necessarily where this cli runs from. + """ + package_spec = f"runpod=={version}" + if shutil.which("uv"): + return [ + "uv", + "pip", + "install", + "--python", + sys.executable, + package_spec, + "--quiet", + ] + return [sys.executable, "-m", "pip", "install", package_spec, "--quiet"] + + +def run_install(version: str) -> None: + cmd = install_command(version) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=INSTALL_TIMEOUT_SECONDS, + ) + if result.returncode != 0: + installer = "uv" if cmd[0] == "uv" else "pip" + raise RuntimeError( + f"{installer} install failed (exit {result.returncode}): " + f"{result.stderr.strip()}" + ) + + +# -- passive background check ------------------------------------------- + + +def _read_cache() -> Optional[dict]: + try: + return json.loads(CACHE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return None + + +def _write_cache(latest_version: str) -> None: + try: + CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + CACHE_PATH.write_text( + json.dumps( + { + "last_checked_utc": datetime.now( + timezone.utc + ).isoformat(), + "latest_version": latest_version, + } + ), + encoding="utf-8", + ) + except OSError: + # cache write is best-effort; the check reruns next invocation + pass + + +def _cache_fresh(cache: dict) -> bool: + try: + last_checked = datetime.fromisoformat(cache["last_checked_utc"]) + elapsed = ( + datetime.now(timezone.utc) - last_checked + ).total_seconds() / 3600 + return elapsed < CHECK_INTERVAL_HOURS + except (KeyError, ValueError, TypeError): + return False + + +def _run_check() -> None: + global _newer_version # noqa: PLW0603 + try: + current = current_version() + if current == "unknown": + return + + cache = _read_cache() + latest = None + if cache and _cache_fresh(cache): + latest = cache.get("latest_version") or None + if not latest: + latest, _ = fetch_pypi_metadata() + _write_cache(latest) + if not latest: + return + + if compare_versions(parse_version(latest), parse_version(current)) > 0: + _newer_version = latest + except Exception: # noqa: BLE001 - the check must never crash the cli + pass + finally: + _check_done.set() + + +def _print_update_notice() -> None: + # plain text: atexit runs after rich teardown + if not _check_done.is_set(): + return + if _newer_version: + print( + f"\na new version of runpod is available: {_newer_version}\n" + " run 'rp update' to upgrade.", + file=sys.stderr, + ) + + +def _is_interactive() -> bool: + for stream in (sys.stderr, sys.stdout): + try: + if stream is not None and stream.isatty(): + return True + except Exception: # noqa: BLE001 + pass + return False + + +def start_background_check() -> None: + """spawn the daemon check thread once per process (ttys only).""" + global _started # noqa: PLW0603 + with _start_lock: + if _started: + return + _started = True + if not _is_interactive(): + return + atexit.register(_print_update_notice) + threading.Thread(target=_run_check, daemon=True).start() diff --git a/runpod/runtimes/README.md b/runpod/runtimes/README.md new file mode 100644 index 00000000..d2675e3c --- /dev/null +++ b/runpod/runtimes/README.md @@ -0,0 +1,75 @@ +# runtimes + +worker runtime scripts and the docker images built from them. images +are built and pushed by the `runtimes` github actions workflow. + +## cold-start model + +deployed workers do no dependency resolution. `rp deploy` vendors the +full runtime environment (the runpod package plus every python +dependency, resolved for the worker platform) into the build artifact +under `env/`. at cold start the bootstrap: + +1. **locate** the app tree: a host-provided pre-unpacked directory if + available, else extract the artifact tarball once (marker file + short-circuits warm restarts) +2. **attach** `env/` and the source tree to the worker's PYTHONPATH, + vendored env first so it wins over image packages +3. **verify** size-excluded packages (torch and friends) exist in the + image, installing only if genuinely absent (loudly, as a fallback) +4. **serve** exec the worker runtime + +torch-family packages are excluded from artifacts by size and expected +from the gpu worker images; the exclusion list is recorded in the +manifest as `excludedPackages`. + +if any phase fails, the worker does not crash-loop silently: queue +workers answer jobs with a structured `BootstrapError`, api workers +answer http requests with it. + +live mode (`rp dev`) has no artifact; source arrives per request and +the bootstrap only ensures the runtime package is importable (a no-op +on the baked images). + +## images + +every runtime kind ships a cpu and a gpu variant per python version. +gpu variants build on `runpod/gpu-base:pyX.Y-{tag}` (python:X.Y-slim +plus the torch family), which preinstalls exactly the packages the +build auto-excludes from artifacts. the two lists must stay in sync: +`SIZE_PROHIBITIVE_PACKAGES` in `runpod/apps/build.py` and the pip +install in `gpu-base/Dockerfile`. + +| image | base | use | +|---|---|---| +| `runpod/gpu-base:py{3.10-3.14}-{tag}` | `python:X.Y-slim` + torch | shared gpu base | +| `runpod/queue:py{3.10-3.14}-{tag}` | `python:X.Y-slim` | cpu queue endpoints | +| `runpod/queue-gpu:py{3.10-3.14}-{tag}` | gpu-base | gpu queue endpoints | +| `runpod/api:py{3.10-3.14}-{tag}` | `python:X.Y-slim` | cpu api endpoints | +| `runpod/api-gpu:py{3.10-3.14}-{tag}` | gpu-base | gpu api endpoints | +| `runpod/task:py{3.10-3.14}-{tag}` | `python:X.Y-slim` | cpu tasks | +| `runpod/task-gpu:py{3.10-3.14}-{tag}` | gpu-base | gpu tasks | + +image selection lives in `runpod.apps.images`; dev sessions and tasks +pick the variant matching the local python so pickled payloads stay +compatible. + +custom images work everywhere: the sdk injects `bootstrap.py` (queue/ +api) or `runner.py` (task) base64-encoded in an env var and boots it +via `dockerArgs`. both scripts are stdlib-only, so any image with a +python3 binary works. for deployed resources the vendored env then +provides everything else, including the runpod package itself. builds +targeting custom images vendor the torch family too (no auto +exclusion), since only the builtin gpu images guarantee it. + +## task + +single-shot task runner for `@app.task` pods. one pod runs one +function: the sdk deploys a pod, waits for `/ping`, posts a +`FunctionRequest` to `/execute` (or `/submit` for spawn), collects the +result, and terminates the pod. + +the wire protocol (`FunctionRequest` / `FunctionResponse`) is defined +in `runpod.apps.protocol`; the runner and sdk must stay in sync with +it. `runner.py` deliberately avoids importing the runpod package so it +can run standalone on arbitrary images. diff --git a/runpod/runtimes/__init__.py b/runpod/runtimes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runpod/runtimes/api/Dockerfile b/runpod/runtimes/api/Dockerfile new file mode 100644 index 00000000..ded53a68 --- /dev/null +++ b/runpod/runtimes/api/Dockerfile @@ -0,0 +1,29 @@ +# api (load-balanced) worker image. BASE_IMAGE is python:X.Y-slim for +# cpu or runpod/gpu-base:pyX.Y-* for gpu (adds the torch family). +ARG BASE_IMAGE=python:3.12-slim +FROM ${BASE_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + PYTHONUNBUFFERED=1 \ + RUNPOD_RUNTIME_KIND=api + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# dependency layer: cached until requirements.txt changes, so code-only +# edits skip the full dependency install +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt cloudpickle "uvicorn>=0.30" \ + && rm /tmp/requirements.txt + +# install the runpod package (worker loop + runtimes) from the build +# context. the context has no .git, so setuptools-scm needs a pretend +# version +COPY . /src +RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD=0.0.0.dev0 \ + pip install --no-cache-dir --no-deps /src && rm -rf /src + +EXPOSE 80 +CMD ["python", "-m", "runpod.runtimes.bootstrap"] diff --git a/runpod/runtimes/api/__init__.py b/runpod/runtimes/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runpod/runtimes/api/server.py b/runpod/runtimes/api/server.py new file mode 100644 index 00000000..e1d0c42c --- /dev/null +++ b/runpod/runtimes/api/server.py @@ -0,0 +1,321 @@ +"""generic api (load-balanced) server for app endpoints. + +serves an asgi app on the port runpod's load balancer routes to +(PORT env, default 80), with /ping kept healthy for LB health checks. + +two serving modes, chosen at startup: + +deployed mode (rp deploy): + the build artifact is unpacked at RUNPOD_APP_DIR and + FLASH_RESOURCE_NAME identifies this resource. the server imports + the user's module, finds the ApiHandle, and builds the asgi app: + - class-based api: instantiate the class, run its @init method + before /ping reports healthy, mount each @get/@post route + - asgi factory: call the factory, serve what it returns + +live mode (rp dev): + no artifact. the client pushes the user's module source to + /_runpod/sync (once per source change); the matching api class is + materialized from it and serves the real routes, so dev requests + hit the same handlers a deployed worker would. /execute remains + for FunctionRequest payloads. +""" + +import importlib +import inspect +import json +import logging +import os +import sys +from typing import Any, Optional + +log = logging.getLogger("runpod.runtimes.api") + +APP_DIR = os.environ.get("RUNPOD_APP_DIR", "/app") +MANIFEST_NAME = "runpod_manifest.json" +PORT = int(os.environ.get("PORT", "80")) + + +def _resource_name() -> str: + return os.environ.get("FLASH_RESOURCE_NAME") or os.environ.get( + "RUNPOD_RESOURCE_NAME", "" + ) + + +def _is_deployed() -> bool: + return bool(_resource_name()) and os.path.isfile( + os.path.join(APP_DIR, MANIFEST_NAME) + ) + + +def _load_api_handle(): + """import the user's module and return the ApiHandle for this resource.""" + with open(os.path.join(APP_DIR, MANIFEST_NAME)) as f: + manifest = json.load(f) + + name = _resource_name() + entry = next( + (r for r in manifest.get("resources", []) if r.get("name") == name), + None, + ) + if entry is None: + raise RuntimeError( + f"resource '{name}' not in manifest " + f"(has: {[r.get('name') for r in manifest.get('resources', [])]})" + ) + + if APP_DIR not in sys.path: + sys.path.insert(0, APP_DIR) + module = importlib.import_module(entry["module"]) + + from runpod.apps.handles import ApiHandle + + for attr in vars(module).values(): + if isinstance(attr, ApiHandle) and attr.spec.name == name: + return attr + raise RuntimeError( + f"no @app.api handle named '{name}' found in module '{entry['module']}'" + ) + + +async def _maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +def _build_class_app(handle) -> Any: + """construct a fastapi app from an ApiHandle's decorated class. + + the class is instantiated once per worker; @init runs before /ping + reports healthy so the LB only routes to ready workers. + """ + from contextlib import asynccontextmanager + + from fastapi import FastAPI, Request + + cls = handle._cls + instance = cls() + ready = {"ok": False} + + @asynccontextmanager + async def lifespan(_app): + if handle._init_name: + await _maybe_await(getattr(instance, handle._init_name)()) + ready["ok"] = True + yield + + app = FastAPI(title=handle.spec.name, lifespan=lifespan) + + @app.get("/ping") + async def ping(): + from fastapi.responses import JSONResponse + + if not ready["ok"]: + return JSONResponse({"status": "initializing"}, status_code=204) + return {"status": "healthy"} + + _mount_routes(app, handle, instance) + return app + + +def _mount_routes(app: Any, handle, instance) -> None: + """add each @get/@post route of an ApiHandle's class to a fastapi app.""" + from fastapi import Request + + for route in handle.spec.routes: + method = getattr(route, "method", None) or route["method"] + path = getattr(route, "path", None) or route["path"] + handler_name = ( + getattr(route, "handler_name", None) or route["handler"] + ) + bound = getattr(instance, handler_name) + + def make_endpoint(fn): + async def endpoint(request: Request): + body = None + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + try: + body = await request.json() + except Exception: # noqa: BLE001 - empty/non-json body + body = None + if body is not None: + return await _maybe_await(fn(body)) + return await _maybe_await(fn()) + + return endpoint + + app.add_api_route( + path, make_endpoint(bound), methods=[method], name=handler_name + ) + + +def _build_factory_app(handle) -> Any: + """call the user's asgi factory and ensure /ping exists. + + fastapi/starlette apps get a /ping route added when missing; other + asgi callables are wrapped so /ping is answered before delegating. + """ + app = handle._asgi_factory() + + routes = getattr(app, "routes", None) + if routes is not None and hasattr(app, "get"): + if not any(getattr(r, "path", None) == "/ping" for r in routes): + + @app.get("/ping") + async def ping(): + return {"status": "healthy"} + + return app + + async def with_ping(scope, receive, send): + if scope["type"] == "http" and scope["path"] == "/ping": + body = b'{"status": "healthy"}' + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + } + ) + await send({"type": "http.response.body", "body": body}) + return + await app(scope, receive, send) + + return with_ping + + +def _install_live_dependencies(request: dict) -> None: + """install a live resource's dependencies before its module runs. + + dev workers boot on the bare runtime image; deployed workers get + dependencies baked into the build artifact, so live mode installs + them at sync time to match. + """ + from runpod.runtimes.executor import _install, _install_system + + error = _install_system(request.get("system_dependencies")) + if error: + raise RuntimeError(error) + error = _install(request.get("dependencies"), "dependencies") + if error: + raise RuntimeError(error) + + +async def _materialize_live_api(source: str, resource: str) -> Any: + """exec shipped module source and build the api app for a resource. + + @init runs here, before the app is swapped in, so the first + routed request already sees initialized state. + """ + from fastapi import FastAPI + + from runpod.apps.handles import ApiHandle + from runpod.runtimes.executor import _materialize_source + + path = _materialize_source(source) + namespace: dict = {"__name__": "__runpod_live__", "__file__": path} + exec(compile(source, path, "exec"), namespace) # noqa: S102 + + handle = None + for value in namespace.values(): + if isinstance(value, ApiHandle) and value.spec.name == resource: + handle = value + break + if handle is None: + raise RuntimeError( + f"no @app.api resource named '{resource}' in shipped module" + ) + if handle._cls is None: + return _build_factory_app(handle) + + instance = handle._cls() + if handle._init_name: + await _maybe_await(getattr(instance, handle._init_name)()) + app = FastAPI(title=f"{resource} (live)") + _mount_routes(app, handle, instance) + return app + + +class _LiveDispatcher: + """asgi front for dev sessions. + + the client pushes module source to /_runpod/sync; the api app is + materialized from it (rebuilt when the source hash changes) and + serves every route. /ping and /execute always work. + """ + + def __init__(self): + self._core = self._build_core() + self._inner: Any = None + self._hash: Optional[str] = None + + def _build_core(self) -> Any: + import hashlib + + from fastapi import FastAPI + + app = FastAPI(title="runpod-live-api") + + @app.get("/ping") + async def ping(): + return {"status": "healthy"} + + @app.post("/execute") + async def execute(request: dict): + from runpod.runtimes.executor import execute_request + + return execute_request(request.get("input", request)) + + @app.post("/_runpod/sync") + async def sync(request: dict): + source = request.get("source") or "" + resource = request.get("resource") or "" + digest = hashlib.sha256(source.encode()).hexdigest() + if digest != self._hash: + _install_live_dependencies(request) + self._inner = await _materialize_live_api(source, resource) + self._hash = digest + return {"status": "synced", "hash": digest} + + return app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self._core(scope, receive, send) + path = scope.get("path", "") + if path in ("/ping", "/execute", "/_runpod/sync") or self._inner is None: + return await self._core(scope, receive, send) + return await self._inner(scope, receive, send) + + +def _build_live_app() -> Any: + return _LiveDispatcher() + + +def build_app() -> Any: + if _is_deployed(): + handle = _load_api_handle() + if handle._cls is not None: + return _build_class_app(handle) + return _build_factory_app(handle) + return _build_live_app() + + +def main() -> None: + import uvicorn + + uvicorn.run( + build_app(), + host="0.0.0.0", + port=PORT, + timeout_keep_alive=600, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/runpod/runtimes/bootstrap.py b/runpod/runtimes/bootstrap.py new file mode 100644 index 00000000..1ba19a77 --- /dev/null +++ b/runpod/runtimes/bootstrap.py @@ -0,0 +1,443 @@ +"""stdlib-only entrypoint for app workers. + +cold starts do no dependency resolution: the build artifact ships a +vendored environment (env/ inside the artifact) containing the runpod +runtime and every python dependency, resolved at deploy time for the +worker platform. this entrypoint only has to make that environment +visible and exec the worker. + +deployed mode (FLASH_RESOURCE_NAME set): + locate find the app tree: a pre-unpacked directory if the host + provides one, else extract the artifact tarball once + attach put {app} and {app}/env at the front of PYTHONPATH + verify packages the build excluded by size (torch and friends) + must come from the image; genuinely absent ones are + installed as a fallback, loudly + system apt packages from the resource's system_dependencies; + these cannot ride in the artifact, so this is the one + install that legitimately remains at cold start + serve exec the worker runtime from the vendored environment + +live mode (no resource name, `rp dev`): + no artifact exists; source arrives per request. the runtime images + bake the runpod package in, custom images get it installed here. + serve exec the worker runtime + +if any phase fails the worker does not crash-loop silently: a minimal +job-take loop answers every job with a structured BootstrapError so +the failure surfaces in job responses (queue endpoints), or an http +server answers every request with the error (api endpoints). + +stdlib only: this file runs on the image's python before any +environment exists. it is baked into the runtime images as CMD and +injected into custom images via RUNPOD_BOOTSTRAP_B64 + dockerArgs. +""" + +import importlib.util +import json +import os +import subprocess +import sys +import tarfile +import time +import urllib.request + +ARTIFACT_PATH = os.environ.get( + "FLASH_BUILD_ARTIFACT_PATH", "/root/.runpod/artifact.tar.gz" +) +# a host-provided, already-unpacked app tree; when present, unpacking +# is skipped entirely +PREBUILT_APP_DIR = os.environ.get("RUNPOD_PREBUILT_APP_DIR", "") +APP_DIR = os.environ.get("RUNPOD_APP_DIR", "/app") +ENV_SUBDIR = "env" +ARTIFACT_WAIT_SECONDS = int(os.environ.get("RUNPOD_ARTIFACT_WAIT", "300")) +MANIFEST_NAME = "runpod_manifest.json" +UNPACK_MARKER = ".rp-unpacked" + +WORKER_MODULES = { + "queue": "runpod.runtimes.queue.worker", + "api": "runpod.runtimes.api.server", +} + + +def _log(message): + sys.stderr.write(f"[bootstrap] {message}\n") + sys.stderr.flush() + + +class PhaseError(Exception): + def __init__(self, phase, detail): + self.phase = phase + self.detail = detail + super().__init__(f"{phase}: {detail}") + + +def _runtime_kind(): + return os.environ.get("RUNPOD_RUNTIME_KIND", "queue") + + +def _resource_name(): + return os.environ.get("FLASH_RESOURCE_NAME") or os.environ.get( + "RUNPOD_RESOURCE_NAME", "" + ) + + +# ---------------------------------------------------------------- locate + + +def _locate(): + """return the app tree root, unpacking the artifact if needed.""" + if PREBUILT_APP_DIR and os.path.isdir(PREBUILT_APP_DIR): + _log(f"using pre-unpacked app tree at {PREBUILT_APP_DIR}") + return PREBUILT_APP_DIR + + marker = os.path.join(APP_DIR, UNPACK_MARKER) + if os.path.isfile(marker): + _log(f"app tree already unpacked at {APP_DIR}") + return APP_DIR + + deadline = time.monotonic() + ARTIFACT_WAIT_SECONDS + while not os.path.isfile(ARTIFACT_PATH): + if time.monotonic() >= deadline: + raise PhaseError( + "locate", + f"artifact not found at {ARTIFACT_PATH} after " + f"{ARTIFACT_WAIT_SECONDS}s; is this endpoint part of an " + f"app deployment?", + ) + time.sleep(2) + + os.makedirs(APP_DIR, exist_ok=True) + target = os.path.realpath(APP_DIR) + started = time.monotonic() + try: + with tarfile.open(ARTIFACT_PATH, mode="r:*") as tar: + if hasattr(tarfile, "data_filter"): + # 3.12+: the data filter rejects traversal, devices, + # and absolute paths + tar.extractall(path=APP_DIR, filter="data") + else: + members = tar.getmembers() + for member in members: + dest = os.path.realpath( + os.path.join(APP_DIR, member.name) + ) + if not dest.startswith(target + os.sep) and dest != target: + raise PhaseError( + "locate", f"unsafe tar member path: {member.name}" + ) + # links can point outside and later members write + # through them; artifacts never contain links + if member.issym() or member.islnk(): + raise PhaseError( + "locate", + f"unsafe tar link member: {member.name}", + ) + if not (member.isfile() or member.isdir()): + raise PhaseError( + "locate", + f"unsupported tar member type: {member.name}", + ) + tar.extractall(path=APP_DIR, members=members) # noqa: S202 - members validated above + except (OSError, tarfile.TarError) as exc: + raise PhaseError("locate", f"failed to extract artifact: {exc}") + + with open(marker, "w") as f: + f.write(str(time.time())) + _log( + f"artifact extracted to {APP_DIR} " + f"in {time.monotonic() - started:.1f}s" + ) + return APP_DIR + + +# ---------------------------------------------------------------- attach + + +def _attach(app_dir): + """paths that must lead sys.path in the worker process. + + the vendored env comes first so the artifact's runpod (matched to + the client that built it) wins over anything baked into the image. + """ + env_dir = os.path.join(app_dir, ENV_SUBDIR) + paths = [app_dir] + if os.path.isdir(env_dir): + paths.insert(0, env_dir) + else: + _log( + f"artifact has no vendored env at {env_dir}; " + f"falling back to image packages" + ) + return paths + + +def _manifest(app_dir): + path = os.path.join(app_dir, MANIFEST_NAME) + if not os.path.isfile(path): + raise PhaseError("attach", f"manifest not found at {path}") + with open(path) as f: + return json.load(f) + + +def _worker_importable(paths): + """can the worker module be imported with these paths leading?""" + module = WORKER_MODULES[_runtime_kind()] + probe = ( + "import sys; sys.path[:0] = sys.argv[1:]; " + f"import importlib; importlib.import_module({module!r})" + ) + result = subprocess.run( + [sys.executable, "-c", probe, *paths], + capture_output=True, + text=True, + ) + return result.returncode == 0, result.stderr[-2000:] + + +# ---------------------------------------------------------------- verify + + +def _pip_install(packages, phase): + env = dict(os.environ) + # tarball specs have no .git for setuptools-scm version inference + env.setdefault("SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD", "0.0.0.dev0") + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "-q", "--upgrade", *packages], + capture_output=True, + text=True, + env=env, + ) + if result.returncode != 0: + raise PhaseError(phase, result.stderr[-3000:]) + + +def _resource_entry(manifest): + name = _resource_name() + for entry in manifest.get("resources", []): + if entry.get("name") == name: + return entry + return {} + + +def _install_system(manifest): + """apt packages cannot be vendored; install this resource's + system_dependencies at cold start.""" + packages = _resource_entry(manifest).get("systemDependencies") or [] + if not packages: + return + import shutil + + if shutil.which("apt-get") is None: + raise PhaseError( + "system", + f"system dependencies {packages} requested but apt-get is " + f"not available in this image; use a debian-based image or " + f"bake them in", + ) + env = dict(os.environ, DEBIAN_FRONTEND="noninteractive") + _log(f"installing system dependencies: {packages}") + update = subprocess.run( + ["apt-get", "update", "-qq"], capture_output=True, text=True, env=env + ) + if update.returncode != 0: + raise PhaseError("system", f"apt-get update failed: {update.stderr[-2000:]}") + result = subprocess.run( + ["apt-get", "install", "-y", "-qq", "--no-install-recommends", *packages], + capture_output=True, + text=True, + env=env, + ) + if result.returncode != 0: + raise PhaseError( + "system", + f"failed to install system dependencies {packages}: " + f"{result.stderr[-2000:]}", + ) + + +def _verify_excluded(manifest): + """packages the build excluded must come from the image. + + a miss means the image was swapped for one without them; install as + a fallback so the worker still comes up, but say so loudly because + it re-adds the cold-start cost the exclusion existed to avoid. + """ + excluded = manifest.get("excludedPackages") or [] + missing = [ + name + for name in excluded + if importlib.util.find_spec(name.replace("-", "_")) is None + ] + if not missing: + return + _log( + f"WARNING: excluded packages {missing} are not in this image; " + f"installing at cold start. use an image that provides them " + f"(e.g. runpod/pytorch) to avoid this cost." + ) + _pip_install(missing, "verify") + + +# ---------------------------------------------------------------- serve + + +def _serve(paths): + """exec the worker runtime with the environment paths leading.""" + module = WORKER_MODULES[_runtime_kind()] + env = dict(os.environ) + existing = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = os.pathsep.join( + paths + ([existing] if existing else []) + ) + if paths: + env["RUNPOD_APP_DIR"] = paths[-1] + os.execve( + sys.executable, + [sys.executable, "-m", module], + env, + ) + + +# ------------------------------------------------------------- live mode + + +def _ensure_runtime_installed(): + """live mode on a custom image: the runtime must be importable. + + baked runtime images make this a no-op. --upgrade matters: the + image may carry an older runpod without the runtimes modules, and + a plain install would no-op against it. + """ + ok, _ = _worker_importable([]) + if ok: + return + spec = os.environ.get("RUNPOD_PACKAGE_SPEC", "runpod") + _log(f"worker runtime not in image, installing {spec}") + packages = [spec, "cloudpickle"] + if _runtime_kind() == "api": + packages.append("uvicorn>=0.30") + _pip_install(packages, "runtime") + ok, err = _worker_importable([]) + if not ok: + raise PhaseError( + "runtime", + f"installed {spec} but the worker runtime is still not " + f"importable: {err}. set RUNPOD_PACKAGE_SPEC to a version " + f"that includes runpod.runtimes.", + ) + + +# ----------------------------------------------------------- error surface + + +def _error_payload(error): + return { + "error_type": "BootstrapError", + "error_message": ( + f"worker bootstrap failed during '{error.phase}': {error.detail}" + ), + } + + +def _queue_error_loop(error): + """answer queue jobs with the bootstrap error.""" + get_url = os.environ.get("RUNPOD_WEBHOOK_GET_JOB", "") + post_url = os.environ.get("RUNPOD_WEBHOOK_POST_OUTPUT", "") + api_key = os.environ.get("RUNPOD_AI_API_KEY", "") + if not get_url or not post_url: + _log("no job webhook env; exiting with error") + sys.exit(1) + + payload = json.dumps({"error": json.dumps(_error_payload(error))}).encode() + + _log("starting error-reporting loop") + while True: + try: + req = urllib.request.Request( + get_url, headers={"Authorization": api_key} + ) + with urllib.request.urlopen(req, timeout=90) as resp: + if resp.status != 200: + time.sleep(2) + continue + job = json.loads(resp.read() or b"{}") + job_id = job.get("id") + if not job_id: + continue + done = urllib.request.Request( + post_url.replace("$ID", job_id), + data=payload, + headers={ + "Authorization": api_key, + "Content-Type": "application/x-www-form-urlencoded", + }, + method="POST", + ) + urllib.request.urlopen(done, timeout=30) + _log(f"reported bootstrap error for job {job_id}") + except Exception as exc: # noqa: BLE001 - loop must survive + _log(f"error loop: {exc}") + time.sleep(5) + + +def _api_error_server(error): + """answer every http request with the bootstrap error.""" + from http.server import BaseHTTPRequestHandler, HTTPServer + + body = json.dumps(_error_payload(error)).encode() + port = int(os.environ.get("PORT", "80")) + + class Handler(BaseHTTPRequestHandler): + def _respond(self): + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = _respond + + def log_message(self, *args): + pass + + _log(f"starting error-reporting http server on :{port}") + HTTPServer(("0.0.0.0", port), Handler).serve_forever() + + +def _report_error(error): + _log(f"FAILED {error}") + if _runtime_kind() == "api": + _api_error_server(error) + else: + _queue_error_loop(error) + + +# ------------------------------------------------------------------ main + + +def main(): + try: + if _resource_name(): + app_dir = _locate() + paths = _attach(app_dir) + manifest = _manifest(app_dir) + _verify_excluded(manifest) + _install_system(manifest) + ok, err = _worker_importable(paths) + if not ok: + raise PhaseError( + "attach", + f"worker runtime not importable from the artifact " + f"env or the image: {err}", + ) + _serve(paths) + else: + _ensure_runtime_installed() + _serve([]) + except PhaseError as exc: + _report_error(exc) + + +if __name__ == "__main__": + main() diff --git a/runpod/runtimes/executor.py b/runpod/runtimes/executor.py new file mode 100644 index 00000000..bb1c7b62 --- /dev/null +++ b/runpod/runtimes/executor.py @@ -0,0 +1,291 @@ +"""shared FunctionRequest execution engine. + +runs one function from shipped source: installs dependencies, execs the +module, resolves the function, and serializes the result per the +request's serialization_format. every runtime that executes live +requests (task pods, queue workers in dev mode, api servers) drives +this same engine. + +stdlib-only (cloudpickle optional, required only for the cloudpickle +serialization format) so it can ship as part of a single-file bootstrap +onto any python image. +""" + +import base64 +import inspect +import io +import json +import os +import shutil +import subprocess +import sys +import traceback +from contextlib import redirect_stdout + + +def _load_cloudpickle(install: bool = False): + try: + import cloudpickle + + return cloudpickle + except ImportError: + if not install: + return None + # bare images (custom image= without the runtime baked in) may lack + # cloudpickle; install it on demand the same way dependencies are + if _install(["cloudpickle"], "cloudpickle"): + return None + import cloudpickle + + return cloudpickle + + +def _install(packages, label): + if not packages: + return None + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "-q", *packages], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return f"failed to install {label}: {result.stderr[-2000:]}" + return None + + +_apt_updated = False + + +def _install_system(packages): + """install apt packages; requires a debian-family image with root.""" + global _apt_updated + if not packages: + return None + if shutil.which("apt-get") is None: + return ( + f"system dependencies {packages} requested but apt-get is not " + f"available in this image; use a debian-based image or bake " + f"them in" + ) + env = dict(os.environ, DEBIAN_FRONTEND="noninteractive") + if not _apt_updated: + update = subprocess.run( + ["apt-get", "update", "-qq"], capture_output=True, text=True, env=env + ) + if update.returncode != 0: + return f"apt-get update failed: {update.stderr[-2000:]}" + _apt_updated = True + result = subprocess.run( + ["apt-get", "install", "-y", "-qq", "--no-install-recommends", *packages], + capture_output=True, + text=True, + env=env, + ) + if result.returncode != 0: + return ( + f"failed to install system dependencies {packages}: " + f"{result.stderr[-2000:]}" + ) + return None + + +def _deserialize_args(request): + fmt = request.get("serialization_format", "cloudpickle") + args = request.get("args") or [] + kwargs = request.get("kwargs") or {} + if fmt == "json": + return list(args), dict(kwargs) + cloudpickle = _load_cloudpickle(install=True) + if cloudpickle is None: + raise RuntimeError("cloudpickle not available for argument deserialization") + args = [cloudpickle.loads(base64.b64decode(a)) for a in args] + kwargs = {k: cloudpickle.loads(base64.b64decode(v)) for k, v in kwargs.items()} + return args, kwargs + + +def _serialize_result(result, fmt): + if fmt == "json": + # json-format requests promise json on the wire; failing here + # keeps dev behavior identical to a deployed endpoint + try: + json.dumps(result) + except (TypeError, ValueError) as exc: + raise TypeError( + f"return value must be json-serializable " + f"(this request uses the json wire format): {exc}" + ) from exc + return {"json_result": result} + cloudpickle = _load_cloudpickle(install=True) + if cloudpickle is None: + return {"json_result": result} + return {"result": base64.b64encode(cloudpickle.dumps(result)).decode("utf-8")} + + +def resolve_request(request): + """prepare one FunctionRequest for execution. + + installs dependencies, execs the shipped source, and resolves the + target function. returns ((fn, args, kwargs), None) on success or + (None, response_dict) on failure. + """ + error = _install_system(request.get("system_dependencies")) + if error: + return None, {"success": False, "error": error} + error = _install(request.get("dependencies"), "dependencies") + if error: + return None, {"success": False, "error": error} + + function_name = request.get("function_name") + function_code = request.get("function_code") + if not function_name or not function_code: + return None, { + "success": False, + "error": "function_name and function_code are required", + } + + # exec mirrors deployed-mode module import: the code is the + # user's full module, so __name__ is set like an import would + # (main guards stay inert) and decorated handles resolve to + # their wrapped functions. the source is written to a real + # file first so inspect.getsource works inside the function + # (nested .remote() calls re-extract sibling source) + source_path = _materialize_source(function_code) + namespace = {"__name__": "__runpod_live__", "__file__": source_path} + code_obj = compile(function_code, source_path, "exec") + exec(code_obj, namespace) # noqa: S102 - that is the job + if function_name not in namespace: + return None, { + "success": False, + "error": f"function '{function_name}' not found in provided code", + } + fn = namespace[function_name] + fn = getattr(fn, "_fn", fn) + + args, kwargs = _deserialize_args(request) + return (fn, args, kwargs), None + + +def serialize_chunk(chunk, request): + """wrap one generator chunk in the response envelope. + + the __stream__ marker lets clients tell a stream of chunks apart + from a single aggregated function response. + """ + response = {"success": True, "__stream__": True} + response.update( + _serialize_result( + chunk, request.get("serialization_format", "cloudpickle") + ) + ) + return response + + +def execute_request(request): + """run one FunctionRequest to completion, returning a response dict.""" + stdout_io = io.StringIO() + try: + prepared, error_response = resolve_request(request) + if error_response is not None: + return error_response + fn, args, kwargs = prepared + + # stdout is teed: captured for the job response and written + # through to the real stdout so container logs (and live log + # streaming in dev sessions) carry the function's prints. + # sys.stderr carries the runner's own request logs from other + # threads, which must not leak into job output + with redirect_stdout(_Tee(stdout_io, sys.__stdout__)): + result = fn(*args, **kwargs) + if hasattr(result, "__await__"): + result = _run_awaitable(result) + # generators aggregate so live .remote() matches the + # deployed worker's return_aggregate_stream output + elif inspect.isgenerator(result): + result = list(result) + elif inspect.isasyncgen(result): + result = _run_awaitable(_drain_async_gen(result)) + + response = {"success": True, "stdout": stdout_io.getvalue()} + response.update( + _serialize_result( + result, request.get("serialization_format", "cloudpickle") + ) + ) + return response + except Exception: # noqa: BLE001 - all failures go over the wire + return { + "success": False, + "error": traceback.format_exc(), + "stdout": stdout_io.getvalue(), + } + + +def _materialize_source(function_code): + """persist request source to a stable path for inspect/linecache.""" + import hashlib + import tempfile + + digest = hashlib.sha256(function_code.encode()).hexdigest()[:16] + path = os.path.join( + tempfile.gettempdir(), f"runpod_live_{digest}.py" + ) + if not os.path.exists(path): + with open(path, "w") as f: + f.write(function_code) + return path + + +class _Tee(io.TextIOBase): + """write-through to multiple streams, flushing eagerly so log + followers see lines as they happen.""" + + def __init__(self, *streams): + self._streams = [s for s in streams if s is not None] + + def write(self, s): + for stream in self._streams: + try: + stream.write(s) + stream.flush() + except (ValueError, OSError): + # one sink failing (closed pipe) must not lose the + # write on the other sinks + pass + return len(s) + + def flush(self): + for stream in self._streams: + try: + stream.flush() + except (ValueError, OSError): + # closed sinks are skipped, same as write + pass + + +def _run_awaitable(awaitable): + """drive an awaitable to completion from sync code. + + task pods run this on plain threads, but queue workers in live mode + call execute_request from inside the serverless event loop, where + asyncio.run would raise; a private loop on a helper thread covers + both. + """ + import asyncio + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(_await(awaitable)) + + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(asyncio.run, _await(awaitable)).result() + + +async def _drain_async_gen(agen): + return [chunk async for chunk in agen] + + +async def _await(awaitable): + return await awaitable diff --git a/runpod/runtimes/gpu-base/Dockerfile b/runpod/runtimes/gpu-base/Dockerfile new file mode 100644 index 00000000..5bc0d2c9 --- /dev/null +++ b/runpod/runtimes/gpu-base/Dockerfile @@ -0,0 +1,29 @@ +# shared gpu base for the runtime images: python:X.Y-slim plus the +# torch family. this preinstalled set must stay in sync with +# SIZE_PROHIBITIVE_PACKAGES in runpod/apps/build.py: deploys targeting +# builtin gpu images exclude exactly these packages from artifacts and +# expect the image to provide them. torch cuda wheels bundle the cuda +# runtime; the host injects the driver. +ARG PYTHON_VERSION=3.12 +FROM python:${PYTHON_VERSION}-slim + +# nvidia container runtime keys on these to inject the host driver; +# without them torch sees no gpu even on a gpu machine +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + PYTHONUNBUFFERED=1 \ + NVIDIA_VISIBLE_DEVICES=all \ + NVIDIA_DRIVER_CAPABILITIES=compute,utility + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# cu128 wheels: default pypi torch is cu130, which needs 580+ drivers +# that much of the fleet does not have yet. cu128 runs on 12.8+ drivers. +# pinned + extra-index (not index-url): the cuda index lacks common +# deps like typing-extensions, and the pin prevents drifting back to +# pypi's cu130 build. +RUN pip install --no-cache-dir \ + "torch==2.9.1+cu128" "torchvision==0.24.1+cu128" "torchaudio==2.9.1+cu128" \ + --extra-index-url https://download.pytorch.org/whl/cu128 diff --git a/runpod/runtimes/queue/Dockerfile b/runpod/runtimes/queue/Dockerfile new file mode 100644 index 00000000..bbaecee6 --- /dev/null +++ b/runpod/runtimes/queue/Dockerfile @@ -0,0 +1,28 @@ +# queue worker image. BASE_IMAGE is python:X.Y-slim for cpu or +# runpod/gpu-base:pyX.Y-* for gpu (adds the torch family). +ARG BASE_IMAGE=python:3.12-slim +FROM ${BASE_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + PYTHONUNBUFFERED=1 \ + RUNPOD_RUNTIME_KIND=queue + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# dependency layer: cached until requirements.txt changes, so code-only +# edits skip the full dependency install +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt cloudpickle \ + && rm /tmp/requirements.txt + +# install the runpod package (worker loop + runtimes) from the build +# context. the context has no .git, so setuptools-scm needs a pretend +# version +COPY . /src +RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD=0.0.0.dev0 \ + pip install --no-cache-dir --no-deps /src && rm -rf /src + +CMD ["python", "-m", "runpod.runtimes.bootstrap"] diff --git a/runpod/runtimes/queue/__init__.py b/runpod/runtimes/queue/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runpod/runtimes/queue/worker.py b/runpod/runtimes/queue/worker.py new file mode 100644 index 00000000..72f2857b --- /dev/null +++ b/runpod/runtimes/queue/worker.py @@ -0,0 +1,192 @@ +"""generic queue worker for app endpoints. + +two serving modes, chosen at startup: + +deployed mode (rp deploy): + the build artifact is unpacked at RUNPOD_APP_DIR and + FLASH_RESOURCE_NAME identifies this resource. the worker imports + the user's module, resolves the decorated function from the + manifest, runs its @init hook, and serves jobs by calling the + function body directly. job input is plain kwargs; source never + travels with requests. + +live mode (rp dev): + no artifact. each job carries a FunctionRequest (source + + serialized args) which is executed via the shared engine in + runpod.runtimes.executor. +""" + +import importlib +import inspect +import json +import os +import sys + +import runpod + +APP_DIR = os.environ.get("RUNPOD_APP_DIR", "/app") +MANIFEST_NAME = "runpod_manifest.json" + + +def _resource_name() -> str: + return os.environ.get("FLASH_RESOURCE_NAME") or os.environ.get( + "RUNPOD_RESOURCE_NAME", "" + ) + + +def _is_deployed() -> bool: + return bool(_resource_name()) and os.path.isfile( + os.path.join(APP_DIR, MANIFEST_NAME) + ) + + +def _load_deployed_handle(): + """import the user's module and return the FunctionHandle for this + resource.""" + with open(os.path.join(APP_DIR, MANIFEST_NAME)) as f: + manifest = json.load(f) + + name = _resource_name() + entry = next( + (r for r in manifest.get("resources", []) if r.get("name") == name), + None, + ) + if entry is None: + raise RuntimeError( + f"resource '{name}' not in manifest " + f"(has: {[r.get('name') for r in manifest.get('resources', [])]})" + ) + + if APP_DIR not in sys.path: + sys.path.insert(0, APP_DIR) + module = importlib.import_module(entry["module"]) + + from runpod.apps.handles import FunctionHandle + + for attr in vars(module).values(): + if ( + isinstance(attr, FunctionHandle) + and attr.spec.name == name + ): + return attr + raise RuntimeError( + f"no @app.queue/@app.task handle named '{name}' found in " + f"module '{entry['module']}'" + ) + + +def _job_kwargs(job: dict) -> dict: + body = dict(job.get("input") or {}) + body.pop("__empty", None) + return body + + +def _make_deployed_handler(handle): + """serverless handler that calls the user function directly. + + generator functions become generator handlers so the serverless + core streams partial outputs to /stream as they are yielded. + """ + fn = handle._fn + + if inspect.isasyncgenfunction(fn): + + async def async_gen_handler(job: dict): + async for chunk in fn(**_job_kwargs(job)): + yield chunk + + return async_gen_handler + + if inspect.isgeneratorfunction(fn): + + def gen_handler(job: dict): + yield from fn(**_job_kwargs(job)) + + return gen_handler + + async def handler(job: dict) -> dict: + result = fn(**_job_kwargs(job)) + if inspect.isawaitable(result): + result = await result + return result + + return handler + + +def _run_init(handle) -> None: + """run the resource's @init hook before serving.""" + init_fn = getattr(handle, "_init_fn", None) + if init_fn is None: + return + result = init_fn() + if inspect.isawaitable(result): + import asyncio + + asyncio.run(result) + + +async def _live_handler(job: dict): + """execute a FunctionRequest, streaming when the function is a + generator so dev sessions behave exactly like deployed workers.""" + from runpod.runtimes.executor import ( + execute_request, + resolve_request, + serialize_chunk, + ) + + request = job.get("input") or {} + prepared, error_response = resolve_request(request) + if error_response is not None: + yield error_response + return + fn, args, kwargs = prepared + + if inspect.isasyncgenfunction(fn): + async for chunk in fn(*args, **kwargs): + yield serialize_chunk(chunk, request) + return + if inspect.isgeneratorfunction(fn): + for chunk in fn(*args, **kwargs): + yield serialize_chunk(chunk, request) + return + + yield execute_request(request) + + +def _max_concurrency() -> int: + """jobs one worker may run at once (RUNPOD_MAX_CONCURRENCY, min 1).""" + try: + return max(1, int(os.environ.get("RUNPOD_MAX_CONCURRENCY", "1"))) + except ValueError: + return 1 + + +def _worker_config(handler) -> dict: + config: dict = {"handler": handler} + from runpod.serverless.modules.rp_handler import is_generator + + if is_generator(handler): + # generator jobs stream partials and still finish with a full + # output, so .remote()/result() work alongside .stream() + config["return_aggregate_stream"] = True + concurrency = _max_concurrency() + if concurrency > 1: + config["concurrency_modifier"] = lambda _current: concurrency + return config + + +def main() -> None: + if _is_deployed(): + handle = _load_deployed_handle() + _run_init(handle) + runpod.serverless.start( + _worker_config(_make_deployed_handler(handle)) + ) + else: + # the live handler is a generator so it can stream; aggregation + # keeps .remote() working for plain functions and generators alike + runpod.serverless.start(_worker_config(_live_handler)) + + +if __name__ == "__main__": + main() diff --git a/runpod/runtimes/task/Dockerfile b/runpod/runtimes/task/Dockerfile new file mode 100644 index 00000000..1e981d9e --- /dev/null +++ b/runpod/runtimes/task/Dockerfile @@ -0,0 +1,28 @@ +# task runner image. BASE_IMAGE is python:X.Y-slim for cpu or +# runpod/gpu-base:pyX.Y-* for gpu (adds the torch family). +ARG BASE_IMAGE=python:3.12-slim +FROM ${BASE_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# dependency layer: cached until requirements.txt changes, so code-only +# edits skip the full dependency install +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt cloudpickle \ + && rm /tmp/requirements.txt + +# install the runpod package: task payloads carry the user's full module +# source, which imports runpod (decorators, nested .remote calls). +# the context has no .git, so setuptools-scm needs a pretend version +COPY . /src +RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD=0.0.0.dev0 \ + pip install --no-cache-dir --no-deps /src && rm -rf /src + +EXPOSE 8080 +CMD ["python", "-m", "runpod.runtimes.task.runner"] diff --git a/runpod/runtimes/task/__init__.py b/runpod/runtimes/task/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runpod/runtimes/task/runner.py b/runpod/runtimes/task/runner.py new file mode 100644 index 00000000..0b3e6d9c --- /dev/null +++ b/runpod/runtimes/task/runner.py @@ -0,0 +1,218 @@ +"""single-shot task runner: the process a task pod boots into. + +a minimal http server speaking the FunctionRequest/FunctionResponse +protocol, driving the shared execution engine in +runpod.runtimes.executor. + +endpoints: + GET /ping readiness probe (unauthenticated) + POST /execute run a function, block, return the response + POST /submit start a function in the background, return immediately + GET /result status/result of the submitted job + +auth: every endpoint except /ping requires + Authorization: Bearer $RUNPOD_TASK_TOKEN + +one pod runs one function; the client terminates the pod after +collecting the result. + +ships to bare images as a single file: the sdk concatenates the +executor source above this module, so the import below is satisfied +either way. +""" + +import json +import os +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +try: + from runpod.runtimes.executor import execute_request +except ImportError: + # single-file bootstrap: the executor source is concatenated above + # this module and its names are already in scope + pass + +PORT = int(os.environ.get("RUNPOD_TASK_PORT", "8080")) +TOKEN = os.environ.get("RUNPOD_TASK_TOKEN", "") + +# watchdog: self-terminate when the client is clearly gone. RUNNING +# jobs are never killed (terminateAfter is the runaway backstop); +# NONE means the client died before submitting, DONE means the result +# sat uncollected. normal flows poll every ~2s, so these never fire +# for a live client. +IDLE_TIMEOUT = float(os.environ.get("RUNPOD_TASK_IDLE_TIMEOUT", "600")) +WATCHDOG_INTERVAL = 15.0 + +# single background job slot for /submit + /result +_job_lock = threading.Lock() +_job_state = {"status": "NONE", "response": None} +_last_contact = {"ts": None} # set at server start +# inline /execute requests in flight; the watchdog must not terminate +# the pod while one runs (long executes outlive the idle timeout) +_inline_executions = {"count": 0} + + +def _touch_contact(): + import time + + _last_contact["ts"] = time.time() + + +def _should_self_terminate(status, last_contact, now, idle_timeout, inline=0): + """the watchdog decision: kill only provably-abandoned pods.""" + if status == "RUNNING" or inline > 0: + return False + if last_contact is None: + return False + return (now - last_contact) > idle_timeout + + +def _terminate_self(): + """terminate this pod via the injected pod-scoped api key. + + every pod carries a RUNPOD_API_KEY scoped to itself; podTerminate + with it removes the pod entirely. exiting the process is the + fallback (stops the workload; terminateAfter finishes the job). + """ + import json as _json + import urllib.request + + pod_id = os.environ.get("RUNPOD_POD_ID") + api_key = os.environ.get("RUNPOD_API_KEY") + if pod_id and api_key: + try: + api_base = os.environ.get( + "RUNPOD_API_BASE_URL", "https://api.runpod.io" + ) + payload = _json.dumps( + { + "query": ( + "mutation podTerminate($input: PodTerminateInput!) " + "{ podTerminate(input: $input) }" + ), + "variables": {"input": {"podId": pod_id}}, + } + ).encode() + request = urllib.request.Request( + f"{api_base}/graphql", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + ) + urllib.request.urlopen(request, timeout=30) # noqa: S310 + sys.stderr.write("[task-runner] self-terminated (abandoned)\n") + except Exception: # noqa: BLE001 - fall through to process exit + pass + os._exit(0) + + +def _watchdog(): + import time + + while True: + time.sleep(WATCHDOG_INTERVAL) + if _should_self_terminate( + _job_state["status"], + _last_contact["ts"], + time.time(), + IDLE_TIMEOUT, + inline=_inline_executions["count"], + ): + _terminate_self() + + +class Handler(BaseHTTPRequestHandler): + def _send(self, code, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _authed(self): + header = self.headers.get("Authorization", "") + authed = TOKEN and header == f"Bearer {TOKEN}" + if authed: + # any authenticated contact proves the client is alive + _touch_contact() + return authed + + def _read_request(self): + length = int(self.headers.get("Content-Length", "0")) + return json.loads(self.rfile.read(length) or b"{}") + + def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler api + if self.path == "/ping": + self._send(200, {"ready": True}) + return + if self.path == "/result": + if not self._authed(): + self._send(401, {"error": "unauthorized"}) + return + with _job_lock: + self._send( + 200, + { + "status": _job_state["status"], + "response": _job_state["response"], + }, + ) + return + self._send(404, {"error": "not found"}) + + def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler api + if not self._authed(): + self._send(401, {"error": "unauthorized"}) + return + if self.path == "/execute": + with _job_lock: + _inline_executions["count"] += 1 + try: + self._send(200, execute_request(self._read_request())) + finally: + with _job_lock: + _inline_executions["count"] -= 1 + return + if self.path == "/submit": + request = self._read_request() + with _job_lock: + if _job_state["status"] == "RUNNING": + self._send(409, {"error": "a job is already running"}) + return + _job_state["status"] = "RUNNING" + _job_state["response"] = None + + def run(): + response = execute_request(request) + with _job_lock: + _job_state["status"] = "DONE" + _job_state["response"] = response + + threading.Thread(target=run, daemon=True).start() + self._send(200, {"status": "RUNNING"}) + return + self._send(404, {"error": "not found"}) + + def log_message(self, format, *args): # noqa: A002 - stdlib signature + # request logging is noise in container logs (dev sessions + # stream them as the function's output) + pass + + +def main() -> None: + if not TOKEN: + sys.stderr.write("[task-runner] RUNPOD_TASK_TOKEN not set, exiting\n") + sys.exit(1) + _touch_contact() + threading.Thread(target=_watchdog, daemon=True).start() + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/runpod/serverless/utils/rp_upload.py b/runpod/serverless/utils/rp_upload.py index d4e9a015..407400fb 100644 --- a/runpod/serverless/utils/rp_upload.py +++ b/runpod/serverless/utils/rp_upload.py @@ -19,9 +19,16 @@ from boto3.s3.transfer import TransferConfig from botocore.client import BaseClient +# configured on this logger only: basicConfig would install a root +# handler for the entire process at import time logger = logging.getLogger("runpod upload utility") FMT = "%(filename)-20s:%(lineno)-4d %(asctime)s %(message)s" -logging.basicConfig(level=logging.INFO, format=FMT, handlers=[logging.StreamHandler()]) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter(FMT)) + logger.addHandler(_handler) + logger.setLevel(logging.INFO) + logger.propagate = False def _import_boto3_dependencies(): diff --git a/setup.py b/setup.py index 6fbfa776..7f8a076e 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,12 @@ "serverless/binaries/README.md", ] }, - entry_points={"console_scripts": ["runpod = runpod.cli.entry:runpod_cli"]}, + entry_points={ + "console_scripts": [ + "runpod = runpod.rp_cli.main:run", + "rp = runpod.rp_cli.main:run", + ] + }, keywords=[ "runpod", "ai", diff --git a/tests/e2e/apps_live_smoke.py b/tests/e2e/apps_live_smoke.py new file mode 100644 index 00000000..e7395830 --- /dev/null +++ b/tests/e2e/apps_live_smoke.py @@ -0,0 +1,54 @@ +"""manual e2e smoke test for the apps live path. + +provisions a real cpu dev endpoint, executes a function remotely via +the live source-per-request protocol, and tears the endpoint down. +requires credentials in ~/.runpod/config.toml or RUNPOD_API_KEY. + +run directly, not via pytest: + python tests/e2e/apps_live_smoke.py +""" + +import asyncio +import os +import sys + +os.environ["RUNPOD_DEV_SESSION"] = "1" + +from runpod.apps import App # noqa: E402 +from runpod.apps.dev import DevSession # noqa: E402 + +app = App("apps-e2e-v2") + + +@app.queue(name="smoke", cpu="cpu3c-1-2", workers=(0, 1)) +def smoke(x: int, y: int): + return {"sum": x + y, "python": True} + + +async def main() -> int: + session = DevSession([app]) + print("provisioning dev endpoint ...") + await session.start() + try: + print("invoking smoke.remote(2, 3) ...") + result = await smoke.remote.aio(2, 3) + print(f"result: {result}") + assert result == {"sum": 5, "python": True}, f"unexpected result: {result}" + + print("refreshing session (generation bump, workers recreated) ...") + await session.refresh([app]) + assert session.generation == 2 + + print("invoking smoke.remote(10, 20) post-refresh ...") + result = await smoke.remote.aio(10, 20) + print(f"result: {result}") + assert result == {"sum": 30, "python": True}, f"unexpected result: {result}" + print("PASS") + return 0 + finally: + print("cleaning up ...") + await session.stop() + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/e2e/examples/01_hello_world.py b/tests/e2e/examples/01_hello_world.py new file mode 100644 index 00000000..fc14acaa --- /dev/null +++ b/tests/e2e/examples/01_hello_world.py @@ -0,0 +1,26 @@ +"""the smallest possible app: one queue function, one remote call. + + rp dev tests/e2e/examples/01_hello_world.py --once +""" + +import runpod +from runpod import App + +app = App("ex-hello") + + +@app.queue(cpu="cpu3c-1-2") +def hello(name: str): + print(f"saying hello to {name}") + return f"hello {name}" + + +@runpod.local_entrypoint +def main(): + result = hello.remote("world") + print("result:", result) + assert result == "hello world" + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/02_invocation_styles.py b/tests/e2e/examples/02_invocation_styles.py new file mode 100644 index 00000000..9aeb5219 --- /dev/null +++ b/tests/e2e/examples/02_invocation_styles.py @@ -0,0 +1,41 @@ +"""every way to call a function: remote, async, spawn, local. + + rp dev tests/e2e/examples/02_invocation_styles.py --once +""" + +import asyncio + +import runpod +from runpod import App + +app = App("ex-invoke") + + +@app.queue(cpu="cpu3c-1-2") +def square(x: int): + return x * x + + +@runpod.local_entrypoint +def main(): + # sync remote call, blocks for the result + assert square.remote(4) == 16 + + # async variant, for use inside event loops + async def go(): + return await square.remote.aio(5) + + assert asyncio.run(go()) == 25 + + # fire and forget -> collect later + job = square.spawn(6) + assert job.result() == 36 + + # run in this process, no cloud involved + assert square.local(7) == 49 + + print("all four invocation styles ok") + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/03_gpu.py b/tests/e2e/examples/03_gpu.py new file mode 100644 index 00000000..055cd9e7 --- /dev/null +++ b/tests/e2e/examples/03_gpu.py @@ -0,0 +1,37 @@ +"""gpu selection: shorthand names resolve to real devices. + + rp dev tests/e2e/examples/03_gpu.py --once +""" + +import runpod +from runpod import App + +app = App("ex-gpu") + + +@app.queue(gpu="4090", env={"MODE": "example"}) +def cuda_info(prompt: str): + import os + + import torch + + print(f"prompt: {prompt}") + return { + "cuda": torch.cuda.is_available(), + "device": torch.cuda.get_device_name(0), + "torch": str(torch.__version__), + "mode": os.environ.get("MODE"), + } + + +@runpod.local_entrypoint +def main(): + info = cuda_info.remote("hello gpu") + print("info:", info) + assert info["cuda"] is True + assert "4090" in info["device"] + assert info["mode"] == "example" + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/04_dependencies.py b/tests/e2e/examples/04_dependencies.py new file mode 100644 index 00000000..4c06728e --- /dev/null +++ b/tests/e2e/examples/04_dependencies.py @@ -0,0 +1,38 @@ +"""pip and apt dependencies, available on the worker without a custom image. + + rp dev tests/e2e/examples/04_dependencies.py --once +""" + +import runpod +from runpod import App + +app = App("ex-deps") + + +@app.queue(cpu="cpu3c-1-2", dependencies=["pyfiglet"]) +def banner(word: str): + import pyfiglet + + art = pyfiglet.figlet_format(word) + print(art) + return len(art) + + +@app.queue(cpu="cpu3c-1-2", system_dependencies=["jq"]) +def jq_version(): + import subprocess + + out = subprocess.run(["jq", "--version"], capture_output=True, text=True) + return out.stdout.strip() + + +@runpod.local_entrypoint +def main(): + assert banner.remote("hi") > 0 + version = jq_version.remote() + print("jq:", version) + assert version.startswith("jq-") + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/05_api_service.py b/tests/e2e/examples/05_api_service.py new file mode 100644 index 00000000..b918d3e2 --- /dev/null +++ b/tests/e2e/examples/05_api_service.py @@ -0,0 +1,45 @@ +"""a load-balanced http service from a class with route markers. + + rp dev tests/e2e/examples/05_api_service.py --once +""" + +import runpod +from runpod import App, get, init, post + +app = App("ex-api") + + +@app.api(cpu="cpu3c-1-2") +class Counter: + @init + def setup(self): + # runs once per worker before it takes traffic + self.count = 0 + print("counter initialized") + + @post("/bump") + async def bump(self, body: dict): + self.count += body.get("by", 1) + return {"count": self.count} + + @get("/value") + async def value(self): + return {"count": self.count} + + +@runpod.local_entrypoint +def main(): + first = Counter.post("/bump", {"by": 3}) + print("bump:", first) + assert first["count"] == 3 + + second = Counter.post("/bump", {"by": 2}) + assert second["count"] == 5 + + value = Counter.get("/value") + print("value:", value) + assert value["count"] == 5 + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/06_tasks.py b/tests/e2e/examples/06_tasks.py new file mode 100644 index 00000000..af12fb34 --- /dev/null +++ b/tests/e2e/examples/06_tasks.py @@ -0,0 +1,35 @@ +"""tasks: one ephemeral pod per call, terminated when done. + + rp dev tests/e2e/examples/06_tasks.py --once +""" + +import runpod +from runpod import App + +app = App("ex-tasks") + + +@app.task(cpu="cpu3c-1-2") +def multiply(x: int, y: int): + print(f"multiplying {x} * {y} on a dedicated pod") + return x * y + + +@app.task(gpu="4090") +def gpu_task(): + import torch + + print("checking cuda on a task pod") + return {"cuda": torch.cuda.is_available(), "torch": str(torch.__version__)} + + +@runpod.local_entrypoint +def main(): + assert multiply.remote(6, 7) == 42 + info = gpu_task.remote() + print("gpu task:", info) + assert info["cuda"] is True + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/07_pipelines.py b/tests/e2e/examples/07_pipelines.py new file mode 100644 index 00000000..0fa0b801 --- /dev/null +++ b/tests/e2e/examples/07_pipelines.py @@ -0,0 +1,41 @@ +"""functions calling functions: workers invoke sibling resources remotely. + + rp dev tests/e2e/examples/07_pipelines.py --once +""" + +import runpod +from runpod import App + +app = App("ex-pipeline") + + +@app.queue(cpu="cpu3c-1-2") +def tokenize(text: str): + tokens = text.split() + print(f"tokenized into {len(tokens)} tokens") + return tokens + + +@app.queue(cpu="cpu3c-1-2") +def count(tokens: list): + return len(tokens) + + +@app.queue(cpu="cpu3c-1-2") +def pipeline(text: str): + # nested .remote() calls run on the sibling resources + tokens = tokenize.remote(text) + total = count.remote(tokens) + return {"tokens": tokens, "total": total} + + +@runpod.local_entrypoint +def main(): + result = pipeline.remote("the quick brown fox") + print("pipeline:", result) + assert result["total"] == 4 + assert result["tokens"][0] == "the" + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/08_volumes.py b/tests/e2e/examples/08_volumes.py new file mode 100644 index 00000000..106acdab --- /dev/null +++ b/tests/e2e/examples/08_volumes.py @@ -0,0 +1,43 @@ +"""shared network volume: one task writes, another reads. + +the volume is created on first use; placement co-locates both tasks +in the volume's datacenter automatically. + + rp dev tests/e2e/examples/08_volumes.py --once +""" + +import runpod +from runpod import App, Volume + +app = App("ex-volumes") + +scratch = Volume("ex-scratch", size=10) + + +@app.task(cpu="cpu3c-1-2", volume=scratch) +def write(content: str): + target = scratch.path / "message.txt" + target.write_text(content) + print(f"wrote {len(content)} bytes to {target}") + return str(target) + + +@app.task(cpu="cpu3c-1-2", volume=scratch) +def read(): + target = scratch.path / "message.txt" + content = target.read_text() + print(f"read {len(content)} bytes from {target}") + return content + + +@runpod.local_entrypoint +def main(): + message = "hello from the other pod" + write.remote(message) + result = read.remote() + print("read back:", result) + assert result == message + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/09_secrets.py b/tests/e2e/examples/09_secrets.py new file mode 100644 index 00000000..f1717048 --- /dev/null +++ b/tests/e2e/examples/09_secrets.py @@ -0,0 +1,37 @@ +"""platform secrets injected as env vars, decrypted only on the worker. + +requires the secret to exist first: + + rp secret add ex-demo-secret --value "s3cret" + rp dev tests/e2e/examples/09_secrets.py --once +""" + +import runpod +from runpod import App, Secret + +app = App("ex-secrets") + + +@app.queue(cpu="cpu3c-1-2", env={"DEMO_TOKEN": Secret("ex-demo-secret"), "MODE": "example"}) +def peek(): + import os + + token = os.environ.get("DEMO_TOKEN", "") + # never print secrets; report shape only + return { + "mode": os.environ.get("MODE"), + "token_present": bool(token), + "token_length": len(token), + } + + +@runpod.local_entrypoint +def main(): + result = peek.remote() + print("peek:", result) + assert result["token_present"] is True + assert result["mode"] == "example" + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/10_cached_models.py b/tests/e2e/examples/10_cached_models.py new file mode 100644 index 00000000..95aae550 --- /dev/null +++ b/tests/e2e/examples/10_cached_models.py @@ -0,0 +1,37 @@ +"""platform-cached model weights: staged on the host before the worker starts. + + rp dev tests/e2e/examples/10_cached_models.py --once +""" + +import runpod +from runpod import App, Model + +app = App("ex-models") + +tiny = Model("sshleifer/tiny-gpt2") + + +@app.queue(gpu="4090", model=tiny, dependencies=["transformers"]) +def generate(prompt: str): + # weights are already on disk; no download happens here + from transformers import AutoModelForCausalLM, AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(str(tiny.path)) + model = AutoModelForCausalLM.from_pretrained(str(tiny.path)) + inputs = tokenizer(prompt, return_tensors="pt") + out = model.generate(**inputs, max_new_tokens=8) + text = tokenizer.decode(out[0]) + print(f"generated: {text!r}") + return {"path": str(tiny.path), "text": text} + + +@runpod.local_entrypoint +def main(): + result = generate.remote("hello") + print("result:", result) + assert result["path"].startswith("/runpod/model-store/huggingface/") + assert result["text"] + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/11_train_eval.py b/tests/e2e/examples/11_train_eval.py new file mode 100644 index 00000000..022210a6 --- /dev/null +++ b/tests/e2e/examples/11_train_eval.py @@ -0,0 +1,73 @@ +"""a real workflow: train on one gpu pod, save to a volume, eval on another. + + rp dev tests/e2e/examples/11_train_eval.py --once +""" + +import runpod +from runpod import App, Volume + +app = App("ex-train-eval") + +models = Volume("ex-models", size=10) + + +@app.task(gpu="4090", volume=models) +def train(steps: int = 200): + import json + + import torch + import torch.nn as nn + + device = "cuda" if torch.cuda.is_available() else "cpu" + torch.manual_seed(0) + + # tiny regression problem: y = 3x + 1 with noise + x = torch.randn(2048, 1, device=device) + y = 3 * x + 1 + torch.randn_like(x) * 0.05 + + model = nn.Linear(1, 1).to(device) + opt = torch.optim.SGD(model.parameters(), lr=0.05) + for step in range(1, steps + 1): + loss = ((model(x) - y) ** 2).mean() + opt.zero_grad() + loss.backward() + opt.step() + if step % 50 == 0: + print(f"step {step}/{steps} loss={loss.item():.5f}") + + run_dir = models.path / "linear-run" + run_dir.mkdir(parents=True, exist_ok=True) + torch.save(model.state_dict(), run_dir / "model.pt") + (run_dir / "meta.json").write_text(json.dumps({"steps": steps})) + print(f"saved to {run_dir}") + return {"checkpoint": "linear-run", "loss": round(loss.item(), 5)} + + +@app.task(gpu="4090", volume=models) +def evaluate(checkpoint: str): + import torch + import torch.nn as nn + + device = "cuda" if torch.cuda.is_available() else "cpu" + model = nn.Linear(1, 1).to(device) + model.load_state_dict( + torch.load(models.path / checkpoint / "model.pt", map_location=device) + ) + weight = model.weight.item() + bias = model.bias.item() + print(f"learned y = {weight:.3f}x + {bias:.3f}") + return {"weight": round(weight, 2), "bias": round(bias, 2)} + + +@runpod.local_entrypoint +def main(): + out = train.remote() + print("train:", out) + fit = evaluate.remote(out["checkpoint"]) + print("eval:", fit) + assert abs(fit["weight"] - 3.0) < 0.3 + assert abs(fit["bias"] - 1.0) < 0.3 + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/12_custom_image.py b/tests/e2e/examples/12_custom_image.py new file mode 100644 index 00000000..15037d72 --- /dev/null +++ b/tests/e2e/examples/12_custom_image.py @@ -0,0 +1,28 @@ +"""a custom container image: the sdk bootstraps its runtime onto any +image with python3. + + rp dev tests/e2e/examples/12_custom_image.py --once +""" + +import runpod +from runpod import App + +app = App("ex-image") + + +@app.queue(cpu="cpu3c-1-2", image="python:3.12-slim") +def python_version(): + import sys + + return f"{sys.version_info.major}.{sys.version_info.minor}" + + +@runpod.local_entrypoint +def main(): + version = python_version.remote() + print("worker python:", version) + assert version == "3.12" + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/13_streaming.py b/tests/e2e/examples/13_streaming.py new file mode 100644 index 00000000..a2cb5215 --- /dev/null +++ b/tests/e2e/examples/13_streaming.py @@ -0,0 +1,56 @@ +"""streaming permutations: sync/async generators, every consumption style. + + rp dev tests/e2e/examples/13_streaming.py --once +""" + +import asyncio + +import runpod +from runpod import App + +app = App("ex-stream") + + +@app.queue(cpu="cpu3c-1-2") +def count_up(n: int): + for i in range(n): + yield {"i": i} + + +@app.queue(cpu="cpu3c-1-2") +async def count_down(n: int): + for i in range(n, 0, -1): + yield i + + +@runpod.local_entrypoint +def main(): + # sync iteration over a sync generator + chunks = list(count_up.stream(3)) + assert chunks == [{"i": 0}, {"i": 1}, {"i": 2}], chunks + + # async iteration over an async generator + async def consume(): + return [c async for c in count_down.stream.aio(3)] + + chunks = asyncio.run(consume()) + assert chunks == [3, 2, 1], chunks + + # .remote() on a generator aggregates every chunk + all_chunks = count_up.remote(2) + assert all_chunks == [{"i": 0}, {"i": 1}], all_chunks + + # spawn -> reconnect-style streaming from the job handle + job = count_up.spawn(2) + chunks = list(job.stream()) + assert chunks == [{"i": 0}, {"i": 1}], chunks + assert job.result() == [{"i": 0}, {"i": 1}] + + # .local() still yields directly, no cloud involved + assert list(count_up.local(2)) == [{"i": 0}, {"i": 1}] + + print("all streaming styles ok") + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/examples/README.md b/tests/e2e/examples/README.md new file mode 100644 index 00000000..d535d4f4 --- /dev/null +++ b/tests/e2e/examples/README.md @@ -0,0 +1,40 @@ +# e2e example suite + +self-asserting apps exercising every sdk feature against production. +each file fails loudly when a result is wrong, so the set doubles as a +release gate. every example carries both a `@runpod.local_entrypoint` +and an `if __name__ == "__main__"` block, so the same `main()` runs in +three modes: + +| mode | command | what it exercises | +| ------ | -------------------- | --------------------------------------------- | +| dev | `rp dev --once` | ephemeral endpoints, entrypoint, teardown | +| deploy | `rp deploy ` | persistent endpoints for the app | +| invoke | `python3 ` | `main()` against the deployed endpoints | + +`run_all.sh` runs every example through all three (the deploy phase does +deploy -> invoke -> undeploy per file, leaving nothing standing): + +```bash +./tests/e2e/examples/run_all.sh # everything, every mode +./tests/e2e/examples/run_all.sh 01 07 # subset by prefix + +SKIP_DEV=1 ./tests/e2e/examples/run_all.sh # deploy/invoke only +SKIP_DEPLOY=1 ./tests/e2e/examples/run_all.sh # dev only +KEEP=1 ./tests/e2e/examples/run_all.sh # leave endpoints up +``` + +prerequisites: + +```bash +rp login +rp secret add ex-demo-secret --value anything # for 09_secrets +``` + +`12_custom_image` installs the runpod package at cold start; until the +release with `runpod.runtimes` is on pypi, point the bootstrap at the +branch tarball (plain https, not git+ — slim images have no git): + +```bash +export RUNPOD_PACKAGE_SPEC=https://github.com/runpod/runpod-python/archive/refs/heads/feat/apps-sdk.tar.gz +``` diff --git a/tests/e2e/examples/run_all.sh b/tests/e2e/examples/run_all.sh new file mode 100755 index 00000000..61999155 --- /dev/null +++ b/tests/e2e/examples/run_all.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# run every example against prod as an end-to-end suite, in three modes. +# +# each example is a self-asserting app with both a +# @runpod.local_entrypoint and an `if __name__ == "__main__"` block, so +# the same main() runs under all three: +# +# dev rp dev --once ephemeral endpoints, run entrypoint, tear down +# deploy rp deploy persistent endpoints for the app +# invoke python3 run main() against the deployed endpoints +# +# the deploy phase runs deploy -> invoke -> undeploy per file, so no +# endpoints are left standing. requires `rp login` and, for 09_secrets: +# +# rp secret add ex-demo-secret --value anything +# +# output streams live so you see the exact cli output. +# +# usage: +# ./run_all.sh # every example, every mode +# ./run_all.sh 01 05 08 # subset by prefix +# +# env toggles: +# SKIP_DEV=1 skip the rp dev phase +# SKIP_DEPLOY=1 skip the rp deploy/python3 phase +# KEEP=1 leave deployed endpoints up (skip undeploy) + +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +files=() +for f in "$HERE"/[0-9][0-9]_*.py; do + name="$(basename "$f")" + if [ "$#" -eq 0 ]; then + files+=("$f") + else + for pre in "$@"; do + if [[ "$name" == "$pre"* ]]; then + files+=("$f") + break + fi + done + fi +done + +if [ "${#files[@]}" -eq 0 ]; then + echo "no examples match: $*" >&2 + exit 1 +fi + +passed=() +failed=() + +app_name_of() { + sed -nE 's/.*App\("([^"]+)".*/\1/p' "$1" | head -1 +} + +# run one step; stream output live, record pass/fail as " ". +run_step() { + local mode="$1" name="$2" + shift 2 + echo "===================================================================" + echo ">>> [$mode] $name" + echo "===================================================================" + local start rc elapsed + start=$(date +%s) + "$@" + rc=$? + elapsed=$(( $(date +%s) - start )) + if [ "$rc" -eq 0 ]; then + echo "<<< PASS [$mode] $name (${elapsed}s)" + passed+=("$mode $name") + else + echo "<<< FAIL [$mode] $name (${elapsed}s, exit $rc)" + failed+=("$mode $name") + fi + echo + return "$rc" +} + +echo "running ${#files[@]} examples" +echo + +# ---------------------------------------------------------------- dev +if [ -z "${SKIP_DEV:-}" ]; then + for f in "${files[@]}"; do + run_step dev "$(basename "$f")" rp dev "$f" --once + done +fi + +# ------------------------------------------------- deploy -> invoke -> undeploy +if [ -z "${SKIP_DEPLOY:-}" ]; then + for f in "${files[@]}"; do + name="$(basename "$f")" + app="$(app_name_of "$f")" + + if run_step deploy "$name" rp deploy "$f"; then + run_step invoke "$name" python3 "$f" + if [ -z "${KEEP:-}" ] && [ -n "$app" ]; then + echo ">>> cleanup: rp undeploy -a $app -y" + rp undeploy -a "$app" -y || echo "!!! cleanup failed for $app" >&2 + echo + fi + fi + done +fi + +# ------------------------------------------------------------- summary +total=$(( ${#passed[@]} + ${#failed[@]} )) +echo "===================================================================" +echo "${#passed[@]}/${total} steps passed" +if [ "${#failed[@]}" -gt 0 ]; then + for s in "${failed[@]}"; do + echo " FAIL $s" + done +fi + +[ "${#failed[@]}" -eq 0 ] diff --git a/tests/e2e/matrix/main.py b/tests/e2e/matrix/main.py new file mode 100644 index 00000000..070022bc --- /dev/null +++ b/tests/e2e/matrix/main.py @@ -0,0 +1,133 @@ +"""e2e matrix app: every resource kind, cross-calls, deps, custom images. + +deployed by tests/e2e/matrix/run.py; each resource exercises a distinct +permutation of the surface. +""" + +import runpod +from runpod import App + +app = App("e2e-matrix") + + +# -- queue permutations ------------------------------------------------ + +@app.queue(name="q-basic", cpu="cpu3c-1-2", workers=(0, 1)) +def q_basic(x: int): + return {"doubled": x * 2} + + +@app.queue( + name="q-deps", + cpu="cpu3c-2-4", + workers=(0, 1), + dependencies=["pyfiglet"], + system_dependencies=["jq"], +) +def q_deps(word: str): + import shutil + import pyfiglet + + return { + "art": pyfiglet.figlet_format(word).splitlines()[0], + "pyfiglet": pyfiglet.__version__, + "jq": shutil.which("jq") is not None, + } + + +@app.queue( + name="q-custom", + cpu="cpu3c-2-4", + workers=(0, 1), + image="python:3.12-slim", + dependencies=["humanize"], +) +def q_custom(n: int): + import humanize + + return {"human": humanize.intword(n)} + + +@app.queue(name="q-gpu", gpu=runpod.GpuGroup.ADA_24, workers=(0, 1)) +def q_gpu(): + import torch + + return { + "torch": torch.__version__, + "cuda": torch.cuda.is_available(), + "device": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, + } + + +# -- streaming --------------------------------------------------------- + +@app.queue(name="q-stream", cpu="cpu3c-1-2", workers=(0, 1)) +def q_stream(n: int): + for i in range(n): + yield {"i": i} + + +@app.queue(name="q-stream-async", cpu="cpu3c-1-2", workers=(0, 1)) +async def q_stream_async(n: int): + for i in range(n, 0, -1): + yield i + + +# -- cross-calls ------------------------------------------------------- + +@app.queue(name="q-caller", cpu="cpu3c-2-4", workers=(0, 1)) +async def q_caller(x: int): + """queue worker fanning out to another queue and a task.""" + doubled = await q_basic.remote.aio(x) + product = await t_mul.remote.aio(x, 10) + return {"from_queue": doubled, "from_task": product} + + +# -- tasks ------------------------------------------------------------- + +@app.task(name="t-mul", cpu="cpu3c-1-2") +def t_mul(a: int, b: int): + return {"product": a * b} + + +@app.task(name="t-gen", cpu="cpu3c-1-2") +def t_gen(n: int): + for i in range(n): + yield i * i + + +@app.task(name="t-gpu", gpu=runpod.GpuGroup.ADA_24) +def t_gpu(size: int): + import torch + + m = torch.rand(size, size, device="cuda") + return {"trace": float(m.trace()), "device": torch.cuda.get_device_name(0)} + + +# -- api (load-balanced) ----------------------------------------------- + +@app.api(name="a-svc", cpu="cpu3c-2-4", workers=(1, 1)) +class Svc: + @runpod.init + def setup(self): + self.counter = 0 + self.ready = True + + @runpod.post("/bump") + def bump(self, body: dict): + self.counter += int(body.get("by", 1)) + return {"counter": self.counter, "ready": self.ready} + + @runpod.get("/stats") + def stats(self): + return {"counter": self.counter} + + @runpod.get("/tokens") + def tokens(self): + from fastapi.responses import StreamingResponse + + def gen(): + for word in ("alpha", "beta", "gamma"): + yield word + "\n" + + return StreamingResponse(gen(), media_type="text/plain") diff --git a/tests/e2e/matrix/run.py b/tests/e2e/matrix/run.py new file mode 100644 index 00000000..125710c5 --- /dev/null +++ b/tests/e2e/matrix/run.py @@ -0,0 +1,203 @@ +"""drive the deployed e2e matrix. run AFTER `rp deploy tests/e2e/matrix`. + + RUNPOD_RUNTIME_TAG=dev python tests/e2e/matrix/run.py [--only q-basic,...] + +each check is independent; failures are collected, not fatal, so one +broken permutation doesn't hide the rest. +""" + +import argparse +import asyncio +import sys +import time +import traceback + +from main import ( # noqa: E402 + Svc, + app, + q_basic, + q_caller, + q_custom, + q_deps, + q_gpu, + q_stream, + q_stream_async, + t_gen, + t_gpu, + t_mul, +) + + +async def check_q_basic(): + r = await q_basic.remote.aio(21) + assert r == {"doubled": 42}, r + return r + + +async def check_q_deps(): + r = await q_deps.remote.aio("hi") + assert r["pyfiglet"], r + assert r["jq"] is True, r + return {k: r[k] for k in ("pyfiglet", "jq")} + + +async def check_q_custom(): + r = await q_custom.remote.aio(1_500_000) + assert r == {"human": "1.5 million"}, r + return r + + +async def check_q_gpu(): + r = await q_gpu.remote.aio() + assert r["cuda"] is True, r + return r + + +async def check_q_caller(): + r = await q_caller.remote.aio(3) + assert r["from_queue"] == {"doubled": 6}, r + assert r["from_task"] == {"product": 30}, r + return r + + +async def check_t_mul(): + r = await t_mul.remote.aio(6, 7) + assert r == {"product": 42}, r + return r + + +async def check_t_gpu(): + r = await t_gpu.remote.aio(64) + assert "device" in r, r + return r + + +async def check_api(): + r1 = await Svc.post.aio("/bump", {"by": 5}) + assert r1["counter"] >= 5 and r1["ready"] is True, r1 + r2 = await Svc.get.aio("/stats") + assert r2["counter"] >= 5, r2 + return {"bump": r1, "stats": r2} + + +async def check_api_streaming_response(): + # api routes may stream; the client delivers the body intact + r = await Svc.get.aio("/tokens") + assert r == "alpha\nbeta\ngamma\n", repr(r) + return {"body": r} + + +async def check_spawn(): + job = await q_basic.spawn.aio(4) + r = await job.result.aio() + assert r == {"doubled": 8}, r + return r + + +async def check_job_ops(): + job = await q_basic.spawn.aio(5) + status = await job.status.aio() + assert status, status + r = await job.result.aio() + assert r == {"doubled": 10}, r + # reconnect by id and read the terminal status + reconnected = await q_basic.job.aio(job.id) + status = await reconnected.status.aio() + assert status == "COMPLETED", status + return {"id": job.id, "status": status} + + +async def check_stream(): + chunks = [c async for c in q_stream.stream.aio(3)] + assert chunks == [{"i": 0}, {"i": 1}, {"i": 2}], chunks + return chunks + + +async def check_stream_async_gen(): + chunks = [c async for c in q_stream_async.stream.aio(3)] + assert chunks == [3, 2, 1], chunks + return chunks + + +async def check_stream_aggregate(): + r = await q_stream.remote.aio(2) + assert r == [{"i": 0}, {"i": 1}], r + return r + + +async def check_stream_from_job(): + job = await q_stream.spawn.aio(2) + chunks = [c async for c in job.stream.aio()] + assert chunks == [{"i": 0}, {"i": 1}], chunks + return chunks + + +async def check_task_generator_aggregates(): + r = await t_gen.remote.aio(4) + assert r == [0, 1, 4, 9], r + return r + + +async def check_task_stream_rejected(): + from runpod.apps.errors import InvalidResourceError + + try: + async for _ in t_gen.stream.aio(2): + pass + except InvalidResourceError as exc: + assert "do not stream" in str(exc), exc + return {"rejected": True} + raise AssertionError("task .stream() should raise InvalidResourceError") + + +CHECKS = { + "q-basic": check_q_basic, + "q-deps": check_q_deps, + "q-custom": check_q_custom, + "q-gpu": check_q_gpu, + "q-caller": check_q_caller, + "t-mul": check_t_mul, + "t-gpu": check_t_gpu, + "api": check_api, + "spawn": check_spawn, + "job-ops": check_job_ops, + "stream": check_stream, + "stream-async-gen": check_stream_async_gen, + "stream-aggregate": check_stream_aggregate, + "stream-from-job": check_stream_from_job, + "task-gen-aggregate": check_task_generator_aggregates, + "task-stream-rejected": check_task_stream_rejected, + "api-streaming-response": check_api_streaming_response, +} + + +async def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--only", help="comma-separated check names") + args = parser.parse_args() + + names = args.only.split(",") if args.only else list(CHECKS) + results, failures = {}, {} + + for name in names: + started = time.monotonic() + print(f"--- {name} ...", flush=True) + try: + results[name] = await CHECKS[name]() + elapsed = time.monotonic() - started + print(f" PASS {elapsed:.1f}s {results[name]}", flush=True) + except Exception as exc: # noqa: BLE001 - collect and report + elapsed = time.monotonic() - started + failures[name] = exc + print(f" FAIL {elapsed:.1f}s {exc}", flush=True) + traceback.print_exc() + + print(f"\n{len(results)}/{len(names)} passed") + if failures: + print(f"failed: {', '.join(failures)}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/e2e/task_pod_smoke.py b/tests/e2e/task_pod_smoke.py new file mode 100644 index 00000000..32b62854 --- /dev/null +++ b/tests/e2e/task_pod_smoke.py @@ -0,0 +1,37 @@ +"""manual e2e smoke test for @app.task pod execution. + +deploys a real cpu pod via the env-injection bootstrap (python:3.12-slim +so the test does not depend on the runpod/task images existing yet), +executes a function, and terminates the pod. + +run directly, not via pytest: + python tests/e2e/task_pod_smoke.py +""" + +import asyncio +import sys + +from runpod.apps import App + +app = App("task-e2e") + + +@app.task(name="smoke", cpu="cpu3c-1-2", image="python:3.12-slim") +def smoke(x: int, y: int): + import platform + + return {"product": x * y, "python": platform.python_version()} + + +async def main() -> int: + print("running smoke.remote(6, 7) (deploys pod, executes, terminates) ...") + result = await smoke.remote.aio(6, 7) + print(f"result: {result}") + assert result["product"] == 42, f"unexpected result: {result}" + assert result["python"].startswith("3.12") + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/test_apps/__init__.py b/tests/test_apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_apps/test_api_client.py b/tests/test_apps/test_api_client.py new file mode 100644 index 00000000..095ad90f --- /dev/null +++ b/tests/test_apps/test_api_client.py @@ -0,0 +1,375 @@ +"""unit tests for the apps control-plane client.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest + +from runpod.apps.api import AppsApiClient +from runpod.error import QueryError + + +def _respond(data): + return AsyncMock(return_value={"data": data}) + + +def _client_with(data): + client = AppsApiClient(api_key="test-key") + return client, patch( + "runpod.apps.api.run_graphql_query_async", _respond(data) + ) + + +class TestExecuteRetry: + async def test_returns_data(self): + client, patcher = _client_with({"ok": 1}) + with patcher: + assert await client._execute("query {}") == {"ok": 1} + + async def test_retries_transport_errors(self): + client = AppsApiClient(api_key="test-key") + transport = AsyncMock( + side_effect=[ + aiohttp.ClientError("reset"), + OSError("dns"), + {"data": {"ok": 1}}, + ] + ) + with ( + patch("runpod.apps.api.run_graphql_query_async", transport), + patch("asyncio.sleep", AsyncMock()), + ): + assert await client._execute("query {}") == {"ok": 1} + assert transport.await_count == 3 + + async def test_exhausted_retries_raise(self): + client = AppsApiClient(api_key="test-key") + transport = AsyncMock(side_effect=aiohttp.ClientError("down")) + with ( + patch("runpod.apps.api.run_graphql_query_async", transport), + patch("asyncio.sleep", AsyncMock()), + ): + with pytest.raises(aiohttp.ClientError): + await client._execute("query {}") + assert transport.await_count == 4 + + async def test_graphql_errors_propagate_immediately(self): + client = AppsApiClient(api_key="test-key") + transport = AsyncMock(side_effect=QueryError("bad query", "query {}")) + with patch("runpod.apps.api.run_graphql_query_async", transport): + with pytest.raises(QueryError): + await client._execute("query {}") + assert transport.await_count == 1 + + +class TestEndpoints: + async def test_save_endpoint(self): + client, patcher = _client_with( + {"saveEndpoint": {"id": "ep1", "name": "chat"}} + ) + with patcher: + result = await client.save_endpoint({"name": "chat"}) + assert result["id"] == "ep1" + + async def test_delete_endpoint(self): + client, patcher = _client_with({"deleteEndpoint": True}) + with patcher: + assert await client.delete_endpoint("ep1") is True + + async def test_list_my_endpoints(self): + client, patcher = _client_with( + {"myself": {"endpoints": [{"id": "ep1"}]}} + ) + with patcher: + assert await client.list_my_endpoints() == [{"id": "ep1"}] + + +class TestTaskPods: + async def test_deploy_gpu_pod(self): + client = AppsApiClient(api_key="test-key") + transport = _respond( + {"podFindAndDeployOnDemand": {"id": "pod1", "desiredStatus": "RUNNING"}} + ) + with patch("runpod.apps.api.run_graphql_query_async", transport): + result = await client.deploy_task_pod( + {"gpuTypeIdList": ["NVIDIA GeForce RTX 4090"]}, is_cpu=False + ) + assert result["id"] == "pod1" + + async def test_deploy_cpu_pod_converts_instance_ids(self): + client = AppsApiClient(api_key="test-key") + transport = _respond({"deployCpuPod": {"id": "pod2"}}) + with patch("runpod.apps.api.run_graphql_query_async", transport): + result = await client.deploy_task_pod( + {"instanceIds": ["cpu3c-2-4", "cpu3g-2-8"]}, is_cpu=True + ) + assert result["id"] == "pod2" + sent = transport.call_args[1]["variables"]["input"] + assert sent["instanceId"] == "cpu3c-2-4" + assert "instanceIds" not in sent + + async def test_terminate_pod(self): + client = AppsApiClient(api_key="test-key") + transport = _respond({"podTerminate": None}) + with patch("runpod.apps.api.run_graphql_query_async", transport): + await client.terminate_pod("pod1") + sent = transport.call_args[1]["variables"]["input"] + assert sent == {"podId": "pod1"} + + +class TestAppLifecycle: + async def test_get_app_by_name(self): + client, patcher = _client_with( + {"flashAppByName": {"id": "app1", "name": "demo"}} + ) + with patcher: + app = await client.get_app_by_name("demo") + assert app["id"] == "app1" + + async def test_create_app(self): + client, patcher = _client_with( + {"createFlashApp": {"id": "app1", "name": "demo"}} + ) + with patcher: + assert (await client.create_app("demo"))["id"] == "app1" + + async def test_create_environment(self): + client, patcher = _client_with( + {"createFlashEnvironment": {"id": "env1", "name": "prod"}} + ) + with patcher: + result = await client.create_environment("app1", "prod") + assert result["id"] == "env1" + + async def test_list_apps(self): + client, patcher = _client_with( + {"myself": {"flashApps": [{"id": "app1"}]}} + ) + with patcher: + assert await client.list_apps() == [{"id": "app1"}] + + async def test_list_apps_empty(self): + client, patcher = _client_with({"myself": {"flashApps": None}}) + with patcher: + assert await client.list_apps() == [] + + async def test_delete_app(self): + client, patcher = _client_with({"deleteFlashApp": True}) + with patcher: + assert await client.delete_app("app1") is True + + async def test_delete_environment(self): + client, patcher = _client_with({"deleteFlashEnvironment": True}) + with patcher: + assert await client.delete_environment("env1") is True + + async def test_get_environment_by_name(self): + client = AppsApiClient(api_key="test-key") + transport = AsyncMock( + side_effect=[ + {"data": {"flashAppByName": {"id": "app1"}}}, + {"data": {"flashEnvironmentByName": {"id": "env1"}}}, + ] + ) + with patch("runpod.apps.api.run_graphql_query_async", transport): + env = await client.get_environment_by_name("demo", "prod") + assert env["id"] == "env1" + + async def test_get_environment_missing_app(self): + client, patcher = _client_with({"flashAppByName": None}) + with patcher: + assert await client.get_environment_by_name("demo", "prod") is None + + async def test_get_environment_not_found(self): + client = AppsApiClient(api_key="test-key") + transport = AsyncMock( + side_effect=[ + {"data": {"flashAppByName": {"id": "app1"}}}, + QueryError("environment not found", "query {}"), + ] + ) + with patch("runpod.apps.api.run_graphql_query_async", transport): + assert await client.get_environment_by_name("demo", "prod") is None + + +class TestStock: + async def test_gpu_stock_status(self): + client, patcher = _client_with( + {"gpuTypes": [{"lowestPrice": {"stockStatus": "High"}}]} + ) + with patcher: + status = await client.gpu_stock_status( + "NVIDIA GeForce RTX 4090", "US-KS-2" + ) + assert status == "High" + + async def test_gpu_stock_no_data(self): + client, patcher = _client_with({"gpuTypes": []}) + with patcher: + assert ( + await client.gpu_stock_status("X", "US-KS-2") is None + ) + + async def test_gpu_stock_pods_flag(self): + client = AppsApiClient(api_key="test-key") + transport = _respond({"gpuTypes": []}) + with patch("runpod.apps.api.run_graphql_query_async", transport): + await client.gpu_stock_status("X", "US-KS-2", pods=True) + sent = transport.call_args[1]["variables"]["lowestPriceInput"] + assert sent["includeAiApi"] is False + + async def test_cpu_stock_status(self): + client = AppsApiClient(api_key="test-key") + transport = _respond( + {"cpuFlavors": [{"specifics": {"stockStatus": "Low"}}]} + ) + with patch("runpod.apps.api.run_graphql_query_async", transport): + status = await client.cpu_stock_status("cpu3c-2-4", "US-KS-2") + assert status == "Low" + sent = transport.call_args[1]["variables"] + assert sent["cpuFlavorInput"] == {"id": "cpu3c"} + + +class TestVolumesRegistrySecrets: + async def test_list_network_volumes(self): + client, patcher = _client_with( + {"myself": {"networkVolumes": [{"id": "v1"}]}} + ) + with patcher: + assert await client.list_network_volumes() == [{"id": "v1"}] + + async def test_create_network_volume(self): + client, patcher = _client_with( + {"createNetworkVolume": {"id": "v1", "name": "data"}} + ) + with patcher: + result = await client.create_network_volume("data", 10, "US-KS-2") + assert result["id"] == "v1" + + async def test_registry_auth_crud(self): + client, patcher = _client_with( + { + "myself": {"containerRegistryCreds": [{"id": "r1"}]}, + "saveRegistryAuth": {"id": "r1", "name": "dh"}, + "deleteRegistryAuth": True, + } + ) + with patcher: + assert await client.list_registry_auths() == [{"id": "r1"}] + assert ( + await client.create_registry_auth("dh", "user", "pass") + )["id"] == "r1" + assert await client.delete_registry_auth("r1") is True + + async def test_secret_crud(self): + client, patcher = _client_with( + { + "myself": {"secrets": [{"id": "s1"}]}, + "secretCreate": {"id": "s1", "name": "tok"}, + "secretDelete": True, + } + ) + with patcher: + assert await client.list_secrets() == [{"id": "s1"}] + assert (await client.create_secret("tok", "v"))["id"] == "s1" + assert await client.delete_secret("s1") is True + + +class TestAuthRequests: + async def test_create_auth_request_is_anonymous(self): + client = AppsApiClient(api_key="test-key") + transport = _respond( + {"createFlashAuthRequest": {"id": "req1", "status": "PENDING"}} + ) + with patch("runpod.apps.api.run_graphql_query_async", transport): + result = await client.create_auth_request() + assert result["id"] == "req1" + assert transport.call_args[1]["anonymous"] is True + + async def test_status_poll_is_anonymous(self): + client = AppsApiClient(api_key="test-key") + transport = _respond( + {"flashAuthRequestStatus": {"id": "req1", "status": "APPROVED"}} + ) + with patch("runpod.apps.api.run_graphql_query_async", transport): + result = await client.get_auth_request_status("req1") + assert result["status"] == "APPROVED" + assert transport.call_args[1]["anonymous"] is True + + +class TestArtifacts: + async def test_prepare_artifact_upload(self): + client, patcher = _client_with( + { + "prepareFlashArtifactUpload": { + "uploadUrl": "https://s3", + "objectKey": "k", + } + } + ) + with patcher: + result = await client.prepare_artifact_upload("app1", 123) + assert result["objectKey"] == "k" + + async def test_finalize_artifact_upload(self): + client, patcher = _client_with( + {"finalizeFlashArtifactUpload": {"id": "b1", "manifest": {}}} + ) + with patcher: + result = await client.finalize_artifact_upload("app1", "k", {}) + assert result["id"] == "b1" + + async def test_deploy_build(self): + client, patcher = _client_with( + {"deployBuildToEnvironment": {"id": "env1", "name": "prod"}} + ) + with patcher: + result = await client.deploy_build("env1", "b1") + assert result["id"] == "env1" + + +class TestUploadTarball: + async def test_upload_reports_progress(self, tmp_path): + tar = tmp_path / "app.tar.gz" + tar.write_bytes(b"x" * 2048) + client = AppsApiClient(api_key="test-key") + progress = MagicMock() + + put = AsyncMock() + with patch.object(client, "_put_tarball", put): + await client.upload_tarball("https://s3", str(tar), progress) + put.assert_awaited_once() + + # drain the reader to drive progress callbacks + reader = put.call_args[0][1] + async for _ in reader: + pass + progress.assert_called_with(2048, 2048) + + async def test_upload_retries_then_succeeds(self, tmp_path): + tar = tmp_path / "app.tar.gz" + tar.write_bytes(b"x") + client = AppsApiClient(api_key="test-key") + + put = AsyncMock(side_effect=[OSError("broken pipe"), None]) + with ( + patch.object(client, "_put_tarball", put), + patch("asyncio.sleep", AsyncMock()), + ): + await client.upload_tarball("https://s3", str(tar)) + assert put.await_count == 2 + + async def test_upload_exhausted_raises(self, tmp_path): + tar = tmp_path / "app.tar.gz" + tar.write_bytes(b"x") + client = AppsApiClient(api_key="test-key") + + put = AsyncMock(side_effect=aiohttp.ClientError("reset")) + with ( + patch.object(client, "_put_tarball", put), + patch("asyncio.sleep", AsyncMock()), + ): + with pytest.raises(aiohttp.ClientError): + await client.upload_tarball("https://s3", str(tar)) + assert put.await_count == 4 diff --git a/tests/test_apps/test_api_server.py b/tests/test_apps/test_api_server.py new file mode 100644 index 00000000..10804879 --- /dev/null +++ b/tests/test_apps/test_api_server.py @@ -0,0 +1,263 @@ +"""tests for the api runtime server.""" + +import json +import textwrap + +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient + +import runpod +from runpod.apps.app import _clear_registry +from runpod.runtimes.api import server + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +def _write_project(tmp_path, monkeypatch, module="main_cls"): + (tmp_path / f"{module}.py").write_text( + textwrap.dedent( + """ + import runpod + from runpod import App, init, get, post + + app = App("api-test") + + @app.api(name="inference", cpu="cpu3c-1-2") + class Inference: + @init + def setup(self): + self.model = "loaded" + + @get("/health") + def health(self): + return {"model": self.model} + + @post("/generate") + async def generate(self, body: dict): + return {"echo": body, "model": self.model} + """ + ) + ) + manifest = { + "version": 1, + "app": "api-test", + "resources": [ + { + "kind": "api", + "name": "inference", + "module": module, + "routes": [ + {"method": "GET", "path": "/health", "handler": "health"}, + { + "method": "POST", + "path": "/generate", + "handler": "generate", + }, + ], + } + ], + } + (tmp_path / "runpod_manifest.json").write_text(json.dumps(manifest)) + monkeypatch.setattr(server, "APP_DIR", str(tmp_path)) + monkeypatch.setenv("FLASH_RESOURCE_NAME", "inference") + + +class TestDeployedClassApi: + def test_routes_and_init(self, tmp_path, monkeypatch): + _write_project(tmp_path, monkeypatch) + app = server.build_app() + with TestClient(app) as client: + # startup ran init before serving + response = client.get("/ping") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + response = client.get("/health") + assert response.json() == {"model": "loaded"} + + response = client.post("/generate", json={"prompt": "hi"}) + assert response.json() == { + "echo": {"prompt": "hi"}, + "model": "loaded", + } + + def test_missing_resource_errors(self, tmp_path, monkeypatch): + _write_project(tmp_path, monkeypatch) + monkeypatch.setenv("FLASH_RESOURCE_NAME", "nope") + with pytest.raises(RuntimeError, match="nope"): + server.build_app() + + +class TestFactoryApi: + def test_asgi_factory_served(self, tmp_path, monkeypatch): + (tmp_path / "main_factory.py").write_text( + textwrap.dedent( + """ + from runpod import App + + app = App("api-test") + + @app.api(name="web", cpu="cpu3c-1-2") + def web(): + from fastapi import FastAPI + + server = FastAPI() + + @server.post("/echo") + async def echo(body: dict): + return body + + return server + """ + ) + ) + manifest = { + "version": 1, + "app": "api-test", + "resources": [ + {"kind": "api", "name": "web", "module": "main_factory"} + ], + } + (tmp_path / "runpod_manifest.json").write_text(json.dumps(manifest)) + monkeypatch.setattr(server, "APP_DIR", str(tmp_path)) + monkeypatch.setenv("FLASH_RESOURCE_NAME", "web") + + app = server.build_app() + with TestClient(app) as client: + assert client.get("/ping").json() == {"status": "healthy"} + assert client.post("/echo", json={"a": 1}).json() == {"a": 1} + + +class TestLiveApi: + def test_execute_endpoint(self, monkeypatch): + monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False) + monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False) + app = server.build_app() + with TestClient(app) as client: + assert client.get("/ping").json() == {"status": "healthy"} + response = client.post( + "/execute", + json={ + "input": { + "function_name": "f", + "function_code": "def f(a, b):\n return a * b", + "args": [6, 7], + "kwargs": {}, + "serialization_format": "json", + } + }, + ) + data = response.json() + assert data["success"] is True + assert data["json_result"] == 42 + + +class TestLiveDispatcher: + def test_sync_materializes_routes(self): + from fastapi.testclient import TestClient + + from runpod.runtimes.api.server import _build_live_app + + source = ( + "import runpod\n" + "from runpod.apps.markers import get, init, post\n" + "app = runpod.App('live')\n" + "@app.api(cpu='cpu3c-1-2')\n" + "class Counter:\n" + " @init\n" + " def setup(self):\n" + " self.count = 0\n" + " @post('/bump')\n" + " async def bump(self, body: dict):\n" + " self.count += body.get('by', 1)\n" + " return {'count': self.count}\n" + " @get('/value')\n" + " async def value(self):\n" + " return {'count': self.count}\n" + ) + + client = TestClient(_build_live_app()) + # before sync: core routes only + assert client.get("/ping").status_code == 200 + assert client.post("/bump", json={}).status_code == 404 + + r = client.post( + "/_runpod/sync", json={"source": source, "resource": "Counter"} + ) + assert r.status_code == 200 + + assert client.post("/bump", json={"by": 3}).json() == {"count": 3} + assert client.get("/value").json() == {"count": 3} + # core routes still work after materialization + assert client.get("/ping").status_code == 200 + + def test_sync_installs_dependencies_before_materializing(self): + from unittest.mock import patch + + from fastapi.testclient import TestClient + + from runpod.runtimes.api.server import _build_live_app + + source = ( + "import runpod\n" + "from runpod.apps.markers import get\n" + "app = runpod.App('live-deps')\n" + "@app.api(cpu='cpu3c-1-2', dependencies=['somepkg'])\n" + "class D:\n" + " @get('/ok')\n" + " async def ok(self):\n" + " return {'ok': True}\n" + ) + client = TestClient(_build_live_app()) + with ( + patch( + "runpod.runtimes.executor._install", return_value=None + ) as install, + patch( + "runpod.runtimes.executor._install_system", + return_value=None, + ) as install_system, + ): + r = client.post( + "/_runpod/sync", + json={ + "source": source, + "resource": "D", + "dependencies": ["somepkg"], + "system_dependencies": ["libfoo"], + }, + ) + assert r.status_code == 200 + install.assert_called_once_with(["somepkg"], "dependencies") + install_system.assert_called_once_with(["libfoo"]) + assert client.get("/ok").json() == {"ok": True} + + def test_resync_same_source_keeps_state(self): + from fastapi.testclient import TestClient + + from runpod.runtimes.api.server import _build_live_app + + source = ( + "import runpod\n" + "from runpod.apps.markers import post\n" + "app = runpod.App('live2')\n" + "@app.api(cpu='cpu3c-1-2')\n" + "class S:\n" + " @post('/add')\n" + " async def add(self, body: dict):\n" + " self.total = getattr(self, 'total', 0) + body['x']\n" + " return {'total': self.total}\n" + ) + client = TestClient(_build_live_app()) + client.post("/_runpod/sync", json={"source": source, "resource": "S"}) + assert client.post("/add", json={"x": 2}).json() == {"total": 2} + # identical source: no rebuild, state survives + client.post("/_runpod/sync", json={"source": source, "resource": "S"}) + assert client.post("/add", json={"x": 3}).json() == {"total": 5} diff --git a/tests/test_apps/test_app.py b/tests/test_apps/test_app.py new file mode 100644 index 00000000..803b3073 --- /dev/null +++ b/tests/test_apps/test_app.py @@ -0,0 +1,324 @@ +"""tests for the App registry and decorators.""" + +import pytest + +import runpod +from runpod.apps import App, ApiHandle, FunctionHandle, ResourceKind +from runpod.apps.app import _REGISTRY, _clear_registry +from runpod.apps.errors import InvalidResourceError +from runpod.apps.markers import get, init, post + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +def test_app_registers_itself(): + app = App("my-app") + assert app in _REGISTRY + assert runpod.apps.get_registered_apps() == [app] + + +def test_app_requires_name(): + with pytest.raises(InvalidResourceError): + App("") + + +def test_queue_decorator_returns_handle(): + app = App("a") + + @app.queue(name="q", gpu=runpod.GpuType.NVIDIA_L4, workers=3) + async def q(x: int): + return x + + assert isinstance(q, FunctionHandle) + assert q.spec.kind is ResourceKind.QUEUE + assert q.spec.name == "q" + assert q.spec.gpu == ["NVIDIA L4"] + assert q.spec.workers == (0, 3) + assert app.resources["q"] is q + + +def test_queue_name_defaults_to_function_name(): + app = App("a") + + @app.queue() + def my_fn(): + return 1 + + assert my_fn.spec.name == "my_fn" + + +def test_task_decorator(): + app = App("a") + + @app.task(name="t", cpu="cpu5c-2-4") + def t(): + return "ok" + + assert t.spec.kind is ResourceKind.TASK + assert t.spec.cpu == ["cpu5c-2-4"] + + +def test_gpu_cpu_mutually_exclusive(): + app = App("a") + with pytest.raises(InvalidResourceError): + + @app.queue(name="bad", gpu=runpod.GpuType.NVIDIA_L4, cpu="cpu5c-2-4") + def bad(): + pass + + +def test_duplicate_resource_name_rejected(): + app = App("a") + + @app.queue(name="dupe") + def one(): + pass + + with pytest.raises(InvalidResourceError): + + @app.queue(name="dupe") + def two(): + pass + + +def test_workers_int_shorthand(): + app = App("a") + + @app.queue(name="q", workers=5) + def q(): + pass + + assert q.spec.workers == (0, 5) + + +def test_workers_tuple(): + app = App("a") + + @app.queue(name="q", workers=(1, 4)) + def q(): + pass + + assert q.spec.workers == (1, 4) + + +def test_workers_invalid(): + app = App("a") + with pytest.raises(InvalidResourceError): + + @app.queue(name="q", workers=(3, 1)) + def q(): + pass + + +def test_api_class_collects_routes(): + app = App("a") + + @app.api(name="api", cpu="cpu5c-2-4") + class Inference: + @init + def setup(self): + self.model = object() + + @get("/health") + def health(self): + return {"ok": True} + + @post("/generate") + async def generate(self, body: dict): + return body + + assert isinstance(Inference, ApiHandle) + routes = {(r.method, r.path) for r in Inference.spec.routes} + assert routes == {("GET", "/health"), ("POST", "/generate")} + assert Inference._init_name == "setup" + + +def test_api_class_without_routes_rejected(): + app = App("a") + with pytest.raises(InvalidResourceError): + + @app.api(name="api") + class Empty: + def nothing(self): + pass + + +def test_api_duplicate_route_rejected(): + app = App("a") + with pytest.raises(InvalidResourceError): + + @app.api(name="api") + class Dupe: + @post("/x") + def one(self): + pass + + @post("/x") + def two(self): + pass + + +def test_api_asgi_factory(): + app = App("a") + + @app.api(name="web", cpu="cpu5c-2-4") + def web(): + return object() + + assert isinstance(web, ApiHandle) + assert web.spec.asgi_factory is not None + + +def test_api_reserved_path_rejected(): + with pytest.raises(ValueError): + post("/execute") + + +def test_schedule_below_app_decorator(): + app = App("a") + + @app.task(name="cron") + @runpod.schedule(cron="0 * * * *") + async def cron(): + pass + + assert cron.spec.schedule == "0 * * * *" + + +def test_schedule_above_app_decorator(): + app = App("a") + + @runpod.schedule(cron="*/5 * * * *") + @app.task(name="cron") + async def cron(): + pass + + assert cron.spec.schedule == "*/5 * * * *" + + +def test_handle_direct_call_raises(): + app = App("a") + + @app.queue(name="q") + def q(): + pass + + with pytest.raises(TypeError, match=r"\.remote\("): + q() + + +def test_handle_local_runs_body(): + app = App("a") + + @app.queue(name="q") + def q(x): + return x * 2 + + assert q.local(21) == 42 + + +async def test_handle_local_async_follows_signature(): + app = App("a") + + @app.queue(name="q") + async def q(x): + return x + 1 + + assert await q.local(1) == 2 + + +def test_manifest_serialization(): + app = App("a") + volume = "my-volume" + + @app.queue( + name="q", + gpu=[runpod.GpuGroup.ADA_24], + workers=(1, 3), + dependencies=["torch"], + volume=volume, + env={"KEY": "val"}, + ) + def q(): + pass + + manifest = q.spec.to_manifest() + assert manifest == { + "kind": "queue", + "name": "q", + "gpuCount": 1, + "workersMin": 1, + "workersMax": 3, + "idleTimeout": 60, + "gpus": ["ADA_24"], + "dependencies": ["torch"], + "networkVolume": "my-volume", + "env": {"KEY": "val"}, + } + + +class TestGpuStringResolution: + def test_pool_id_passthrough(self): + from runpod.apps.spec import normalize_gpu + + assert normalize_gpu("ADA_24") == ["ADA_24"] + assert normalize_gpu("ada_24") == ["ADA_24"] + + def test_device_name_passthrough(self): + from runpod.apps.spec import normalize_gpu + + assert normalize_gpu("NVIDIA B200") == ["NVIDIA B200"] + + def test_enum_style_name(self): + from runpod.apps.spec import normalize_gpu + + assert normalize_gpu("NVIDIA_B200") == ["NVIDIA B200"] + + def test_shorthand_fragments(self): + from runpod.apps.spec import normalize_gpu + + assert normalize_gpu("B200") == ["NVIDIA B200"] + assert normalize_gpu("4090") == ["NVIDIA GeForce RTX 4090"] + assert normalize_gpu("5090") == ["NVIDIA GeForce RTX 5090"] + + def test_fragment_matches_all_variants(self): + from runpod.apps.spec import normalize_gpu + + matches = normalize_gpu("A100") + assert set(matches) == { + "NVIDIA A100 80GB PCIe", + "NVIDIA A100-SXM4-80GB", + } + + def test_any_sentinel(self): + from runpod.apps.spec import normalize_gpu + + assert normalize_gpu("any") == ["any"] + + def test_unknown_raises_at_decoration(self): + import pytest + + from runpod.apps.errors import InvalidResourceError + from runpod.apps.spec import normalize_gpu + + with pytest.raises(InvalidResourceError, match="unknown gpu 'B300'"): + normalize_gpu("B300") + + def test_gpu_ids_maps_devices_to_pools(self): + from runpod.apps.gpu import gpu_ids_value + + # endpoints select by pool; device names map back + assert gpu_ids_value(["NVIDIA B200"]) == "BLACKWELL_180" + assert gpu_ids_value(["NVIDIA GeForce RTX 4090"]) == "ADA_24" + # pool ids pass through untouched + assert gpu_ids_value(["ADA_24"]) == "ADA_24" + # duplicates collapse (two devices in the same pool) + assert ( + gpu_ids_value(["NVIDIA A100 80GB PCIe", "NVIDIA A100-SXM4-80GB"]) + == "AMPERE_80" + ) diff --git a/tests/test_apps/test_auth.py b/tests/test_apps/test_auth.py new file mode 100644 index 00000000..335bfc2f --- /dev/null +++ b/tests/test_apps/test_auth.py @@ -0,0 +1,60 @@ +"""browser login flow.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from runpod.apps.auth import LoginError, auth_url, browser_login + + +def _api(statuses): + api = AsyncMock() + api.create_auth_request.return_value = { + "id": "req-1", + "status": "PENDING", + "expiresAt": None, + } + api.get_auth_request_status.side_effect = statuses + return api + + +class TestBrowserLogin: + def test_returns_key_on_approval(self, monkeypatch): + monkeypatch.setattr("runpod.apps.auth.POLL_INTERVAL_SECONDS", 0) + api = _api( + [ + {"status": "PENDING"}, + {"status": "APPROVED", "apiKey": "rpa_new"}, + ] + ) + urls = [] + key = asyncio.run( + browser_login(api=api, on_url=urls.append) + ) + assert key == "rpa_new" + assert urls == [auth_url("req-1")] + + def test_denied_raises(self, monkeypatch): + monkeypatch.setattr("runpod.apps.auth.POLL_INTERVAL_SECONDS", 0) + api = _api([{"status": "DENIED"}]) + with pytest.raises(LoginError, match="denied"): + asyncio.run(browser_login(api=api)) + + def test_timeout_raises(self, monkeypatch): + monkeypatch.setattr("runpod.apps.auth.POLL_INTERVAL_SECONDS", 0) + api = _api([{"status": "PENDING"}] * 50) + with pytest.raises(LoginError, match="timed out"): + asyncio.run(browser_login(api=api, timeout_seconds=0)) + + def test_missing_request_id_raises(self): + api = AsyncMock() + api.create_auth_request.return_value = {} + with pytest.raises(LoginError, match="initialize"): + asyncio.run(browser_login(api=api)) + + def test_consumed_without_key_raises(self, monkeypatch): + monkeypatch.setattr("runpod.apps.auth.POLL_INTERVAL_SECONDS", 0) + api = _api([{"status": "CONSUMED"}]) + with pytest.raises(LoginError, match="already used"): + asyncio.run(browser_login(api=api)) diff --git a/tests/test_apps/test_bootstrap.py b/tests/test_apps/test_bootstrap.py new file mode 100644 index 00000000..5eadedc0 --- /dev/null +++ b/tests/test_apps/test_bootstrap.py @@ -0,0 +1,460 @@ +"""tests for the queue bootstrap and custom-image support.""" + +import base64 +import json +import textwrap + +import pytest + +import runpod +from runpod.apps import App +from runpod.apps.app import _clear_registry +from runpod.apps.dev import _endpoint_input +from runpod.runtimes import bootstrap + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +class TestLocatePhase: + def test_missing_artifact_fails_with_phase(self, tmp_path, monkeypatch): + monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(tmp_path / "nope.tar.gz")) + monkeypatch.setattr(bootstrap, "ARTIFACT_WAIT_SECONDS", 0) + monkeypatch.setattr(bootstrap, "APP_DIR", str(tmp_path / "app")) + with pytest.raises(bootstrap.PhaseError) as exc_info: + bootstrap._locate() + assert exc_info.value.phase == "locate" + + def test_extracts_artifact(self, tmp_path, monkeypatch): + import tarfile + + src = tmp_path / "src" + src.mkdir() + (src / "main.py").write_text("x = 1") + artifact = tmp_path / "artifact.tar.gz" + with tarfile.open(artifact, "w:gz") as tar: + tar.add(src / "main.py", arcname="main.py") + + app_dir = tmp_path / "app" + monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(artifact)) + monkeypatch.setattr(bootstrap, "APP_DIR", str(app_dir)) + assert bootstrap._locate() == str(app_dir) + assert (app_dir / "main.py").read_text() == "x = 1" + + def test_unpack_happens_once(self, tmp_path, monkeypatch): + import tarfile + + src = tmp_path / "src" + src.mkdir() + (src / "main.py").write_text("x = 1") + artifact = tmp_path / "artifact.tar.gz" + with tarfile.open(artifact, "w:gz") as tar: + tar.add(src / "main.py", arcname="main.py") + + app_dir = tmp_path / "app" + monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(artifact)) + monkeypatch.setattr(bootstrap, "APP_DIR", str(app_dir)) + bootstrap._locate() + # second boot on a warm container: marker short-circuits + artifact.unlink() + assert bootstrap._locate() == str(app_dir) + + def test_prebuilt_dir_skips_unpack(self, tmp_path, monkeypatch): + prebuilt = tmp_path / "prebuilt" + prebuilt.mkdir() + monkeypatch.setattr(bootstrap, "PREBUILT_APP_DIR", str(prebuilt)) + # no artifact anywhere; the host-provided tree wins outright + monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(tmp_path / "no.tar.gz")) + assert bootstrap._locate() == str(prebuilt) + + def test_rejects_traversal(self, tmp_path, monkeypatch): + import io + import tarfile + + artifact = tmp_path / "evil.tar.gz" + with tarfile.open(artifact, "w:gz") as tar: + data = b"evil" + info = tarfile.TarInfo(name="../../etc/evil") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + + monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(artifact)) + monkeypatch.setattr(bootstrap, "APP_DIR", str(tmp_path / "app")) + with pytest.raises(bootstrap.PhaseError) as exc_info: + bootstrap._locate() + assert exc_info.value.phase == "locate" + + +class TestAttachPhase: + def test_env_dir_leads_path_order(self, tmp_path): + (tmp_path / "env").mkdir() + paths = bootstrap._attach(str(tmp_path)) + assert paths == [str(tmp_path / "env"), str(tmp_path)] + + def test_no_env_dir_falls_back_to_source_only(self, tmp_path): + paths = bootstrap._attach(str(tmp_path)) + assert paths == [str(tmp_path)] + + def test_manifest_read(self, tmp_path): + (tmp_path / "runpod_manifest.json").write_text( + json.dumps({"resources": [{"name": "q1"}]}) + ) + manifest = bootstrap._manifest(str(tmp_path)) + assert manifest["resources"][0]["name"] == "q1" + + def test_manifest_missing_fails_with_phase(self, tmp_path): + with pytest.raises(bootstrap.PhaseError) as exc_info: + bootstrap._manifest(str(tmp_path)) + assert exc_info.value.phase == "attach" + + +class TestSystemPhase: + def test_no_system_deps_no_op(self): + bootstrap._install_system({"resources": [{"name": "q"}]}) + + def test_missing_apt_fails_with_phase(self, monkeypatch): + import shutil + + monkeypatch.setenv("FLASH_RESOURCE_NAME", "q") + monkeypatch.setattr(shutil, "which", lambda _: None) + manifest = { + "resources": [{"name": "q", "systemDependencies": ["ffmpeg"]}] + } + with pytest.raises(bootstrap.PhaseError) as exc_info: + bootstrap._install_system(manifest) + assert exc_info.value.phase == "system" + assert "ffmpeg" in str(exc_info.value) + + def test_installs_resource_system_deps(self, monkeypatch): + import shutil + + monkeypatch.setenv("FLASH_RESOURCE_NAME", "q") + monkeypatch.setattr(shutil, "which", lambda _: "/usr/bin/apt-get") + calls = [] + + class R: + returncode = 0 + stderr = "" + + monkeypatch.setattr( + bootstrap.subprocess, + "run", + lambda cmd, **kw: calls.append(cmd) or R(), + ) + manifest = { + "resources": [{"name": "q", "systemDependencies": ["ffmpeg", "sox"]}] + } + bootstrap._install_system(manifest) + assert calls[0][:2] == ["apt-get", "update"] + assert calls[1][:3] == ["apt-get", "install", "-y"] + assert "ffmpeg" in calls[1] and "sox" in calls[1] + + def test_other_resources_deps_ignored(self, monkeypatch): + monkeypatch.setenv("FLASH_RESOURCE_NAME", "q") + manifest = { + "resources": [{"name": "other", "systemDependencies": ["ffmpeg"]}] + } + # q has no system deps; must not attempt anything + bootstrap._install_system(manifest) + + +class TestVerifyPhase: + def test_no_exclusions_no_op(self): + bootstrap._verify_excluded({"resources": []}) + + def test_present_exclusions_no_install(self, monkeypatch): + installed = [] + monkeypatch.setattr( + bootstrap, "_pip_install", lambda pkgs, phase: installed.extend(pkgs) + ) + # json is definitely importable; stands in for torch-in-image + bootstrap._verify_excluded({"excludedPackages": ["json"]}) + assert installed == [] + + def test_missing_exclusions_installed(self, monkeypatch): + installed = [] + monkeypatch.setattr( + bootstrap, "_pip_install", lambda pkgs, phase: installed.extend(pkgs) + ) + bootstrap._verify_excluded( + {"excludedPackages": ["definitely-not-installed-xyz"]} + ) + assert installed == ["definitely-not-installed-xyz"] + + +class TestCustomImagePayloads: + def test_queue_custom_image_gets_bootstrap(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2", image="pytorch/pytorch:latest") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + template = payload["template"] + assert template["imageName"] == "pytorch/pytorch:latest" + assert "bootstrap.py" in template["dockerArgs"] + env = {e["key"]: e["value"] for e in template["env"]} + decoded = base64.b64decode(env["RUNPOD_BOOTSTRAP_B64"]).decode() + assert "def _locate" in decoded + + def test_queue_default_image_no_bootstrap(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + template = payload["template"] + from runpod.apps.images import local_python_version + + assert template["imageName"].startswith( + f"runpod/queue:py{local_python_version()}-" + ) + assert template["dockerArgs"] == "" + env = {e["key"]: e["value"] for e in template["env"]} + assert "RUNPOD_BOOTSTRAP_B64" not in env + + def test_api_custom_image_supported(self): + app = App("a") + + @app.api(name="api", cpu="cpu3c-1-2", image="my/server:1") + class Api: + @runpod.post("/x") + def x(self, body: dict): + return body + + payload = _endpoint_input(app, Api.spec) + assert payload["template"]["imageName"] == "my/server:1" + + def test_image_in_manifest(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2", image="custom:1") + def q(): + pass + + assert q.spec.to_manifest()["imageName"] == "custom:1" + + +class TestBootstrapIsStdlibOnly: + def test_no_third_party_imports(self): + import ast + from pathlib import Path + + source = ( + Path(bootstrap.__file__).read_text() + ) + tree = ast.parse(source) + # module-level imports only: function-local imports (the runpod + # availability probe in _ensure_runtime) run after installation + imports = set() + for node in tree.body: + if isinstance(node, ast.Import): + imports.update(a.name.split(".")[0] for a in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0: + imports.add(node.module.split(".")[0]) + stdlib = { + "json", "os", "subprocess", "sys", "tarfile", "time", + "urllib", "io", "importlib", "http", + } + assert imports <= stdlib, f"non-stdlib imports: {imports - stdlib}" + + +class TestErrorSurface: + def test_error_payload_shape(self): + error = bootstrap.PhaseError("locate", "artifact missing") + payload = bootstrap._error_payload(error) + assert payload["error_type"] == "BootstrapError" + assert "locate" in payload["error_message"] + assert "artifact missing" in payload["error_message"] + + def test_queue_error_loop_exits_without_webhooks(self, monkeypatch): + monkeypatch.delenv("RUNPOD_WEBHOOK_GET_JOB", raising=False) + monkeypatch.delenv("RUNPOD_WEBHOOK_POST_OUTPUT", raising=False) + with pytest.raises(SystemExit): + bootstrap._queue_error_loop(bootstrap.PhaseError("locate", "x")) + + def test_queue_error_loop_reports_jobs(self, monkeypatch): + import contextlib + import io + + monkeypatch.setenv("RUNPOD_WEBHOOK_GET_JOB", "http://jobs/get") + monkeypatch.setenv("RUNPOD_WEBHOOK_POST_OUTPUT", "http://jobs/out/$ID") + monkeypatch.setenv("RUNPOD_AI_API_KEY", "k") + + posted = [] + + @contextlib.contextmanager + def _get_response(): + response = io.BytesIO(json.dumps({"id": "job1"}).encode()) + response.status = 200 + yield response + + def fake_urlopen(req, timeout=None): + url = req.full_url + if url.endswith("/get"): + return _get_response() + posted.append((url, req.data)) + # first report done: break out of the infinite loop + raise KeyboardInterrupt + + monkeypatch.setattr( + bootstrap.urllib.request, "urlopen", fake_urlopen + ) + error = bootstrap.PhaseError("attach", "manifest gone") + with pytest.raises(KeyboardInterrupt): + bootstrap._queue_error_loop(error) + + assert posted + url, body = posted[0] + assert url.endswith("/out/job1") + assert b"BootstrapError" in body + + def test_report_error_routes_by_kind(self, monkeypatch): + calls = [] + monkeypatch.setattr( + bootstrap, "_api_error_server", lambda e: calls.append("api") + ) + monkeypatch.setattr( + bootstrap, "_queue_error_loop", lambda e: calls.append("queue") + ) + error = bootstrap.PhaseError("locate", "x") + + monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "api") + bootstrap._report_error(error) + monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "queue") + bootstrap._report_error(error) + assert calls == ["api", "queue"] + + +class TestLiveRuntimeInstall: + def test_importable_runtime_no_op(self, monkeypatch): + monkeypatch.setattr( + bootstrap, "_worker_importable", lambda paths: (True, "") + ) + installs = [] + monkeypatch.setattr( + bootstrap, "_pip_install", lambda pkgs, phase: installs.append(pkgs) + ) + bootstrap._ensure_runtime_installed() + assert installs == [] + + def test_installs_package_spec(self, monkeypatch): + probes = iter([(False, "no module"), (True, "")]) + monkeypatch.setattr( + bootstrap, "_worker_importable", lambda paths: next(probes) + ) + installs = [] + monkeypatch.setattr( + bootstrap, "_pip_install", lambda pkgs, phase: installs.append(pkgs) + ) + monkeypatch.setenv("RUNPOD_PACKAGE_SPEC", "runpod==1.99.0") + bootstrap._ensure_runtime_installed() + assert installs == [["runpod==1.99.0", "cloudpickle"]] + + def test_api_kind_adds_uvicorn(self, monkeypatch): + probes = iter([(False, "no module"), (True, "")]) + monkeypatch.setattr( + bootstrap, "_worker_importable", lambda paths: next(probes) + ) + installs = [] + monkeypatch.setattr( + bootstrap, "_pip_install", lambda pkgs, phase: installs.append(pkgs) + ) + monkeypatch.delenv("RUNPOD_PACKAGE_SPEC", raising=False) + monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "api") + bootstrap._ensure_runtime_installed() + assert installs == [["runpod", "cloudpickle", "uvicorn>=0.30"]] + + def test_install_failure_raises_phase(self, monkeypatch): + monkeypatch.setattr( + bootstrap, "_worker_importable", lambda paths: (False, "still broken") + ) + monkeypatch.setattr( + bootstrap, "_pip_install", lambda pkgs, phase: None + ) + with pytest.raises(bootstrap.PhaseError) as exc_info: + bootstrap._ensure_runtime_installed() + assert exc_info.value.phase == "runtime" + + +class TestServe: + def test_serve_execs_worker_module(self, monkeypatch): + execs = [] + monkeypatch.setattr( + bootstrap.os, + "execve", + lambda exe, argv, env: execs.append((exe, argv, env)), + ) + monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "queue") + monkeypatch.setenv("PYTHONPATH", "/existing") + bootstrap._serve(["/env", "/app"]) + exe, argv, env = execs[0] + assert argv[-1] == bootstrap.WORKER_MODULES["queue"] + assert env["PYTHONPATH"].split(":")[:2] == ["/env", "/app"] + assert "/existing" in env["PYTHONPATH"] + assert env["RUNPOD_APP_DIR"] == "/app" + + def test_serve_live_mode_no_paths(self, monkeypatch): + execs = [] + monkeypatch.setattr( + bootstrap.os, + "execve", + lambda exe, argv, env: execs.append((exe, argv, env)), + ) + monkeypatch.delenv("PYTHONPATH", raising=False) + monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "api") + bootstrap._serve([]) + _, argv, env = execs[0] + assert argv[-1] == bootstrap.WORKER_MODULES["api"] + assert "RUNPOD_APP_DIR" not in env + + +class TestMain: + def test_deployed_path_serves(self, tmp_path, monkeypatch): + app_dir = str(tmp_path) + (tmp_path / bootstrap.MANIFEST_NAME).write_text( + json.dumps({"resources": [{"name": "chat"}]}) + ) + monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat") + monkeypatch.setattr(bootstrap, "_locate", lambda: app_dir) + monkeypatch.setattr( + bootstrap, "_worker_importable", lambda paths: (True, "") + ) + served = [] + monkeypatch.setattr(bootstrap, "_serve", lambda paths: served.append(paths)) + bootstrap.main() + assert served and served[0][-1] == app_dir + + def test_phase_error_reports(self, monkeypatch): + monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat") + monkeypatch.setattr( + bootstrap, + "_locate", + lambda: (_ for _ in ()).throw( + bootstrap.PhaseError("locate", "gone") + ), + ) + reported = [] + monkeypatch.setattr( + bootstrap, "_report_error", lambda e: reported.append(e) + ) + bootstrap.main() + assert reported and reported[0].phase == "locate" + + def test_live_path(self, monkeypatch): + monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False) + monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False) + monkeypatch.setattr( + bootstrap, "_ensure_runtime_installed", lambda: None + ) + served = [] + monkeypatch.setattr(bootstrap, "_serve", lambda paths: served.append(paths)) + bootstrap.main() + assert served == [[]] diff --git a/tests/test_apps/test_build.py b/tests/test_apps/test_build.py new file mode 100644 index 00000000..30af8ece --- /dev/null +++ b/tests/test_apps/test_build.py @@ -0,0 +1,224 @@ +"""tests for deploy-time environment vendoring.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from runpod.apps import App +from runpod.apps.app import _clear_registry +from runpod.apps.build import ( + SIZE_PROHIBITIVE_PACKAGES, + BuildError, + collect_requirements, + requirement_name, + runtime_requirement, + split_exclusions, + vendor, +) + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +class TestRequirementName: + @pytest.mark.parametrize( + "spec,name", + [ + ("numpy", "numpy"), + ("numpy==1.26.0", "numpy"), + ("Torch>=2.0", "torch"), + ("scikit_learn~=1.4", "scikit-learn"), + ("pillow[extras]==10", "pillow"), + ], + ) + def test_extracts_distribution_name(self, spec, name): + assert requirement_name(spec) == name + + +class TestCollectRequirements: + def test_merges_requirements_txt_and_decorator_deps(self, tmp_path): + (tmp_path / "requirements.txt").write_text( + "numpy==1.26\n# comment\n\npandas\n" + ) + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2", dependencies=["requests", "numpy"]) + def q(): + pass + + reqs = collect_requirements(tmp_path, app) + # numpy deduped by name, requirements.txt wins on order + assert reqs == ["numpy==1.26", "pandas", "requests"] + + def test_no_requirements_file(self, tmp_path): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + assert collect_requirements(tmp_path, app) == [] + + +class TestSplitExclusions: + def test_torch_family_auto_excluded(self): + kept, excluded = split_exclusions( + ["numpy", "torch==2.4", "torchvision", "requests"] + ) + assert kept == ["numpy", "requests"] + assert excluded == ["torch", "torchvision"] + + def test_user_excludes_added(self): + kept, excluded = split_exclusions( + ["numpy", "transformers"], extra_excludes=["transformers"] + ) + assert kept == ["numpy"] + assert excluded == ["transformers"] + + def test_auto_exclude_off_vendors_everything(self): + # custom images carry no torch guarantee: nothing auto-excluded + kept, excluded = split_exclusions( + ["numpy", "torch==2.4", "triton"], auto_exclude=False + ) + assert kept == ["numpy", "torch==2.4", "triton"] + assert excluded == [] + + def test_auto_exclude_off_user_excludes_still_apply(self): + kept, excluded = split_exclusions( + ["numpy", "torch"], extra_excludes=["torch"], auto_exclude=False + ) + assert kept == ["numpy"] + assert excluded == ["torch"] + + def test_exclusion_set_is_size_based(self): + # sanity-pin the auto set; additions must be size-justified + assert SIZE_PROHIBITIVE_PACKAGES == { + "torch", + "torchvision", + "torchaudio", + "triton", + } + + +class TestRuntimeRequirement: + def test_default_is_published_package(self, tmp_path, monkeypatch): + monkeypatch.delenv("RUNPOD_PACKAGE_SPEC", raising=False) + assert runtime_requirement(tmp_path) == "runpod" + + def test_running_package_overlays_env(self, tmp_path): + from runpod.apps.build import sync_running_package + + env_dir = tmp_path / "env" + # stale copy from the pypi install: must be overwritten + (env_dir / "runpod").mkdir(parents=True) + (env_dir / "runpod" / "stale.py").write_text("old = True") + + sync_running_package(env_dir) + + # the client's own tree (which has the runtimes) wins + assert (env_dir / "runpod" / "runtimes" / "bootstrap.py").is_file() + assert (env_dir / "runpod" / "apps" / "build.py").is_file() + assert not any( + p.name == "__pycache__" + for p in (env_dir / "runpod").rglob("*") + if p.is_dir() + ) + + def test_version_pin_passes_through(self, tmp_path, monkeypatch): + monkeypatch.setenv("RUNPOD_PACKAGE_SPEC", "runpod==1.8.0") + assert runtime_requirement(tmp_path) == "runpod==1.8.0" + + def test_url_spec_builds_wheel(self, tmp_path, monkeypatch): + monkeypatch.setenv( + "RUNPOD_PACKAGE_SPEC", "https://example.com/runpod.tar.gz" + ) + wheel = tmp_path / "wheels" / "runpod-0.0.0.dev0-py3-none-any.whl" + + def fake_run(cmd, **kwargs): + wheel.parent.mkdir(parents=True, exist_ok=True) + wheel.write_bytes(b"") + + class R: + returncode = 0 + stderr = "" + + return R() + + with patch("runpod.apps.build.subprocess.run", side_effect=fake_run): + assert runtime_requirement(tmp_path) == str(wheel) + + +def _fake_popen(captured, *, returncode=0, stdout="", stderr=""): + import io + + def fake(cmd, **kwargs): + captured["cmd"] = cmd + + class P: + def __init__(self): + self.stdout = io.StringIO(stdout) + self.stderr = io.StringIO(stderr) + self.returncode = returncode + + def wait(self): + return returncode + + return P() + + return fake + + +class TestVendor: + def test_targets_worker_platform(self, tmp_path): + captured = {} + with patch( + "runpod.apps.build.subprocess.Popen", + side_effect=_fake_popen(captured), + ): + vendor(tmp_path, ["numpy"], "3.12") + + cmd = captured["cmd"] + assert "--target" in cmd + assert "--python-version" in cmd and "3.12" in cmd + assert "--only-binary" in cmd + assert "manylinux_2_28_x86_64" in cmd + + def test_empty_requirements_no_op(self, tmp_path): + with patch("runpod.apps.build.subprocess.Popen") as popen: + vendor(tmp_path, [], "3.12") + popen.assert_not_called() + + def test_failure_raises_build_error(self, tmp_path): + with patch( + "runpod.apps.build.subprocess.Popen", + side_effect=_fake_popen( + {}, returncode=1, stderr="no matching distribution" + ), + ): + with pytest.raises(BuildError, match="no matching distribution"): + vendor(tmp_path, ["nonexistent-xyz"], "3.12") + + def test_progress_reports_collected_packages(self, tmp_path): + seen = [] + stdout = ( + "Collecting numpy>=1.0\n" + " Downloading numpy-2.0-cp312.whl\n" + "Collecting requests\n" + "Installing collected packages\n" + ) + with patch( + "runpod.apps.build.subprocess.Popen", + side_effect=_fake_popen({}, stdout=stdout), + ): + vendor( + tmp_path, + ["numpy"], + "3.12", + progress=lambda c, n: seen.append((c, n)), + ) + assert seen == [(1, "numpy"), (2, "requests")] diff --git a/tests/test_apps/test_compat_lock.py b/tests/test_apps/test_compat_lock.py new file mode 100644 index 00000000..d810aa5c --- /dev/null +++ b/tests/test_apps/test_compat_lock.py @@ -0,0 +1,105 @@ +"""compatibility locks: the legacy surface must not change. + +these tests intentionally assert exact signatures and behaviors of the +pre-apps sdk. if one fails, the apps work has leaked into the legacy +surface, which is a release blocker. +""" + +import inspect +import unittest +from unittest.mock import patch + +import runpod +from runpod.endpoint.runner import Endpoint, Job, RunPodClient + + +class TestEndpointSignatureLock(unittest.TestCase): + """runpod.Endpoint(id).run_sync() is byte-for-byte the legacy client.""" + + def test_endpoint_is_runner_endpoint(self): + self.assertIs(runpod.Endpoint, Endpoint) + + def test_constructor_signature(self): + params = list(inspect.signature(Endpoint.__init__).parameters) + self.assertEqual(params, ["self", "endpoint_id", "api_key"]) + + def test_run_sync_signature(self): + params = list(inspect.signature(Endpoint.run_sync).parameters) + self.assertEqual(params, ["self", "request_input", "timeout"]) + default = inspect.signature(Endpoint.run_sync).parameters["timeout"].default + self.assertEqual(default, 86400) + + def test_run_signature(self): + params = list(inspect.signature(Endpoint.run).parameters) + self.assertEqual(params, ["self", "request_input"]) + + def test_endpoint_methods_present(self): + for method in ("run", "run_sync", "health", "purge_queue"): + self.assertTrue(callable(getattr(Endpoint, method))) + + def test_job_methods_present(self): + for method in ("status", "output", "stream", "cancel"): + self.assertTrue(callable(getattr(Job, method))) + + @patch.object(RunPodClient, "post") + @patch("runpod.api_key", "test-key") + def test_run_sync_behavior(self, mock_post): + mock_post.return_value = { + "id": "job-1", + "status": "COMPLETED", + "output": {"result": 42}, + } + endpoint = Endpoint("ep-abc", api_key="test-key") + result = endpoint.run_sync({"prompt": "hi"}) + self.assertEqual(result, {"result": 42}) + mock_post.assert_called_once_with( + "ep-abc/runsync", {"input": {"prompt": "hi"}}, timeout=86400 + ) + + @patch.object(RunPodClient, "post") + @patch("runpod.api_key", "test-key") + def test_run_returns_job(self, mock_post): + mock_post.return_value = {"id": "job-2"} + endpoint = Endpoint("ep-abc", api_key="test-key") + job = endpoint.run({"prompt": "hi"}) + self.assertIsInstance(job, Job) + self.assertEqual(job.job_id, "job-2") + + +class TestLegacyFlatVerbsPresent(unittest.TestCase): + """the flat api verbs stay importable from the package root.""" + + def test_flat_verbs(self): + for verb in ( + "create_pod", + "get_pods", + "get_pod", + "stop_pod", + "resume_pod", + "terminate_pod", + "create_endpoint", + "get_endpoints", + "create_template", + "get_gpus", + "get_gpu", + "get_user", + "update_user_settings", + "update_endpoint_template", + "create_container_registry_auth", + "update_container_registry_auth", + "delete_container_registry_auth", + ): + self.assertTrue( + callable(getattr(runpod, verb)), f"runpod.{verb} missing" + ) + + def test_asyncio_endpoint_present(self): + self.assertTrue(hasattr(runpod, "AsyncioEndpoint")) + self.assertTrue(hasattr(runpod, "AsyncioJob")) + + def test_serverless_start_present(self): + self.assertTrue(callable(runpod.serverless.start)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_apps/test_config_params.py b/tests/test_apps/test_config_params.py new file mode 100644 index 00000000..214a40c6 --- /dev/null +++ b/tests/test_apps/test_config_params.py @@ -0,0 +1,364 @@ +"""endpoint configuration parameters across spec, dev, and deploy payloads.""" + +import pytest + +import runpod +from runpod.apps import App +from runpod.apps.app import _clear_registry +from runpod.apps.dev import _endpoint_input +from runpod.apps.errors import InvalidResourceError +from runpod.apps.spec import ( + ResourceKind, + ResourceSpec, + normalize_cuda_version, + normalize_scaler_type, +) + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +class TestNormalizers: + def test_scaler_type_uppercases(self): + assert normalize_scaler_type("queue_delay") == "QUEUE_DELAY" + assert normalize_scaler_type("REQUEST_COUNT") == "REQUEST_COUNT" + + def test_scaler_type_none_passes(self): + assert normalize_scaler_type(None) is None + + def test_scaler_type_rejects_unknown(self): + with pytest.raises(InvalidResourceError): + normalize_scaler_type("SOMETHING") + + def test_cuda_version_valid(self): + assert normalize_cuda_version("12.8") == "12.8" + + def test_cuda_version_none_passes(self): + assert normalize_cuda_version(None) is None + + def test_cuda_version_rejects_unknown(self): + with pytest.raises(InvalidResourceError): + normalize_cuda_version("10.0") + + +class TestSpecValidation: + def test_max_concurrency_floor(self): + with pytest.raises(InvalidResourceError): + ResourceSpec( + kind=ResourceKind.QUEUE, name="q", max_concurrency=0 + ) + + def test_execution_timeout_floor(self): + with pytest.raises(InvalidResourceError): + ResourceSpec( + kind=ResourceKind.QUEUE, name="q", execution_timeout_ms=-1 + ) + + def test_scaler_value_floor(self): + with pytest.raises(InvalidResourceError): + ResourceSpec(kind=ResourceKind.QUEUE, name="q", scaler_value=0) + + def test_container_disk_floor(self): + with pytest.raises(InvalidResourceError): + ResourceSpec( + kind=ResourceKind.QUEUE, name="q", container_disk_gb=0 + ) + + def test_min_cuda_rejected_on_cpu(self): + with pytest.raises(InvalidResourceError): + ResourceSpec( + kind=ResourceKind.QUEUE, + name="q", + cpu=["cpu3c-1-2"], + min_cuda_version="12.8", + ) + + def test_effective_scaler_type_defaults_by_kind(self): + queue = ResourceSpec(kind=ResourceKind.QUEUE, name="q") + api = ResourceSpec(kind=ResourceKind.API, name="api") + assert queue.effective_scaler_type == "QUEUE_DELAY" + assert api.effective_scaler_type == "REQUEST_COUNT" + + def test_effective_scaler_type_explicit_wins(self): + spec = ResourceSpec( + kind=ResourceKind.QUEUE, name="q", scaler_type="REQUEST_COUNT" + ) + assert spec.effective_scaler_type == "REQUEST_COUNT" + + +class TestDecoratorPlumbing: + def test_queue_accepts_all_params(self): + app = App("a") + + @app.queue( + name="q", + gpu="4090", + max_concurrency=4, + execution_timeout_ms=60000, + flashboot=False, + scaler_type="request_count", + scaler_value=8, + min_cuda_version="12.4", + accelerate_downloads=False, + container_disk_gb=50, + ) + def q(): + pass + + spec = q.spec + assert spec.max_concurrency == 4 + assert spec.execution_timeout_ms == 60000 + assert spec.flashboot is False + assert spec.scaler_type == "REQUEST_COUNT" + assert spec.scaler_value == 8 + assert spec.min_cuda_version == "12.4" + assert spec.accelerate_downloads is False + assert spec.container_disk_gb == 50 + + def test_api_accepts_params(self): + app = App("a") + + @app.api( + name="api", + cpu="cpu3c-1-2", + execution_timeout_ms=30000, + scaler_value=2, + container_disk_gb=20, + ) + class Api: + @runpod.post("/x") + def x(self, body: dict): + return body + + assert Api.spec.execution_timeout_ms == 30000 + assert Api.spec.scaler_value == 2 + assert Api.spec.container_disk_gb == 20 + + def test_task_accepts_params(self): + app = App("a") + + @app.task( + name="t", + gpu="4090", + min_cuda_version="12.8", + accelerate_downloads=False, + container_disk_gb=100, + ) + def t(): + pass + + assert t.spec.min_cuda_version == "12.8" + assert t.spec.accelerate_downloads is False + assert t.spec.container_disk_gb == 100 + + def test_queue_invalid_scaler_fails_at_decoration(self): + app = App("a") + + with pytest.raises(InvalidResourceError): + + @app.queue(name="q", scaler_type="nope") + def q(): + pass + + +class TestManifestSerialization: + def test_non_defaults_serialized(self): + spec = ResourceSpec( + kind=ResourceKind.QUEUE, + name="q", + max_concurrency=4, + execution_timeout_ms=1000, + flashboot=False, + scaler_type="REQUEST_COUNT", + scaler_value=8, + min_cuda_version="12.8", + accelerate_downloads=False, + container_disk_gb=42, + ) + data = spec.to_manifest() + assert data["maxConcurrency"] == 4 + assert data["executionTimeoutMs"] == 1000 + assert data["flashboot"] is False + assert data["scalerType"] == "REQUEST_COUNT" + assert data["scalerValue"] == 8 + assert data["minCudaVersion"] == "12.8" + assert data["accelerateDownloads"] is False + assert data["containerDiskGb"] == 42 + + def test_defaults_omitted(self): + spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q") + data = spec.to_manifest() + for key in ( + "maxConcurrency", + "executionTimeoutMs", + "flashboot", + "scalerType", + "scalerValue", + "minCudaVersion", + "accelerateDownloads", + "containerDiskGb", + ): + assert key not in data + + +class TestDevPayload: + def test_config_params_reach_payload(self): + app = App("a") + + @app.queue( + name="q", + gpu="4090", + max_concurrency=3, + execution_timeout_ms=90000, + scaler_type="request_count", + scaler_value=7, + min_cuda_version="12.6", + container_disk_gb=40, + ) + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert payload["scalerType"] == "REQUEST_COUNT" + assert payload["scalerValue"] == 7 + assert payload["executionTimeoutMs"] == 90000 + assert payload["minCudaVersion"] == "12.6" + assert payload["template"]["containerDiskInGb"] == 40 + env = {e["key"]: e["value"] for e in payload["template"]["env"]} + assert env["RUNPOD_MAX_CONCURRENCY"] == "3" + + def test_flashboot_disabled_omits_flag(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2", flashboot=False) + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert "flashBootType" not in payload + + def test_defaults_unchanged(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert payload["scalerType"] == "QUEUE_DELAY" + assert payload["scalerValue"] == 4 + assert payload["executionTimeoutMs"] == 0 + assert payload["flashBootType"] == "FLASHBOOT" + assert payload["template"]["containerDiskInGb"] == 10 + env = {e["key"]: e["value"] for e in payload["template"]["env"]} + assert "RUNPOD_MAX_CONCURRENCY" not in env + + +class TestDeployPayload: + def test_config_params_reach_payload(self): + from runpod.apps.deploy import _deployed_endpoint_input + + app = App("dep") + + @app.queue( + name="que", + gpu="4090", + max_concurrency=2, + execution_timeout_ms=5000, + scaler_value=9, + min_cuda_version="12.8", + container_disk_gb=64, + ) + def que(): + pass + + payload = _deployed_endpoint_input(app, que.spec, "env-1", "b-1", "3.12") + assert payload["scalerValue"] == 9 + assert payload["executionTimeoutMs"] == 5000 + assert payload["minCudaVersion"] == "12.8" + assert payload["template"]["containerDiskInGb"] == 64 + env = {e["key"]: e["value"] for e in payload["template"]["env"]} + assert env["RUNPOD_MAX_CONCURRENCY"] == "2" + + def test_flashboot_default_present(self): + from runpod.apps.deploy import _deployed_endpoint_input + + app = App("dep") + + @app.queue(name="que", cpu="cpu3c-1-2") + def que(): + pass + + payload = _deployed_endpoint_input(app, que.spec, "env-1", "b-1", "3.12") + assert payload["flashBootType"] == "FLASHBOOT" + + def test_flashboot_disabled_omitted(self): + from runpod.apps.deploy import _deployed_endpoint_input + + app = App("dep") + + @app.queue(name="que", cpu="cpu3c-1-2", flashboot=False) + def que(): + pass + + payload = _deployed_endpoint_input(app, que.spec, "env-1", "b-1", "3.12") + assert "flashBootType" not in payload + + +class TestTaskPayload: + def test_min_cuda_expands_to_allowed_versions(self): + from runpod.apps.tasks import _pod_input, cuda_versions_at_least + + app = App("a") + + @app.task(name="t", gpu="4090", min_cuda_version="12.8") + def t(): + pass + + pod = _pod_input(t.spec, "tok", "t") + assert pod["allowedCudaVersions"] == ["12.8", "12.9", "13.0"] + assert cuda_versions_at_least("12.9") == ["12.9", "13.0"] + + def test_container_disk_override(self): + from runpod.apps.tasks import _pod_input + + app = App("a") + + @app.task(name="t", cpu="cpu3c-1-2", container_disk_gb=25) + def t(): + pass + + pod = _pod_input(t.spec, "tok", "t") + assert pod["containerDiskInGb"] == 25 + + +class TestAccelerateDownloads: + def test_flag_travels_in_function_request(self): + from runpod.apps.targets import LiveTarget + + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2", accelerate_downloads=False) + def q(x: int): + return x + + target = LiveTarget("ep-1", "q") + payload = target.build_payload(q._fn, q.spec, (1,), {}) + assert payload["input"]["accelerate_downloads"] is False + + def test_default_true(self): + from runpod.apps.targets import LiveTarget + + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(x: int): + return x + + target = LiveTarget("ep-1", "q") + payload = target.build_payload(q._fn, q.spec, (1,), {}) + assert payload["input"]["accelerate_downloads"] is True diff --git a/tests/test_apps/test_deploy.py b/tests/test_apps/test_deploy.py new file mode 100644 index 00000000..f8223061 --- /dev/null +++ b/tests/test_apps/test_deploy.py @@ -0,0 +1,257 @@ +"""tests for discovery, manifest building, and packaging.""" + +import io +import json +import tarfile +import textwrap +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +import runpod +from runpod.apps import App +from runpod.apps.app import _clear_registry +from runpod.apps.deploy import ( + DeployResult, + build_manifest, + deploy_app, + package_project, +) +from runpod.apps.discovery import DiscoveryError, discover_apps +from runpod.apps.errors import ScheduleNotSupported + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +def _write_project(tmp_path: Path) -> Path: + (tmp_path / "main.py").write_text( + textwrap.dedent( + """ + import runpod + from runpod import App + + app = App("demo-app") + + @app.queue(name="que1", cpu="cpu5c-2-4") + def que1(x: int): + return x * 2 + + if __name__ == "__main__": + raise SystemExit("main guard must not run during discovery") + """ + ) + ) + return tmp_path + + +class TestDiscovery: + def test_discovers_app(self, tmp_path): + _write_project(tmp_path) + apps = discover_apps(tmp_path) + assert len(apps) == 1 + assert apps[0].name == "demo-app" + assert "que1" in apps[0].resources + + def test_single_file_target(self, tmp_path): + _write_project(tmp_path) + apps = discover_apps(tmp_path / "main.py") + assert len(apps) == 1 + + def test_main_guard_not_executed(self, tmp_path): + _write_project(tmp_path) + # would raise SystemExit if __main__ ran + discover_apps(tmp_path) + + def test_import_error_reported(self, tmp_path): + (tmp_path / "broken.py").write_text("import nonexistent_module_xyz") + with pytest.raises(DiscoveryError, match="broken.py"): + discover_apps(tmp_path) + + def test_skips_venv_dirs(self, tmp_path): + _write_project(tmp_path) + venv = tmp_path / ".venv" / "lib" + venv.mkdir(parents=True) + (venv / "bad.py").write_text("import nonexistent_module_xyz") + apps = discover_apps(tmp_path) + assert len(apps) == 1 + + def test_non_python_file_rejected(self, tmp_path): + f = tmp_path / "notes.txt" + f.write_text("hi") + with pytest.raises(DiscoveryError): + discover_apps(f) + + +class TestManifest: + def test_manifest_shape(self, tmp_path): + app = App("m-app") + + @app.queue(name="qee", cpu="cpu5c-2-4", dependencies=["numpy"]) + def qee(x): + return x + + manifest = build_manifest(app, tmp_path) + assert manifest["app"] == "m-app" + assert manifest["version"] == 1 + (resource,) = manifest["resources"] + assert resource["kind"] == "queue" + assert resource["name"] == "qee" + assert resource["dependencies"] == ["numpy"] + assert resource["qualname"] + + def test_schedule_blocked_until_backend_support(self, tmp_path): + app = App("s-app") + + @app.task(name="t") + @runpod.schedule(cron="0 * * * *") + def t(): + pass + + with pytest.raises(ScheduleNotSupported): + build_manifest(app, tmp_path) + + +class TestPackaging: + def test_tarball_contains_source_and_manifest(self, tmp_path): + _write_project(tmp_path) + manifest = {"version": 1, "app": "demo-app", "resources": []} + tar_path = package_project(tmp_path, manifest) + + with tarfile.open(tar_path) as tar: + names = tar.getnames() + assert "main.py" in names + assert "runpod_manifest.json" in names + extracted = json.load(tar.extractfile("runpod_manifest.json")) + assert extracted["app"] == "demo-app" + + def test_ignores_applied(self, tmp_path): + _write_project(tmp_path) + (tmp_path / "secret.env").write_text("KEY=1") + (tmp_path / ".runpodignore").write_text("secret.env\n") + pycache = tmp_path / "__pycache__" + pycache.mkdir() + (pycache / "x.pyc").write_text("junk") + + tar_path = package_project(tmp_path, {"version": 1, "resources": []}) + with tarfile.open(tar_path) as tar: + names = tar.getnames() + assert "secret.env" not in names + assert not any("__pycache__" in n for n in names) + + def test_vendored_env_included_under_env(self, tmp_path): + _write_project(tmp_path) + env_dir = tmp_path / "built-env" + (env_dir / "numpy").mkdir(parents=True) + (env_dir / "numpy" / "__init__.py").write_text("") + + tar_path = package_project( + tmp_path, {"version": 1, "resources": []}, env_dir=env_dir + ) + with tarfile.open(tar_path) as tar: + names = tar.getnames() + assert "env/numpy/__init__.py" in names + # env dir under project root must not be double-added as source + assert "built-env/numpy/__init__.py" not in names + assert "main.py" in names + + +def _stub_build(tmp_path): + """patch environment vendoring with a tiny fake env tree.""" + from runpod.apps.build import BuildResult + + env_dir = tmp_path / "fake-env" + env_dir.mkdir(exist_ok=True) + (env_dir / "vendored_pkg.py").write_text("x = 1") + return patch( + "runpod.apps.deploy.build_environment", + return_value=BuildResult(env_dir=env_dir, requirements=["runpod"]), + ) + + +class TestDeployPipeline: + async def test_deploy_app_full_flow(self, tmp_path): + _write_project(tmp_path) + (app,) = discover_apps(tmp_path) + + api = AsyncMock() + api.get_app_by_name.return_value = None + api.create_app.return_value = {"id": "app-1", "flashEnvironments": []} + api.create_environment.return_value = {"id": "env-1", "name": "default"} + api.prepare_artifact_upload.return_value = { + "uploadUrl": "https://upload", + "objectKey": "key-1", + } + api.finalize_artifact_upload.return_value = {"id": "build-1"} + api.deploy_build.return_value = {"id": "env-1"} + + with _stub_build(tmp_path): + result = await deploy_app(app, tmp_path, api=api) + + assert isinstance(result, DeployResult) + assert result.build_id == "build-1" + assert result.resources == ["que1"] + api.upload_tarball.assert_awaited_once() + api.deploy_build.assert_awaited_once_with("env-1", "build-1") + + async def test_deploy_reuses_existing_app_and_env(self, tmp_path): + _write_project(tmp_path) + (app,) = discover_apps(tmp_path) + + api = AsyncMock() + api.get_app_by_name.return_value = { + "id": "app-1", + "flashEnvironments": [{"id": "env-1", "name": "default"}], + } + api.prepare_artifact_upload.return_value = { + "uploadUrl": "https://upload", + "objectKey": "key-1", + } + api.finalize_artifact_upload.return_value = {"id": "build-2"} + + with _stub_build(tmp_path): + await deploy_app(app, tmp_path, api=api) + + api.create_app.assert_not_awaited() + api.create_environment.assert_not_awaited() + + +class TestTolerantDiscovery: + def test_broken_bystander_warns_but_discovers(self, tmp_path): + _write_project(tmp_path) + (tmp_path / "scratch.py").write_text("this is not python !!!") + apps = discover_apps(tmp_path) + assert len(apps) == 1 + + def test_single_file_target_stays_strict(self, tmp_path): + broken = tmp_path / "broken.py" + broken.write_text("import nonexistent_module_xyz") + with pytest.raises(DiscoveryError, match="broken.py"): + discover_apps(broken) + + def test_no_apps_and_failures_raises_with_causes(self, tmp_path): + (tmp_path / "broken.py").write_text("import nonexistent_module_xyz") + with pytest.raises(DiscoveryError, match="broken.py"): + discover_apps(tmp_path) + + def test_import_time_invocation_diagnosed(self, tmp_path): + _write_project(tmp_path) + (tmp_path / "client.py").write_text( + "from main import que1\nque1.remote(1)\n" + ) + # directory walk: the client file fails with the precise + # diagnosis but the app still discovers + apps = discover_apps(tmp_path) + assert len(apps) == 1 + + def test_import_time_invocation_message(self, tmp_path): + _write_project(tmp_path) + client = tmp_path / "client.py" + client.write_text("from main import que1\nque1.remote(1)\n") + with pytest.raises(DiscoveryError, match="invoked at import time"): + discover_apps(client) diff --git a/tests/test_apps/test_dev.py b/tests/test_apps/test_dev.py new file mode 100644 index 00000000..4ad35661 --- /dev/null +++ b/tests/test_apps/test_dev.py @@ -0,0 +1,470 @@ +"""tests for dev session provisioning.""" + +from unittest.mock import AsyncMock + +import pytest + +import runpod +from runpod.apps import App +from runpod.apps.app import _clear_registry +from runpod.apps.dev import DevSession, _endpoint_input, dev_endpoint_name +from runpod.apps.targets import LiveTarget + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +def _mock_api(): + api = AsyncMock() + api.list_my_endpoints.return_value = [] + api.save_endpoint.return_value = {"id": "ep-new"} + api.delete_endpoint.return_value = True + return api + + +class TestEndpointInput: + def test_cpu_queue_payload(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2", workers=(0, 1)) + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert payload["name"].startswith("dev-a-q-") + assert payload["instanceIds"] == ["cpu3c-1-2"] + assert "EU-RO-1" in payload["locations"] + assert "US-KS-2" in payload["locations"] + assert payload["scalerType"] == "QUEUE_DELAY" + assert payload["flashBootType"] == "FLASHBOOT" + + def test_cpu5_pins_to_stocked_datacenters(self): + app = App("a") + + @app.queue(name="q", cpu="cpu5c-2-4") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert payload["locations"] == "EU-RO-1" + + def test_cpu_locations_are_storage_supported(self): + from runpod.apps.datacenter import DataCenter + + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + valid = {dc.value for dc in DataCenter} + for loc in payload["locations"].split(","): + assert loc in valid + template = payload["template"] + assert template["dockerArgs"] == "" + env = {e["key"]: e["value"] for e in template["env"]} + assert env["RUNPOD_DEV_GENERATION"] == "1" + # nested .remote() support: dev-session marker always present + assert env["RUNPOD_DEV_APP"] == app.name + + def test_api_key_forwarded_when_configured(self, monkeypatch): + monkeypatch.setenv("RUNPOD_API_KEY", "rpa_test123") + app = App("keyed") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + env = {e["key"]: e["value"] for e in payload["template"]["env"]} + assert env["RUNPOD_API_KEY"] == "rpa_test123" + assert "gpuIds" not in payload + + def test_gpu_queue_payload(self): + app = App("a") + + @app.queue(name="q", gpu=runpod.GpuGroup.ADA_24) + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert payload["gpuIds"] == "ADA_24" + assert payload["gpuCount"] == 1 + assert "instanceIds" not in payload + assert "locations" not in payload + + def test_api_payload_is_lb(self): + app = App("a") + + @app.api(name="api", cpu="cpu3c-1-2") + class Api: + @runpod.post("/x") + def x(self, body: dict): + return body + + payload = _endpoint_input(app, Api.spec) + assert payload["type"] == "LB" + assert payload["scalerType"] == "REQUEST_COUNT" + + def test_datacenter_pins_locations(self): + app = App("a") + + @app.queue(name="q", gpu=runpod.GpuGroup.ADA_24, datacenter="US-KS-2") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert payload["locations"] == "US-KS-2" + + def test_env_forwarded_to_template(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2", env={"K": "v"}) + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert {"key": "K", "value": "v"} in payload["template"]["env"] + + def test_generation_stamped_in_template_env(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + payload = _endpoint_input(app, q.spec, generation=7) + assert {"key": "RUNPOD_DEV_GENERATION", "value": "7"} in payload[ + "template" + ]["env"] + + +class TestDevSession: + async def test_start_provisions_and_registers_targets(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + api = _mock_api() + session = DevSession([app], api=api) + await session.start() + + api.save_endpoint.assert_awaited_once() + target = app._dev_targets["q"] + assert isinstance(target, LiveTarget) + assert target.endpoint_id == "ep-new" + + async def test_start_adopts_and_reconciles_existing_endpoint(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + api = _mock_api() + api.list_my_endpoints.return_value = [ + {"id": "ep-old", "name": dev_endpoint_name("a", "q")} + ] + api.save_endpoint.return_value = {"id": "ep-old"} + session = DevSession([app], api=api) + await session.start() + + # adopted endpoints are reconciled via saveEndpoint with the id set + payload = api.save_endpoint.await_args.args[0] + assert payload["id"] == "ep-old" + assert app._dev_targets["q"].endpoint_id == "ep-old" + + async def test_stop_deletes_all_session_endpoints(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + api = _mock_api() + session = DevSession([app], api=api) + await session.start() + await session.stop() + + api.delete_endpoint.assert_awaited_once_with("ep-new") + assert app._dev_targets == {} + + async def test_tasks_not_provisioned(self): + app = App("a") + + @app.task(name="t", cpu="cpu3c-1-2") + def t(): + pass + + api = _mock_api() + session = DevSession([app], api=api) + await session.start() + + api.save_endpoint.assert_not_awaited() + + +class TestDevRefresh: + async def test_refresh_bumps_generation_and_updates(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + api = _mock_api() + api.save_endpoint.return_value = {"id": "ep-1"} + session = DevSession([app], api=api) + await session.start() + + await session.refresh([app]) + + payload = api.save_endpoint.await_args.args[0] + assert payload["id"] == "ep-1" + assert {"key": "RUNPOD_DEV_GENERATION", "value": "2"} in payload[ + "template" + ]["env"] + assert session.generation == 2 + + async def test_refresh_provisions_added_resource(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + api = _mock_api() + api.save_endpoint.return_value = {"id": "ep-1"} + session = DevSession([app], api=api) + await session.start() + + _clear_registry() + app2 = App("a") + + @app2.queue(name="q", cpu="cpu3c-1-2") + def q2(): + pass + + @app2.queue(name="extra", cpu="cpu3c-1-2") + def extra(): + pass + + api.save_endpoint.return_value = {"id": "ep-2"} + await session.refresh([app2]) + + names = { + c.args[0]["name"] for c in api.save_endpoint.await_args_list + } + assert dev_endpoint_name("a", "extra") in names + assert "extra" in app2._dev_targets + + async def test_refresh_deletes_removed_resource(self): + app = App("a") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + @app.queue(name="gone", cpu="cpu3c-1-2") + def gone(): + pass + + api = _mock_api() + api.save_endpoint.side_effect = [{"id": "ep-q"}, {"id": "ep-gone"}] + session = DevSession([app], api=api) + await session.start() + + _clear_registry() + app2 = App("a") + + @app2.queue(name="q", cpu="cpu3c-1-2") + def q2(): + pass + + api.save_endpoint.side_effect = None + api.save_endpoint.return_value = {"id": "ep-q"} + await session.refresh([app2]) + + api.delete_endpoint.assert_awaited_once_with("ep-gone") + + +class TestDevEvents: + async def test_lifecycle_events_emitted(self): + events = [] + + class Sink: + def provisioning(self, name, kind, hardware): + events.append(("provisioning", name, kind, hardware)) + + def adopted(self, name, endpoint_id): + events.append(("adopted", name, endpoint_id)) + + def ready(self, name, endpoint_id): + events.append(("ready", name, endpoint_id)) + + def resource_changed(self, name, fields): + events.append(("resource_changed", name, fields)) + + def resource_added(self, name, kind, hardware): + events.append(("resource_added", name, kind, hardware)) + + def resource_removed(self, name): + events.append(("resource_removed", name)) + + def deleted(self, name): + events.append(("deleted", name)) + + app = App("ev-app") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + api = AsyncMock() + api.list_my_endpoints.return_value = [] + api.save_endpoint.return_value = {"id": "ep-1"} + api.delete_endpoint.return_value = True + + session = DevSession([app], api=api, events=Sink()) + await session.start() + await session.refresh([app]) + await session.stop() + + kinds = [e[0] for e in events] + assert "provisioning" in kinds + assert "ready" in kinds + assert "deleted" in kinds + # unchanged resource refreshes silently: no diff events + assert "resource_changed" not in kinds + assert "resource_added" not in kinds + assert ("provisioning", "q", "queue", "cpu3c-1-2") in events + # deleted reports the resource name, not the endpoint name + assert ("deleted", "q") in events + + async def test_refresh_diff_events(self): + events = [] + + class Sink: + def resource_added(self, name, kind, hardware): + events.append(("added", name, kind, hardware)) + + def resource_changed(self, name, fields): + events.append(("changed", name, tuple(fields))) + + def resource_removed(self, name): + events.append(("removed", name)) + + app = App("diff-app") + + @app.queue(name="stays", cpu="cpu3c-1-2") + def stays(): + pass + + @app.queue(name="goes", cpu="cpu3c-1-2") + def goes(): + pass + + api = AsyncMock() + api.list_my_endpoints.return_value = [] + api.save_endpoint.side_effect = lambda p: {"id": f"ep-{p['name']}"} + api.delete_endpoint.return_value = True + + session = DevSession([app], api=api, events=Sink()) + await session.start() + + # rescan: 'goes' removed, 'stays' reconfigured, 'fresh' added + app2 = App("diff-app") + + @app2.queue(name="stays", cpu="cpu3c-1-2", workers=(1, 4)) + def stays2(): + pass + + @app2.queue(name="fresh", cpu="cpu5c-2-4") + def fresh(): + pass + + await session.refresh([app2]) + + assert ("removed", "goes") in events + added = [e for e in events if e[0] == "added"] + assert ("added", "fresh", "queue", "cpu5c-2-4") in added + changed = [e for e in events if e[0] == "changed"] + assert len(changed) == 1 + assert changed[0][1] == "stays" + assert "workersMin" in changed[0][2] or "workersMax" in changed[0][2] + + async def test_missing_event_methods_ignored(self): + app = App("ev-app2") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + api = AsyncMock() + api.list_my_endpoints.return_value = [] + api.save_endpoint.return_value = {"id": "ep-1"} + + # events object with no handlers at all must not break anything + session = DevSession([app], api=api, events=object()) + await session.start() + await session.stop() + + +class TestGpuIdsPayload: + def test_no_gpu_selection_expands_to_all_pools(self): + # the api rejects "any"; an unconstrained gpu queue asks for + # every pool id instead + app = App("gpu-any") + + @app.queue(name="q") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + gpu_ids = payload["gpuIds"].split(",") + assert "any" not in gpu_ids + assert "ADA_24" in gpu_ids and "AMPERE_80" in gpu_ids + + def test_explicit_pools_pass_through(self): + app = App("gpu-explicit") + + @app.queue(name="q", gpu="ADA_24") + def q(): + pass + + payload = _endpoint_input(app, q.spec) + assert payload["gpuIds"] == "ADA_24" + + +class TestRefreshKeepsTaskEvents: + async def test_dev_events_reattached_after_refresh(self): + sink = object() + app = App("evt-app") + + @app.queue(name="q", cpu="cpu3c-1-2") + def q(): + pass + + api = AsyncMock() + api.list_my_endpoints.return_value = [] + api.save_endpoint.return_value = {"id": "ep-1"} + + session = DevSession([app], api=api, events=sink) + await session.start() + assert app._dev_events is sink + + # file change: discovery builds a brand-new app instance + app2 = App("evt-app") + + @app2.queue(name="q", cpu="cpu3c-1-2") + def q2(): + pass + + await session.refresh([app2]) + assert app2._dev_events is sink diff --git a/tests/test_apps/test_dispatch.py b/tests/test_apps/test_dispatch.py new file mode 100644 index 00000000..a6dd0388 --- /dev/null +++ b/tests/test_apps/test_dispatch.py @@ -0,0 +1,520 @@ +"""tests for context detection and remote dispatch.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +import runpod +from runpod.apps import App, Context, current_context, is_local +from runpod.apps.app import _clear_registry +from runpod.apps.errors import ( + EndpointNotFound, + InvalidResourceError, + RemoteExecutionError, +) +from runpod.apps.targets import ( + SentinelTarget, + args_to_input, + unwrap_job_output, +) + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +class TestContextDetection: + def test_local_by_default(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + assert current_context() is Context.LOCAL + assert is_local() is True + + def test_worker_via_endpoint_id(self, monkeypatch): + monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1") + assert current_context() is Context.WORKER + assert is_local() is False + + def test_worker_via_pod_id(self, monkeypatch): + monkeypatch.delenv("RUNPOD_ENDPOINT_ID", raising=False) + monkeypatch.setenv("RUNPOD_POD_ID", "pod-1") + assert current_context() is Context.WORKER + + def test_dev_session(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("RUNPOD_DEV_SESSION", "1") + assert current_context() is Context.DEV + assert is_local() is True + + +class TestArgsToInput: + def test_positional_mapped_to_names(self): + def fn(prompt, temp): + pass + + assert args_to_input(fn, ("hi", 0.5), {}) == {"prompt": "hi", "temp": 0.5} + + def test_kwargs_merged(self): + def fn(a, b): + pass + + assert args_to_input(fn, (1,), {"b": 2}) == {"a": 1, "b": 2} + + def test_empty_gets_placeholder(self): + def fn(): + pass + + assert args_to_input(fn, (), {}) == {"__empty": True} + + def test_too_many_positional(self): + def fn(a): + pass + + with pytest.raises(TypeError): + args_to_input(fn, (1, 2), {}) + + +class TestUnwrapJobOutput: + def test_output_extracted(self): + assert unwrap_job_output({"status": "COMPLETED", "output": {"x": 1}}) == { + "x": 1 + } + + def test_failed_raises(self): + with pytest.raises(RemoteExecutionError): + unwrap_job_output({"status": "FAILED", "error": "boom"}) + + def test_error_in_output_raises(self): + with pytest.raises(RemoteExecutionError): + unwrap_job_output({"status": "COMPLETED", "output": {"error": "bad"}}) + + +class TestRemoteDispatch: + def test_remote_sync_via_sentinel(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="q") + def q(x): + return x + + with patch.object( + SentinelTarget, "invoke", new_callable=AsyncMock + ) as mock_invoke: + mock_invoke.return_value = {"doubled": 4} + result = q.remote(2) + + assert result == {"doubled": 4} + mock_invoke.assert_awaited_once_with({"input": {"x": 2}}) + + async def test_remote_aio(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="q") + async def q(x): + return x + + with patch.object( + SentinelTarget, "invoke", new_callable=AsyncMock + ) as mock_invoke: + mock_invoke.return_value = {"ok": True} + result = await q.remote.aio(1) + + assert result == {"ok": True} + + def test_remote_with_options_merges_payload(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="q") + def q(x): + return x + + with patch.object( + SentinelTarget, "invoke", new_callable=AsyncMock + ) as mock_invoke: + mock_invoke.return_value = {"ok": 1} + q.with_options( + webhook="https://hook", execution_timeout=5000, low_priority=True + ).remote(2) + + mock_invoke.assert_awaited_once_with( + { + "input": {"x": 2}, + "webhook": "https://hook", + "policy": {"executionTimeout": 5000, "lowPriority": True}, + } + ) + + def test_spawn_with_options_merges_payload(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="q") + def q(x): + return x + + with patch.object( + SentinelTarget, "submit", new_callable=AsyncMock + ) as mock_submit: + mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"} + q.with_options(s3_config={"bucketName": "b"}).spawn(1) + + mock_submit.assert_awaited_once_with( + {"input": {"x": 1}, "s3Config": {"bucketName": "b"}} + ) + + def test_with_options_returns_new_handle(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="q") + def q(x): + return x + + bound = q.with_options(webhook="https://hook") + assert bound is not q + assert q._job_options == {} + # policy fields deep-merge across chained calls + chained = q.with_options(execution_timeout=1).with_options(ttl=2) + assert chained._job_options == {"policy": {"executionTimeout": 1, "ttl": 2}} + + def test_with_options_rejects_tasks(self): + app = App("my-app") + + @app.task(name="t") + def t(x): + return x + + with pytest.raises(InvalidResourceError, match="only to @app.queue"): + t.with_options(webhook="https://hook") + + def test_worker_executes_own_body(self, monkeypatch): + monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1") + monkeypatch.setenv("FLASH_RESOURCE_NAME", "q") + app = App("my-app") + + @app.queue(name="q") + def q(x): + return x * 10 + + assert q.remote(4) == 40 + + def test_worker_calls_sibling_via_sentinel(self, monkeypatch): + monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1") + monkeypatch.setenv("FLASH_RESOURCE_NAME", "other") + app = App("my-app") + + @app.queue(name="q") + def q(x): + return x + + with patch.object( + SentinelTarget, "invoke", new_callable=AsyncMock + ) as mock_invoke: + mock_invoke.return_value = "remote-result" + result = q.remote(1) + + assert result == "remote-result" + + def test_dev_without_provisioned_target_raises(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("RUNPOD_DEV_SESSION", "1") + app = App("my-app") + + @app.queue(name="q") + def q(): + pass + + with pytest.raises(EndpointNotFound): + q.remote() + + def test_spawn_returns_job(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="q") + def q(x): + return x + + with patch.object( + SentinelTarget, "submit", new_callable=AsyncMock + ) as mock_submit: + mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"} + job = q.spawn(1) + + assert isinstance(job, runpod.Job) + assert job.id == "job-1" + + def test_stream_generator_function(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="gen") + def gen(n): + yield from range(n) + + async def fake_stream(self, job_id, *, timeout=300.0): + for chunk in ("x", "y"): + yield chunk + + with ( + patch.object( + SentinelTarget, "submit", new_callable=AsyncMock + ) as mock_submit, + patch.object(SentinelTarget, "stream_job", fake_stream), + ): + mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"} + chunks = list(gen.stream(2)) + + assert chunks == ["x", "y"] + + def test_stream_rejects_non_generator(self, monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="plain") + def plain(x): + return x + + with pytest.raises(InvalidResourceError, match="not a generator"): + list(plain.stream(1)) + + +class TestStubs: + def test_queue_stub_requires_name_or_id(self): + with pytest.raises(ValueError): + runpod.Queue(app="a") + with pytest.raises(ValueError): + runpod.Queue(app="a", name="x", id="y") + + def test_queue_stub_remote(self): + stub = runpod.Queue(app="other-app", name="q") + with patch.object( + SentinelTarget, "invoke", new_callable=AsyncMock + ) as mock_invoke: + mock_invoke.return_value = {"ok": 1} + result = stub.remote(prompt="hi") + + assert result == {"ok": 1} + mock_invoke.assert_awaited_once_with({"input": {"prompt": "hi"}}) + + def test_queue_stub_with_options(self): + stub = runpod.Queue(app="other-app", name="q") + bound = stub.with_options(webhook="https://hook", ttl=1000) + assert bound is not stub + assert stub._job_options == {} + with patch.object( + SentinelTarget, "invoke", new_callable=AsyncMock + ) as mock_invoke: + mock_invoke.return_value = {"ok": 1} + bound.remote(prompt="hi") + + mock_invoke.assert_awaited_once_with( + { + "input": {"prompt": "hi"}, + "webhook": "https://hook", + "policy": {"ttl": 1000}, + } + ) + + def test_queue_stub_spawn_returns_job(self): + stub = runpod.Queue(app="other-app", name="q") + with patch.object( + SentinelTarget, "submit", new_callable=AsyncMock + ) as mock_submit: + mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"} + job = stub.spawn(prompt="hi") + + assert isinstance(job, runpod.Job) + assert job.id == "job-1" + + def test_queue_stub_stream(self): + stub = runpod.Queue(app="other-app", name="q") + + async def fake_stream(self, job_id, *, timeout=300.0): + for chunk in ("x", "y"): + yield chunk + + with ( + patch.object( + SentinelTarget, "submit", new_callable=AsyncMock + ) as mock_submit, + patch.object(SentinelTarget, "stream_job", fake_stream), + ): + mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"} + chunks = list(stub.stream(prompt="hi")) + + assert chunks == ["x", "y"] + + def test_queue_stub_reconnects_to_job(self): + stub = runpod.Queue(app="other-app", name="q") + job = stub.job("job-1") + + assert isinstance(job, runpod.Job) + assert job.id == "job-1" + + def test_api_stub_http(self): + stub = runpod.Api(app="other-app", name="api") + with patch.object( + SentinelTarget, "request", new_callable=AsyncMock + ) as mock_request: + mock_request.return_value = {"status": "healthy"} + result = stub.get("/health") + + assert result == {"status": "healthy"} + mock_request.assert_awaited_once_with("GET", "/health", None) + + +class TestSyncBridge: + def test_remote_inside_running_loop(self, monkeypatch): + """calling sync .remote() from inside an event loop must not raise.""" + import asyncio + + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = App("my-app") + + @app.queue(name="q") + def q(x): + return x + + with patch.object( + SentinelTarget, "invoke", new_callable=AsyncMock + ) as mock_invoke: + mock_invoke.return_value = "bridged" + + async def caller(): + return q.remote(1) + + result = asyncio.run(caller()) + + assert result == "bridged" + + +class TestModuleSourceShipping: + def test_whole_module_ships(self, tmp_path): + import importlib.util + import sys + + mod_file = tmp_path / "shipmod.py" + mod_file.write_text( + "import asyncio\n" + "from pathlib import Path\n" + "\n" + "GREETING = 'hello'\n" + "\n" + "async def waiter():\n" + " await asyncio.sleep(0)\n" + " return f'{GREETING} {Path(\"x\")}'\n" + "\n" + "if __name__ == '__main__':\n" + " raise SystemExit('main guard must not run')\n" + ) + spec = importlib.util.spec_from_file_location("shipmod", mod_file) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + sys.modules["shipmod"] = mod + try: + from runpod.apps.serialization import get_function_source + + source = get_function_source(mod.waiter) + # the entire module ships: imports and globals included + assert "import asyncio" in source + assert "GREETING = 'hello'" in source + + # executes like deployed-mode module import: main guard inert + namespace = {"__name__": "__runpod_live__"} + exec(source, namespace) + import asyncio as aio + + assert aio.run(namespace["waiter"]()) == "hello x" + finally: + sys.modules.pop("shipmod", None) + + def test_execute_request_unwraps_handles(self): + # a shipped module defines decorated handles; the runner must + # call the wrapped function, not the handle + from runpod.runtimes.executor import execute_request + + code = ( + "import runpod\n" + "app = runpod.App('t')\n" + "@app.queue()\n" + "def double(x):\n" + " return x * 2\n" + ) + response = execute_request( + { + "function_name": "double", + "function_code": code, + "args": [], + "kwargs": {"x": "AAA"}, + "serialization_format": "json", + } + ) + assert response["success"], response.get("error") + assert response["json_result"] == "AAAAAA" + + def test_nested_source_extraction_from_shipped_module(self): + # inside a live worker, a function calling sibling.remote() + # re-extracts that sibling's source via inspect; the shipped + # module must be inspectable after exec + from runpod.runtimes.executor import execute_request + + code = ( + "import runpod\n" + "app = runpod.App('t2')\n" + "@app.queue()\n" + "def sibling(x):\n" + " return x\n" + "@app.queue()\n" + "def caller():\n" + " from runpod.apps.serialization import get_function_source\n" + " return get_function_source(sibling._fn)\n" + ) + response = execute_request( + { + "function_name": "caller", + "function_code": code, + "args": [], + "kwargs": {}, + "serialization_format": "json", + } + ) + assert response["success"], response.get("error") + # the whole module re-ships (decorators intact), matching what + # the first hop sent + assert "def sibling" in response["json_result"] + assert "@app.queue()" in response["json_result"] + + def test_missing_module_on_deserialize_is_actionable(self): + import base64 + + import cloudpickle + import pytest + + from runpod.apps.errors import RemoteExecutionError + from runpod.apps.serialization import deserialize_result + + class FakePickle: + def __reduce__(self): + return (__import__, ("nonexistent_pkg_xyz",)) + + payload = base64.b64encode(cloudpickle.dumps(FakePickle())).decode() + with pytest.raises(RemoteExecutionError, match="nonexistent_pkg_xyz"): + deserialize_result(payload) diff --git a/tests/test_apps/test_executor.py b/tests/test_apps/test_executor.py new file mode 100644 index 00000000..01205e70 --- /dev/null +++ b/tests/test_apps/test_executor.py @@ -0,0 +1,195 @@ +"""unit tests for the shared FunctionRequest execution engine.""" + +import pytest + +from runpod.runtimes import executor + + +def test_execute_roundtrip_json(): + response = executor.execute_request( + { + "function_name": "mul", + "function_code": "def mul(a, b):\n return a * b", + "args": [6, 7], + "kwargs": {}, + "serialization_format": "json", + } + ) + assert response["success"] is True + assert response["json_result"] == 42 + + +def test_json_result_rejects_non_json_return(): + with pytest.raises(TypeError, match="json-serializable"): + executor._serialize_result(object(), "json") + + +def test_serialize_chunk_marks_stream(): + chunk = executor.serialize_chunk("tok", {"serialization_format": "json"}) + assert chunk == {"success": True, "__stream__": True, "json_result": "tok"} + + +def test_resolve_request_returns_callable(): + prepared, error = executor.resolve_request( + { + "function_name": "double", + "function_code": "def double(x):\n return x * 2", + "kwargs": {}, + "args": [], + "serialization_format": "json", + } + ) + assert error is None + fn, args, kwargs = prepared + assert fn(4) == 8 + + +def test_resolve_request_missing_function(): + prepared, error = executor.resolve_request( + {"function_name": "nope", "function_code": "x = 1"} + ) + assert prepared is None + assert error["success"] is False + assert "not found" in error["error"] + + +def test_generator_results_aggregate(): + response = executor.execute_request( + { + "function_name": "gen", + "function_code": "def gen(n):\n yield from range(n)", + "args": [], + "kwargs": {"n": 3}, + "serialization_format": "json", + } + ) + assert response["success"] is True + assert response["json_result"] == [0, 1, 2] + + +class TestSystemDependencies: + def test_missing_apt_reports_error(self, monkeypatch): + import shutil + + monkeypatch.setattr(shutil, "which", lambda _: None) + response = executor.execute_request( + { + "function_name": "f", + "function_code": "def f():\n return 1", + "system_dependencies": ["ffmpeg"], + "serialization_format": "json", + } + ) + assert response["success"] is False + assert "apt-get is not available" in response["error"] + + def test_system_deps_installed_before_execution(self, monkeypatch): + import shutil + + monkeypatch.setattr(shutil, "which", lambda _: "/usr/bin/apt-get") + monkeypatch.setattr(executor, "_apt_updated", False) + calls = [] + + class R: + returncode = 0 + stderr = "" + + monkeypatch.setattr( + executor.subprocess, + "run", + lambda cmd, **kw: calls.append(cmd) or R(), + ) + response = executor.execute_request( + { + "function_name": "f", + "function_code": "def f():\n return 40 + 2", + "system_dependencies": ["ffmpeg"], + "serialization_format": "json", + } + ) + assert response["success"] is True + assert response["json_result"] == 42 + assert calls[0][:2] == ["apt-get", "update"] + assert "ffmpeg" in calls[1] + + def test_apt_update_runs_once(self, monkeypatch): + import shutil + + monkeypatch.setattr(shutil, "which", lambda _: "/usr/bin/apt-get") + monkeypatch.setattr(executor, "_apt_updated", False) + calls = [] + + class R: + returncode = 0 + stderr = "" + + monkeypatch.setattr( + executor.subprocess, + "run", + lambda cmd, **kw: calls.append(cmd) or R(), + ) + executor._install_system(["ffmpeg"]) + executor._install_system(["sox"]) + updates = [c for c in calls if c[:2] == ["apt-get", "update"]] + assert len(updates) == 1 + + +class TestStdoutTee: + def test_prints_reach_real_stdout_and_response(self, capfd): + response = executor.execute_request( + { + "function_name": "speak", + "function_code": "def speak():\n print('live line')\n return 1\n", + "args": [], + "kwargs": {}, + "serialization_format": "json", + } + ) + assert response["success"] + # captured for the job response + assert "live line" in response["stdout"] + # and written through to the container's stdout for log streams + assert "live line" in capfd.readouterr().out + + +class TestCloudpickleLoading: + def test_available_returns_module(self): + module = executor._load_cloudpickle() + assert module is not None + assert hasattr(module, "dumps") + + def test_missing_without_install_returns_none(self, monkeypatch): + import builtins + + real_import = builtins.__import__ + + def no_cloudpickle(name, *args, **kwargs): + if name == "cloudpickle": + raise ImportError("not installed") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_cloudpickle) + assert executor._load_cloudpickle(install=False) is None + + +class TestInstallHelpers: + def test_install_empty_is_noop(self): + assert executor._install([], "nothing") is None + + def test_install_failure_returns_message(self, monkeypatch): + from unittest.mock import MagicMock + + result = MagicMock(returncode=1, stderr="resolver exploded") + monkeypatch.setattr( + executor.subprocess, "run", MagicMock(return_value=result) + ) + message = executor._install(["ghost-package"], "deps") + assert "resolver exploded" in message + + def test_install_system_requires_apt(self, monkeypatch): + monkeypatch.setattr(executor.shutil, "which", lambda _: None) + message = executor._install_system(["ffmpeg"]) + assert "apt-get" in message + + def test_install_system_empty_is_noop(self): + assert executor._install_system([]) is None diff --git a/tests/test_apps/test_images.py b/tests/test_apps/test_images.py new file mode 100644 index 00000000..87e7dfc2 --- /dev/null +++ b/tests/test_apps/test_images.py @@ -0,0 +1,83 @@ +"""tests for runtime image selection.""" + +import pytest + +from runpod.apps.images import ( + SUPPORTED_PYTHON_VERSIONS, + image_for_spec, + local_python_version, + runtime_image, +) +from runpod.apps.spec import ResourceKind, ResourceSpec + + +class TestRuntimeImage: + @pytest.mark.parametrize("kind", list(ResourceKind)) + @pytest.mark.parametrize("gpu", [False, True]) + @pytest.mark.parametrize("version", SUPPORTED_PYTHON_VERSIONS) + def test_full_matrix_resolves(self, kind, gpu, version): + image = runtime_image(kind, gpu=gpu, python_version=version) + assert image.startswith("runpod/") + assert f"py{version}-" in image + assert ("-gpu:" in image) == gpu + + def test_unsupported_python_rejected(self): + with pytest.raises(ValueError, match="3.9"): + runtime_image(ResourceKind.QUEUE, gpu=False, python_version="3.9") + + def test_tag_channel_from_env(self, monkeypatch): + monkeypatch.setenv("RUNPOD_RUNTIME_TAG", "dev") + image = runtime_image(ResourceKind.TASK, gpu=True, python_version="3.11") + assert image == "runpod/task-gpu:py3.11-dev" + + def test_default_channel_is_latest(self, monkeypatch): + monkeypatch.delenv("RUNPOD_RUNTIME_TAG", raising=False) + image = runtime_image(ResourceKind.API, gpu=False, python_version="3.12") + assert image == "runpod/api:py3.12-latest" + + +class TestImageForSpec: + def test_custom_image_wins(self): + spec = ResourceSpec( + kind=ResourceKind.QUEUE, name="q", image="my/image:1" + ) + assert image_for_spec(spec) == "my/image:1" + + def test_cpu_spec_gets_cpu_image(self): + spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q", cpu=["cpu3c-1-2"]) + assert image_for_spec(spec, python_version="3.12") == ( + "runpod/queue:py3.12-latest" + ) + + def test_gpu_spec_gets_gpu_image(self): + spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q", gpu=["ADA_24"]) + assert image_for_spec(spec, python_version="3.12") == ( + "runpod/queue-gpu:py3.12-latest" + ) + + +class TestLocalPythonVersion: + def test_returns_supported_version(self): + assert local_python_version() in SUPPORTED_PYTHON_VERSIONS + + def test_unsupported_local_python_fails_loudly(self, monkeypatch): + # cloudpickle payloads are version-bound; a silent fallback + # would break at deserialization time on the worker + import runpod.apps.images as images + + class FakeVersion: + major, minor = 3, 9 + + monkeypatch.setattr(images.sys, "version_info", FakeVersion) + with pytest.raises(RuntimeError, match="3.9"): + local_python_version() + + def test_supported_range_policy(self): + # non-EOL cpython with torch wheels; update at EOL boundaries + assert SUPPORTED_PYTHON_VERSIONS == ( + "3.10", + "3.11", + "3.12", + "3.13", + "3.14", + ) diff --git a/tests/test_apps/test_job.py b/tests/test_apps/test_job.py new file mode 100644 index 00000000..cc8a4e24 --- /dev/null +++ b/tests/test_apps/test_job.py @@ -0,0 +1,161 @@ +"""tests for the shared Apps SDK queue job.""" + +from typing import Any, Callable, Dict, Optional + +import pytest + +import runpod +from runpod.apps.app import _clear_registry +from runpod.apps.errors import InvalidResourceError +from runpod.apps.job import Job + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +class FakeTarget: + def __init__(self): + self.calls = [] + + async def job_status(self, job_id: str) -> Dict[str, Any]: + self.calls.append(("status", job_id)) + return {"id": job_id, "status": "IN_PROGRESS", "workerId": "worker-1"} + + async def wait( + self, + job_data: Dict[str, Any], + *, + timeout: float, + on_status: Optional[Callable[[Dict[str, Any]], None]] = None, + ) -> Any: + self.calls.append(("wait", job_data["id"], timeout)) + final = { + "id": job_data["id"], + "status": "COMPLETED", + "output": {"answer": 42}, + } + if on_status is not None: + on_status(final) + return final["output"] + + async def cancel_job(self, job_id: str) -> Dict[str, Any]: + self.calls.append(("cancel", job_id)) + return {"id": job_id, "status": "CANCELLED"} + + async def retry_job(self, job_id: str) -> Dict[str, Any]: + self.calls.append(("retry", job_id)) + return {"id": job_id, "status": "IN_QUEUE", "retries": 1} + + async def stream_job(self, job_id: str, *, timeout: float = 300.0): + self.calls.append(("stream", job_id)) + for chunk in ("a", "b", "c"): + yield chunk + + +def test_job_status_polls_target(): + target = FakeTarget() + job = Job({"id": "job-1", "status": "IN_QUEUE"}, target) + + assert job.status() == "IN_PROGRESS" + assert target.calls == [("status", "job-1")] + + +def test_job_status_skips_poll_once_terminal(): + target = FakeTarget() + job = Job({"id": "job-1", "status": "COMPLETED"}, target) + + assert job.status() == "COMPLETED" + assert target.calls == [] + + +def test_job_result_reaches_terminal_state(): + target = FakeTarget() + job = Job({"id": "job-1", "status": "IN_QUEUE"}, target) + + assert job.result(timeout=12) == {"answer": 42} + assert job.done is True + assert job.status() == "COMPLETED" + # the wait carried timeout through; terminal status needed no extra poll + assert target.calls == [("wait", "job-1", 12)] + + +def test_job_output_aliases_result(): + target = FakeTarget() + job = Job({"id": "job-1", "status": "IN_QUEUE"}, target) + + assert job.output() == {"answer": 42} + + +def test_job_cancel_and_retry_update_state(): + target = FakeTarget() + job = Job({"id": "job-1", "status": "IN_PROGRESS"}, target) + + assert job.cancel() is job + assert job.done is True + assert job.retry() is job + assert job.done is False + assert target.calls == [("cancel", "job-1"), ("retry", "job-1")] + + +async def test_job_async_forms(): + target = FakeTarget() + job = Job({"id": "job-1", "status": "IN_QUEUE"}, target) + + assert await job.status.aio() == "IN_PROGRESS" + assert await job.result.aio() == {"answer": 42} + assert await job.cancel.aio() is job + assert await job.retry.aio() is job + + +def test_job_stream_sync(): + target = FakeTarget() + job = Job({"id": "job-1", "status": "IN_QUEUE"}, target) + + assert list(job.stream()) == ["a", "b", "c"] + assert target.calls == [("stream", "job-1")] + + +async def test_job_stream_async(): + target = FakeTarget() + job = Job({"id": "job-1", "status": "IN_QUEUE"}, target) + + chunks = [chunk async for chunk in job.stream.aio()] + assert chunks == ["a", "b", "c"] + + +def test_job_repr(): + job = Job({"id": "job-1", "status": "IN_QUEUE"}, FakeTarget()) + assert repr(job) == "Job(id='job-1', status='IN_QUEUE')" + + +def test_decorated_queue_reconnects_to_job(monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = runpod.App("demo") + + @app.queue(name="generate") + def generate(prompt: str): + return prompt + + job = generate.job("job-1") + + assert isinstance(job, runpod.Job) + assert job.id == "job-1" + assert job.done is False + + +def test_task_job_cannot_reconnect(monkeypatch): + for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"): + monkeypatch.delenv(var, raising=False) + app = runpod.App("demo") + + @app.task(name="train") + def train(): + return None + + with pytest.raises(InvalidResourceError, match="cannot be reconnected"): + train.job("job-1") diff --git a/tests/test_apps/test_logs.py b/tests/test_apps/test_logs.py new file mode 100644 index 00000000..f32b661b --- /dev/null +++ b/tests/test_apps/test_logs.py @@ -0,0 +1,140 @@ +"""unit tests for pod log access.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from runpod.apps import logs as pod_logs_mod +from runpod.apps.logs import pod_logs, stream_pod_logs, tail_summary + + +class TestTailSummary: + def test_empty(self): + assert tail_summary({}) == "(no logs available)" + assert tail_summary({"container": [], "system": []}) == ( + "(no logs available)" + ) + + def test_container_only(self): + out = tail_summary({"container": ["a", "b"], "system": []}) + assert "--- container (last 2) ---" in out + assert "a" in out and "b" in out + assert "system" not in out + + def test_tail_limit(self): + entries = [f"line{i}" for i in range(50)] + out = tail_summary({"container": entries}, lines=3) + assert "line49" in out + assert "line46" not in out + assert "(last 3)" in out + + def test_system_before_container(self): + out = tail_summary({"container": ["c"], "system": ["s"]}) + assert out.index("system") < out.index("container") + + +def _mock_session(response): + """a ClientSession whose get() context manager yields `response`.""" + get_cm = MagicMock() + get_cm.__aenter__ = AsyncMock(return_value=response) + get_cm.__aexit__ = AsyncMock(return_value=False) + session = MagicMock() + session.get = MagicMock(return_value=get_cm) + session_cm = MagicMock() + session_cm.__aenter__ = AsyncMock(return_value=session) + session_cm.__aexit__ = AsyncMock(return_value=False) + return session_cm, session + + +class TestPodLogs: + async def test_snapshot_normalizes_nulls(self, monkeypatch): + monkeypatch.setenv("RUNPOD_API_KEY", "test-key") + response = MagicMock() + response.raise_for_status = MagicMock() + response.json = AsyncMock( + return_value={"container": ["hello"], "system": None} + ) + session_cm, session = _mock_session(response) + + with patch("aiohttp.ClientSession", return_value=session_cm): + result = await pod_logs("pod123") + + assert result == {"container": ["hello"], "system": []} + url = session.get.call_args[0][0] + assert "pod123" in url + headers = session.get.call_args[1]["headers"] + assert headers["Authorization"] == "Bearer test-key" + + async def test_log_type_param(self, monkeypatch): + monkeypatch.setenv("RUNPOD_API_KEY", "test-key") + response = MagicMock() + response.raise_for_status = MagicMock() + response.json = AsyncMock(return_value={}) + session_cm, session = _mock_session(response) + + with patch("aiohttp.ClientSession", return_value=session_cm): + await pod_logs("pod123", log_type="system") + + assert session.get.call_args[1]["params"] == {"type": "system"} + + +class _FakeContent: + def __init__(self, lines): + self._lines = list(lines) + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._lines: + raise StopAsyncIteration + return self._lines.pop(0) + + +class TestStreamPodLogs: + async def test_parses_sse_frames(self, monkeypatch): + monkeypatch.setenv("RUNPOD_API_KEY", "test-key") + frames = [ + b": heartbeat\n", + b"data: " + json.dumps( + {"source": "container", "line": "hi", "ts": "t1"} + ).encode() + b"\n", + b"garbage\n", + b"data: not-json\n", + b"data: " + json.dumps( + {"source": "system", "line": "boot", "ts": "t2"} + ).encode() + b"\n", + ] + response = MagicMock() + response.raise_for_status = MagicMock() + response.content = _FakeContent(frames) + session_cm, session = _mock_session(response) + + with patch("aiohttp.ClientSession", return_value=session_cm): + events = [e async for e in stream_pod_logs("pod123")] + + assert events == [ + {"source": "container", "line": "hi", "ts": "t1"}, + {"source": "system", "line": "boot", "ts": "t2"}, + ] + params = session.get.call_args[1]["params"] + assert params["stream"] == "true" + assert params["tail"] == "100" + + async def test_since_param(self, monkeypatch): + monkeypatch.setenv("RUNPOD_API_KEY", "test-key") + response = MagicMock() + response.raise_for_status = MagicMock() + response.content = _FakeContent([]) + session_cm, session = _mock_session(response) + + with patch("aiohttp.ClientSession", return_value=session_cm): + async for _ in stream_pod_logs( + "pod123", since="2026-01-01T00:00:00Z", tail=5 + ): + pass + + params = session.get.call_args[1]["params"] + assert params["since"] == "2026-01-01T00:00:00Z" + assert params["tail"] == "5" diff --git a/tests/test_apps/test_manage.py b/tests/test_apps/test_manage.py new file mode 100644 index 00000000..637505a5 --- /dev/null +++ b/tests/test_apps/test_manage.py @@ -0,0 +1,150 @@ +"""app/environment lifecycle: list, inspect, undeploy, delete.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from runpod.apps.manage import ( + AppNotFound, + EnvironmentNotFound, + delete_app, + get_app, + get_environment, + list_apps, + undeploy_environment, +) + + +def _api(**overrides): + api = AsyncMock() + api.list_apps.return_value = [] + api.get_app_by_name.return_value = None + api.get_environment_by_name.return_value = None + api.delete_endpoint.return_value = True + api.delete_environment.return_value = True + api.delete_app.return_value = True + for key, value in overrides.items(): + getattr(api, key).return_value = value + return api + + +class TestQueries: + def test_list_sorted_by_name(self): + api = _api( + list_apps=[{"name": "zeta"}, {"name": "alpha"}] + ) + apps = asyncio.run(list_apps(api=api)) + assert [a["name"] for a in apps] == ["alpha", "zeta"] + + def test_get_app_not_found(self): + with pytest.raises(AppNotFound, match="rp deploy"): + asyncio.run(get_app("ghost", api=_api())) + + def test_get_environment_not_found(self): + with pytest.raises(EnvironmentNotFound, match="ghost"): + asyncio.run(get_environment("app", "ghost", api=_api())) + + +class TestUndeployEnvironment: + def _env(self, n_endpoints=2): + return { + "id": "env-1", + "name": "default", + "endpoints": [ + {"id": f"ep-{i}", "name": f"svc-{i}"} + for i in range(n_endpoints) + ], + } + + def test_deletes_endpoints_then_environment(self): + api = _api(get_environment_by_name=self._env()) + result = asyncio.run( + undeploy_environment("app", "default", api=api) + ) + assert result.endpoints_deleted == 2 + assert result.environment_deleted + assert not result.failures + api.delete_environment.assert_awaited_once_with("env-1") + + def test_keep_env_flag(self): + api = _api(get_environment_by_name=self._env()) + result = asyncio.run( + undeploy_environment("app", "default", api=api, delete_env=False) + ) + assert result.endpoints_deleted == 2 + assert not result.environment_deleted + api.delete_environment.assert_not_awaited() + + def test_endpoint_failure_keeps_environment(self): + api = _api(get_environment_by_name=self._env(1)) + api.delete_endpoint.side_effect = RuntimeError("in use") + result = asyncio.run( + undeploy_environment("app", "default", api=api) + ) + assert result.endpoints_deleted == 0 + assert not result.environment_deleted + assert result.failures + api.delete_environment.assert_not_awaited() + + def test_events_emitted(self): + events = [] + + class Sink: + def cleanup_started(self, total): + events.append(("started", total)) + + def deleting(self, name): + events.append(("deleting", name)) + + def deleted(self, name): + events.append(("deleted", name)) + + api = _api(get_environment_by_name=self._env(1)) + asyncio.run( + undeploy_environment("app", "default", api=api, events=Sink()) + ) + assert events == [ + ("started", 1), + ("deleting", "svc-0"), + ("deleted", "svc-0"), + ] + + +class TestDeleteApp: + def test_undeploys_all_envs_then_app(self): + app_data = { + "id": "app-1", + "name": "myapp", + "flashEnvironments": [ + {"id": "env-1", "name": "default"}, + {"id": "env-2", "name": "staging"}, + ], + } + api = _api(get_app_by_name=app_data) + api.get_environment_by_name.side_effect = [ + {"id": "env-1", "name": "default", "endpoints": [{"id": "e1", "name": "a"}]}, + {"id": "env-2", "name": "staging", "endpoints": []}, + ] + result = asyncio.run(delete_app("myapp", api=api)) + assert result.endpoints_deleted == 1 + assert result.app_deleted + api.delete_app.assert_awaited_once_with("app-1") + + def test_failure_keeps_app(self): + app_data = { + "id": "app-1", + "name": "myapp", + "flashEnvironments": [{"id": "env-1", "name": "default"}], + } + api = _api(get_app_by_name=app_data) + api.get_environment_by_name.return_value = { + "id": "env-1", + "name": "default", + "endpoints": [{"id": "e1", "name": "a"}], + } + api.delete_endpoint.side_effect = RuntimeError("nope") + result = asyncio.run(delete_app("myapp", api=api)) + assert not result.app_deleted + assert result.failures + api.delete_app.assert_not_awaited() diff --git a/tests/test_apps/test_model.py b/tests/test_apps/test_model.py new file mode 100644 index 00000000..2aaaaf23 --- /dev/null +++ b/tests/test_apps/test_model.py @@ -0,0 +1,91 @@ +"""model references: parsing, paths, payload plumbing.""" + +from pathlib import Path + +import pytest + +from runpod.apps.model import Model, ModelError, model_reference + + +class TestModelRef: + def test_repo_parse(self): + model = Model("meta-llama/Llama-3.1-8B-Instruct") + assert model.owner == "meta-llama" + assert model.name == "Llama-3.1-8B-Instruct" + assert model.revision is None + + def test_revision_parse(self): + model = Model("org/name:abc123") + assert model.revision == "abc123" + assert model.reference == "org/name:abc123" + + def test_invalid_references(self): + for bad in ("", "no-slash", "a/b/c", "spaces in/name"): + with pytest.raises(ModelError): + Model(bad) + + def test_store_path_with_env_revision(self, monkeypatch): + monkeypatch.setenv("MODEL_REVISION", "deadbeef") + model = Model("org/name") + assert model.path == Path( + "/runpod/model-store/huggingface/org/name/deadbeef" + ) + + def test_store_path_without_revision(self, monkeypatch): + monkeypatch.delenv("MODEL_REVISION", raising=False) + model = Model("org/name") + assert model.path == Path("/runpod/model-store/huggingface/org/name") + + def test_hf_cache_path(self): + model = Model("org/name") + assert model.hf_cache_path == Path( + "/runpod-volume/huggingface-cache/hub/models--org--name" + ) + + def test_model_reference_normalization(self): + assert model_reference(None) is None + assert model_reference("org/name") == "org/name" + assert model_reference(Model("org/name:rev")) == "org/name:rev" + with pytest.raises(ModelError): + model_reference(42) + + +class TestSpecPlumbing: + def test_decorator_accepts_model(self): + from runpod.apps.app import App + + app = App("modeltest") + llama = Model("meta-llama/Llama-3.1-8B-Instruct") + + @app.queue(name="chat", gpu="4090", model=llama) + def chat(prompt: str): + pass + + assert chat.spec.model is llama + assert ( + chat.spec.to_manifest()["model"] + == "meta-llama/Llama-3.1-8B-Instruct" + ) + + def test_task_decorator_rejects_model(self): + from runpod.apps.app import App + + app = App("modeltest") + + # model= is not a parameter of @app.task; passed via a kwargs + # dict so the intentional misuse is constructed at runtime + kwargs = {"name": "train", "gpu": "4090", "model": Model("org/name")} + with pytest.raises(TypeError, match="model"): + + @app.task(**kwargs) + def train(): + pass + + def test_task_spec_rejects_model(self): + from runpod.apps.errors import InvalidResourceError + from runpod.apps.spec import ResourceKind, ResourceSpec + + with pytest.raises(InvalidResourceError, match="queue and api"): + ResourceSpec( + kind=ResourceKind.TASK, name="train", model=Model("org/name") + ) diff --git a/tests/test_apps/test_monitor.py b/tests/test_apps/test_monitor.py new file mode 100644 index 00000000..c1cb2719 --- /dev/null +++ b/tests/test_apps/test_monitor.py @@ -0,0 +1,273 @@ +"""worker monitor: metrics transitions, log attach, event emission.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from runpod.apps.monitor import WorkerMonitor, emit, format_worker_counts + + +class Sink: + def __init__(self): + self.events = [] + + def worker_status(self, name, counts): + self.events.append(("worker_status", name, counts)) + + def worker_ready(self, name, worker_id): + self.events.append(("worker_ready", name, worker_id)) + + def worker_log(self, name, line): + self.events.append(("worker_log", name, line)) + + +class TestEmit: + def test_missing_handler_skipped(self): + emit(object(), "nope", 1) + + def test_none_sink_skipped(self): + emit(None, "anything") + + def test_handler_called(self): + sink = Sink() + emit(sink, "worker_ready", "calc", "w1") + assert sink.events == [("worker_ready", "calc", "w1")] + + def test_handler_errors_swallowed(self): + class Bad: + def worker_ready(self, *a): + raise RuntimeError("render bug") + + emit(Bad(), "worker_ready", "calc", "w1") + + +class TestFormatWorkerCounts: + def test_only_nonzero(self): + s = format_worker_counts({"initializing": 1, "ready": 2, "throttled": 0}) + assert s == "1 initializing, 2 ready" + + def test_empty(self): + assert format_worker_counts({"ready": 0}) == "no workers" + + +class TestOnStatus: + def test_worker_id_triggers_ready_and_stream(self): + sink = Sink() + monitor = WorkerMonitor("ep1", "calc", sink) + + async def run(): + with patch( + "runpod.apps.monitor.PodLogStream.attach" + ) as attach, patch( + "runpod.apps.monitor.PodLogStream.stop", + return_value=asyncio.sleep(0), + ): + monitor.on_status({"workerId": "w123", "status": "IN_PROGRESS"}) + monitor.on_status({"workerId": "w123", "status": "IN_PROGRESS"}) + # one stream per worker, not per status payload + assert attach.call_count == 1 + await monitor.stop() + + asyncio.run(run()) + assert sink.events == [("worker_ready", "calc", "w123")] + + def test_no_worker_id_no_events(self): + sink = Sink() + monitor = WorkerMonitor("ep1", "calc", sink) + monitor.on_status({"status": "IN_QUEUE"}) + assert sink.events == [] + + +class TestReportCounts: + def test_first_steady_snapshot_suppressed(self): + sink = Sink() + monitor = WorkerMonitor("ep1", "calc", sink) + monitor._report_counts({"workers": {"ready": 2, "running": 1}}) + assert sink.events == [] + + def test_first_snapshot_with_initializing_reported(self): + sink = Sink() + monitor = WorkerMonitor("ep1", "calc", sink) + monitor._report_counts({"workers": {"initializing": 1}}) + assert len(sink.events) == 1 + assert sink.events[0][2]["initializing"] == 1 + + def test_transition_reported_once(self): + sink = Sink() + monitor = WorkerMonitor("ep1", "calc", sink) + monitor._report_counts({"workers": {"initializing": 1}}) + monitor._report_counts({"workers": {"initializing": 1}}) + monitor._report_counts({"workers": {"ready": 1}}) + assert len(sink.events) == 2 + assert sink.events[1][2]["ready"] == 1 + + def test_malformed_payload_ignored(self): + sink = Sink() + monitor = WorkerMonitor("ep1", "calc", sink) + monitor._report_counts({"workers": None}) + monitor._report_counts({}) + assert sink.events == [] + + +class TestLineFiltering: + def test_user_prints_pass_through(self): + from runpod.apps.monitor import _filter_line + + assert _filter_line("hello world") == "hello world" + assert _filter_line("42") == "42" + + def test_sdk_info_frames_hidden(self): + from runpod.apps.monitor import _filter_line + + assert ( + _filter_line('{"requestId": null, "message": "Jobs in queue: 1", "level": "INFO"}') + is None + ) + assert ( + _filter_line('{"requestId": "abc", "message": "Started.", "level": "INFO"}') + is None + ) + + def test_sdk_error_frames_surface_message(self): + from runpod.apps.monitor import _filter_line + + assert ( + _filter_line('{"requestId": "abc", "message": "boom", "level": "ERROR"}') + == "boom" + ) + + def test_non_frame_json_passes_through(self): + from runpod.apps.monitor import _filter_line + + # user code printing json without the sdk frame shape + assert _filter_line('{"result": 5}') == '{"result": 5}' + + +class TestPodLogStream: + async def test_follow_emits_and_dedups(self): + from runpod.apps.monitor import PodLogStream + + sink = Sink() + events = [ + {"ts": "t1", "line": "hello"}, + {"ts": "t1", "line": "hello"}, # duplicate frame + {"ts": "t2", "line": ""}, # blank filtered + {"ts": "t3", "line": "world"}, + ] + + calls = {"n": 0} + + async def fake_stream(pod_id, **kwargs): + if calls["n"]: + # second attach: park forever so stop() cancels us + await asyncio.sleep(3600) + calls["n"] += 1 + for event in events: + yield event + + stream = PodLogStream("pod1", "calc", sink) + with patch("runpod.apps.logs.stream_pod_logs", fake_stream): + stream.attach() + await asyncio.sleep(0.05) + await stream.stop() + + logs = [e for e in sink.events if e[0] == "worker_log"] + assert logs == [ + ("worker_log", "calc", "hello"), + ("worker_log", "calc", "world"), + ] + + async def test_stop_snapshots_when_stream_never_attached(self): + from datetime import datetime, timedelta, timezone + + from runpod.apps.monitor import PodLogStream + + sink = Sink() + + async def failing_stream(pod_id, **kwargs): + raise RuntimeError("403") + yield # pragma: no cover + + stream = PodLogStream("pod1", "calc", sink) + after = ( + datetime.now(timezone.utc) + timedelta(seconds=5) + ).isoformat() + snapshot = { + "container": [f"{after} recovered output"], + "system": [], + } + with ( + patch("runpod.apps.logs.stream_pod_logs", failing_stream), + patch( + "runpod.apps.logs.pod_logs", + AsyncMock(return_value=snapshot), + ), + ): + stream.attach() + await asyncio.sleep(0.05) + await stream.stop() + + logs = [e for e in sink.events if e[0] == "worker_log"] + assert logs == [("worker_log", "calc", "recovered output")] + + async def test_snapshot_skips_lines_before_since(self): + from runpod.apps.monitor import PodLogStream + + sink = Sink() + stale = "2000-01-01T00:00:00.000000000Z old line" + snapshot = {"container": [stale], "system": []} + stream = PodLogStream("pod1", "calc", sink) + with patch( + "runpod.apps.logs.pod_logs", + AsyncMock(return_value=snapshot), + ): + await stream._snapshot() + + assert sink.events == [] + + async def test_attach_idempotent(self): + from runpod.apps.monitor import PodLogStream + + async def parked(pod_id, **kwargs): + await asyncio.sleep(3600) + yield # pragma: no cover + + stream = PodLogStream("pod1", "calc", Sink()) + with patch("runpod.apps.logs.stream_pod_logs", parked): + stream.attach() + first = stream._task + stream.attach() + assert stream._task is first + stream._lines_emitted = 1 # skip the snapshot fallback + await stream.stop() + assert stream._task is None + + +class TestMonitorLifecycle: + async def test_start_without_metrics_key_is_noop(self): + monitor = WorkerMonitor("ep1", "calc", Sink()) + await monitor.start() + assert monitor._tasks == [] + await monitor.stop() + + async def test_metrics_polling_reports_counts(self): + import runpod + + sink = Sink() + monitor = WorkerMonitor("ep1", "calc", sink, metrics_key="mk") + payload = {"workers": {"initializing": 1, "ready": 0}} + + async def fake_get(url, headers, timeout): + assert headers["Authorization"] == "Bearer mk" + return payload + + with patch("runpod.apps.targets._get_json", fake_get): + await monitor.start() + await asyncio.sleep(0.05) + await monitor.stop() + + statuses = [e for e in sink.events if e[0] == "worker_status"] + assert statuses + assert statuses[0][2]["initializing"] == 1 + diff --git a/tests/test_apps/test_placement.py b/tests/test_apps/test_placement.py new file mode 100644 index 00000000..9c55c210 --- /dev/null +++ b/tests/test_apps/test_placement.py @@ -0,0 +1,168 @@ +"""placement solve: candidates, intersections, maximin ranking.""" + +import pytest + +from runpod.apps.placement import ( + PlacementError, + StockMap, + candidates, + solve_placement, +) +from runpod.apps.spec import ResourceKind, ResourceSpec + + +def _spec(name="r", gpu=None, cpu=None, datacenter=None): + return ResourceSpec( + kind=ResourceKind.TASK, + name=name, + gpu=gpu, + cpu=cpu, + datacenter=datacenter, + ) + + +def _stock(gpu=None, cpu=None): + stock = StockMap(api=object()) + # task specs query the pod plane; mirror into both tables so the + # fixtures stay hardware-shaped rather than plane-shaped + stock._gpu = dict(gpu or {}) + stock._gpu_pod = dict(gpu or {}) + stock._cpu = cpu or {} + return stock + + +class TestCandidates: + def test_gpu_stock_filters_dcs(self): + stock = _stock( + gpu={ + ("NVIDIA GeForce RTX 4090", "EU-RO-1"): 3, + ("NVIDIA GeForce RTX 4090", "US-KS-2"): 0, + } + ) + dcs = candidates(_spec(gpu=["NVIDIA GeForce RTX 4090"]), stock) + assert "EU-RO-1" in dcs + assert "US-KS-2" not in dcs + + def test_pool_id_expands_to_devices(self): + stock = _stock(gpu={("NVIDIA GeForce RTX 4090", "EU-RO-1"): 2}) + dcs = candidates(_spec(gpu=["ADA_24"]), stock) + assert "EU-RO-1" in dcs + + def test_datacenter_pin_intersects(self): + stock = _stock( + gpu={ + ("NVIDIA GeForce RTX 4090", "EU-RO-1"): 3, + ("NVIDIA GeForce RTX 4090", "US-KS-2"): 3, + } + ) + dcs = candidates( + _spec(gpu=["NVIDIA GeForce RTX 4090"], datacenter=["US-KS-2"]), + stock, + ) + assert dcs == {"US-KS-2"} + + def test_cpu_stock(self): + stock = _stock(cpu={("cpu5c-2-4", "EU-RO-1"): 2}) + dcs = candidates(_spec(cpu=["cpu5c-2-4"]), stock) + assert dcs == {"EU-RO-1"} + + def test_any_gpu_allows_everywhere(self): + stock = _stock(gpu={("NVIDIA GeForce RTX 4090", "EU-RO-1"): 1}) + dcs = candidates(_spec(gpu=None), stock) + assert "EU-RO-1" in dcs + + +class TestSolvePlacement: + def test_intersection_picks_shared_dc(self): + stock = _stock( + gpu={ + ("NVIDIA GeForce RTX 4090", "EU-RO-1"): 3, + ("NVIDIA GeForce RTX 4090", "US-KS-2"): 3, + ("NVIDIA H200", "EU-RO-1"): 2, + } + ) + dc = solve_placement( + [ + _spec("train", gpu=["NVIDIA H200"]), + _spec("infer", gpu=["NVIDIA GeForCE RTX 4090".replace("CE", "ce")]), + ], + stock, + volume_name="models", + ) + assert dc == "EU-RO-1" + + def test_disjoint_hardware_errors_with_details(self): + stock = _stock( + gpu={ + ("NVIDIA H200", "EU-RO-1"): 3, + ("NVIDIA B200", "US-KS-2"): 3, + } + ) + with pytest.raises(PlacementError, match="no datacenter can host"): + solve_placement( + [ + _spec("train", gpu=["NVIDIA H200"]), + _spec("eval", gpu=["NVIDIA B200"]), + ], + stock, + volume_name="models", + ) + + def test_existing_dc_is_hard_constraint(self): + stock = _stock(gpu={("NVIDIA H200", "EU-RO-1"): 3}) + dc = solve_placement( + [_spec("train", gpu=["NVIDIA H200"])], + stock, + volume_name="models", + existing_dc="EU-RO-1", + ) + assert dc == "EU-RO-1" + + def test_existing_dc_unschedulable_errors(self): + stock = _stock(gpu={("NVIDIA H200", "EU-RO-1"): 3}) + with pytest.raises(PlacementError, match="lives in US-KS-2"): + solve_placement( + [_spec("train", gpu=["NVIDIA H200"])], + stock, + volume_name="models", + existing_dc="US-KS-2", + ) + + def test_maximin_prefers_worst_case_stock(self): + # both DCs host both resources; EU is (3, 1), US is (2, 2): + # maximin picks US because its worst resource is better off + stock = _stock( + gpu={ + ("NVIDIA H200", "EU-RO-1"): 3, + ("NVIDIA H200", "US-KS-2"): 2, + ("NVIDIA B200", "EU-RO-1"): 1, + ("NVIDIA B200", "US-KS-2"): 2, + } + ) + dc = solve_placement( + [ + _spec("a", gpu=["NVIDIA H200"]), + _spec("b", gpu=["NVIDIA B200"]), + ], + stock, + volume_name="v", + ) + assert dc == "US-KS-2" + + def test_mixed_cpu_gpu_sharing(self): + stock = _stock( + gpu={ + ("NVIDIA GeForce RTX 4090", "EU-RO-1"): 3, + ("NVIDIA GeForce RTX 4090", "US-KS-2"): 3, + }, + cpu={("cpu5c-2-4", "EU-RO-1"): 2}, + ) + dc = solve_placement( + [ + _spec("gpures", gpu=["NVIDIA GeForce RTX 4090"]), + _spec("cpures", cpu=["cpu5c-2-4"]), + ], + stock, + volume_name="shared", + ) + assert dc == "EU-RO-1" diff --git a/tests/test_apps/test_queue_worker.py b/tests/test_apps/test_queue_worker.py new file mode 100644 index 00000000..8ac14f8e --- /dev/null +++ b/tests/test_apps/test_queue_worker.py @@ -0,0 +1,282 @@ +"""unit tests for the generic queue worker runtime.""" + +import inspect +import json +from unittest.mock import MagicMock, patch + +import pytest + +from runpod.runtimes.queue import worker + + +class TestModeDetection: + def test_resource_name_flash_env(self, monkeypatch): + monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat") + assert worker._resource_name() == "chat" + + def test_resource_name_runpod_env(self, monkeypatch): + monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False) + monkeypatch.setenv("RUNPOD_RESOURCE_NAME", "embed") + assert worker._resource_name() == "embed" + + def test_resource_name_empty(self, monkeypatch): + monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False) + monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False) + assert worker._resource_name() == "" + + def test_is_deployed_requires_name_and_manifest(self, monkeypatch, tmp_path): + monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False) + monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False) + monkeypatch.setattr(worker, "APP_DIR", str(tmp_path)) + assert not worker._is_deployed() + + monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat") + assert not worker._is_deployed() + + (tmp_path / worker.MANIFEST_NAME).write_text("{}") + assert worker._is_deployed() + + +class TestDeployedHandle: + def _write_manifest(self, tmp_path, resources): + (tmp_path / worker.MANIFEST_NAME).write_text( + json.dumps({"resources": resources}) + ) + + def test_load_deployed_handle(self, monkeypatch, tmp_path): + module_src = ( + "import runpod\n" + "app = runpod.App('t')\n" + "@app.queue(name='chat', gpu='4090')\n" + "def chat(prompt: str):\n" + " return {'echo': prompt}\n" + ) + (tmp_path / "user_mod_a.py").write_text(module_src) + self._write_manifest( + tmp_path, [{"name": "chat", "module": "user_mod_a"}] + ) + monkeypatch.setattr(worker, "APP_DIR", str(tmp_path)) + monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat") + + handle = worker._load_deployed_handle() + assert handle.spec.name == "chat" + + def test_load_deployed_handle_missing_resource(self, monkeypatch, tmp_path): + self._write_manifest(tmp_path, [{"name": "other", "module": "x"}]) + monkeypatch.setattr(worker, "APP_DIR", str(tmp_path)) + monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat") + + with pytest.raises(RuntimeError, match="not in manifest"): + worker._load_deployed_handle() + + def test_load_deployed_handle_missing_handle(self, monkeypatch, tmp_path): + (tmp_path / "user_mod_b.py").write_text("x = 1\n") + self._write_manifest( + tmp_path, [{"name": "chat", "module": "user_mod_b"}] + ) + monkeypatch.setattr(worker, "APP_DIR", str(tmp_path)) + monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat") + + with pytest.raises(RuntimeError, match="no @app.queue"): + worker._load_deployed_handle() + + +class TestDeployedHandler: + def _handle(self, fn): + handle = MagicMock() + handle._fn = fn + return handle + + async def test_sync_function(self): + handler = worker._make_deployed_handler( + self._handle(lambda a, b: {"sum": a + b}) + ) + result = await handler({"input": {"a": 1, "b": 2}}) + assert result == {"sum": 3} + + async def test_async_function(self): + async def fn(x): + return x * 2 + + handler = worker._make_deployed_handler(self._handle(fn)) + assert await handler({"input": {"x": 5}}) == 10 + + async def test_empty_marker_stripped(self): + handler = worker._make_deployed_handler( + self._handle(lambda: "ok") + ) + assert await handler({"input": {"__empty": True}}) == "ok" + + async def test_no_input(self): + handler = worker._make_deployed_handler( + self._handle(lambda: "ok") + ) + assert await handler({}) == "ok" + + async def test_sync_generator(self): + def fn(n): + for i in range(n): + yield i + + handler = worker._make_deployed_handler(self._handle(fn)) + assert inspect.isgeneratorfunction(handler) + assert list(handler({"input": {"n": 3}})) == [0, 1, 2] + + async def test_async_generator(self): + async def fn(n): + for i in range(n): + yield i + + handler = worker._make_deployed_handler(self._handle(fn)) + assert inspect.isasyncgenfunction(handler) + chunks = [c async for c in handler({"input": {"n": 2}})] + assert chunks == [0, 1] + + +class TestInitHook: + def test_no_init(self): + handle = MagicMock(spec=[]) + worker._run_init(handle) # no _init_fn attr: no-op + + def test_sync_init(self): + calls = [] + handle = MagicMock() + handle._init_fn = lambda: calls.append(1) + worker._run_init(handle) + assert calls == [1] + + def test_async_init(self): + calls = [] + + async def init(): + calls.append(1) + + handle = MagicMock() + handle._init_fn = init + worker._run_init(handle) + assert calls == [1] + + +class TestConcurrency: + def test_default(self, monkeypatch): + monkeypatch.delenv("RUNPOD_MAX_CONCURRENCY", raising=False) + assert worker._max_concurrency() == 1 + + def test_env_value(self, monkeypatch): + monkeypatch.setenv("RUNPOD_MAX_CONCURRENCY", "8") + assert worker._max_concurrency() == 8 + + def test_invalid_value(self, monkeypatch): + monkeypatch.setenv("RUNPOD_MAX_CONCURRENCY", "banana") + assert worker._max_concurrency() == 1 + + def test_floor_of_one(self, monkeypatch): + monkeypatch.setenv("RUNPOD_MAX_CONCURRENCY", "0") + assert worker._max_concurrency() == 1 + + def test_worker_config_single(self, monkeypatch): + monkeypatch.delenv("RUNPOD_MAX_CONCURRENCY", raising=False) + config = worker._worker_config(lambda job: job) + assert "concurrency_modifier" not in config + + def test_worker_config_concurrent(self, monkeypatch): + monkeypatch.setenv("RUNPOD_MAX_CONCURRENCY", "4") + config = worker._worker_config(lambda job: job) + assert config["concurrency_modifier"](1) == 4 + + def test_worker_config_plain_handler(self, monkeypatch): + monkeypatch.delenv("RUNPOD_MAX_CONCURRENCY", raising=False) + config = worker._worker_config(lambda job: job) + assert "return_aggregate_stream" not in config + + def test_worker_config_generator_handler(self, monkeypatch): + monkeypatch.delenv("RUNPOD_MAX_CONCURRENCY", raising=False) + + def gen_handler(job): + yield job + + config = worker._worker_config(gen_handler) + assert config["return_aggregate_stream"] is True + + +class TestMain: + def test_live_mode(self, monkeypatch): + monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False) + monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False) + with patch("runpod.serverless.start") as start: + worker.main() + config = start.call_args[0][0] + assert config["handler"] is worker._live_handler + + def test_deployed_mode(self, monkeypatch, tmp_path): + module_src = ( + "import runpod\n" + "app = runpod.App('t')\n" + "@app.queue(name='greet', gpu='4090')\n" + "def greet(name: str):\n" + " return f'hi {name}'\n" + ) + (tmp_path / "user_mod_c.py").write_text(module_src) + (tmp_path / worker.MANIFEST_NAME).write_text( + json.dumps({"resources": [{"name": "greet", "module": "user_mod_c"}]}) + ) + monkeypatch.setattr(worker, "APP_DIR", str(tmp_path)) + monkeypatch.setenv("FLASH_RESOURCE_NAME", "greet") + + with patch("runpod.serverless.start") as start: + worker.main() + handler = start.call_args[0][0]["handler"] + assert inspect.iscoroutinefunction(handler) + + async def test_live_handler_plain_function(self): + def fn(x): + return {"ok": x} + + with ( + patch( + "runpod.runtimes.executor.resolve_request", + return_value=((fn, [1], {}), None), + ), + patch( + "runpod.runtimes.executor.execute_request", + return_value={"success": True, "json_result": {"ok": 1}}, + ) as execute, + ): + chunks = [c async for c in worker._live_handler({"input": {"foo": 1}})] + assert chunks == [{"success": True, "json_result": {"ok": 1}}] + execute.assert_called_once_with({"foo": 1}) + + async def test_live_handler_resolve_error(self): + with patch( + "runpod.runtimes.executor.resolve_request", + return_value=(None, {"success": False, "error": "boom"}), + ): + chunks = [c async for c in worker._live_handler({"input": {}})] + assert chunks == [{"success": False, "error": "boom"}] + + async def test_live_handler_streams_generator(self): + def fn(n): + for i in range(n): + yield i + + with patch( + "runpod.runtimes.executor.resolve_request", + return_value=((fn, [], {"n": 2}), None), + ): + chunks = [c async for c in worker._live_handler({"input": {}})] + assert all(c["__stream__"] for c in chunks) + assert all(c["success"] for c in chunks) + assert len(chunks) == 2 + + async def test_live_handler_streams_async_generator(self): + async def fn(n): + for i in range(n): + yield i + + with patch( + "runpod.runtimes.executor.resolve_request", + return_value=((fn, [], {"n": 3}), None), + ): + chunks = [c async for c in worker._live_handler({"input": {}})] + assert len(chunks) == 3 + assert all(c["__stream__"] for c in chunks) diff --git a/tests/test_apps/test_registry.py b/tests/test_apps/test_registry.py new file mode 100644 index 00000000..f8ecfde6 --- /dev/null +++ b/tests/test_apps/test_registry.py @@ -0,0 +1,69 @@ +"""registry credential resolution.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from runpod.apps.registry import RegistryAuthError, resolve_registry_auth + + +def _api(creds=None): + api = AsyncMock() + api.list_registry_auths.return_value = creds or [] + return api + + +class TestResolveRegistryAuth: + def test_none_passthrough(self): + assert ( + asyncio.run(resolve_registry_auth(None, api=_api())) is None + ) + + def test_resolves_by_name(self): + api = _api([{"id": "cra-1", "name": "my-ghcr"}]) + assert ( + asyncio.run(resolve_registry_auth("my-ghcr", api=api)) == "cra-1" + ) + + def test_resolves_by_id(self): + api = _api([{"id": "cra-1", "name": "my-ghcr"}]) + assert ( + asyncio.run(resolve_registry_auth("cra-1", api=api)) == "cra-1" + ) + + def test_missing_lists_available(self): + api = _api([{"id": "cra-1", "name": "other"}]) + with pytest.raises( + RegistryAuthError, match="available: other" + ): + asyncio.run(resolve_registry_auth("ghost", api=api)) + + def test_duplicates_require_id(self): + api = _api( + [ + {"id": "cra-1", "name": "dup"}, + {"id": "cra-2", "name": "dup"}, + ] + ) + with pytest.raises(RegistryAuthError, match="reference by id"): + asyncio.run(resolve_registry_auth("dup", api=api)) + + +class TestSpecPlumbing: + def test_decorator_accepts_registry_auth(self): + from runpod.apps.app import App + + app = App("regtest") + + @app.queue( + name="q", + cpu=["cpu3c-1-2"], + image="ghcr.io/me/x:1", + registry_auth="my-ghcr", + ) + def q(): + pass + + assert q.spec.registry_auth == "my-ghcr" + assert q.spec.to_manifest()["registryAuth"] == "my-ghcr" diff --git a/tests/test_apps/test_retry.py b/tests/test_apps/test_retry.py new file mode 100644 index 00000000..a973cb3c --- /dev/null +++ b/tests/test_apps/test_retry.py @@ -0,0 +1,102 @@ +"""tests for transient-failure retry in the http layer.""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import patch + +import aiohttp +import pytest + +from runpod.apps.errors import EndpointNotFound +from runpod.apps.targets import ( + RETRY_ATTEMPTS, + RETRYABLE_STATUSES, + _post_json, +) + + +@pytest.fixture +def flaky_server(): + """local server whose first responses are scripted status codes.""" + state = {"script": [], "hits": 0} + + class Handler(BaseHTTPRequestHandler): + def _respond(self): + state["hits"] += 1 + if state["hits"] <= len(state["script"]): + status = state["script"][state["hits"] - 1] + self.send_response(status) + self.end_headers() + self.wfile.write(b"scripted") + return + body = json.dumps({"ok": True, "hits": state["hits"]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + do_GET = do_POST = _respond + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + server.state = state + server.url = f"http://127.0.0.1:{server.server_address[1]}/run" + yield server + server.shutdown() + + +async def test_retries_transient_5xx_then_succeeds(flaky_server): + flaky_server.state["script"] = [520, 502] + with patch("runpod.apps.targets.RETRY_BASE_DELAY", 0.01): + data = await _post_json(flaky_server.url, {"x": 1}, {}, timeout=10) + assert data["ok"] is True + assert data["hits"] == 3 + + +async def test_exhausted_retries_raise_last_error(flaky_server): + flaky_server.state["script"] = [503] * (RETRY_ATTEMPTS + 2) + with patch("runpod.apps.targets.RETRY_BASE_DELAY", 0.01): + with pytest.raises(aiohttp.ClientResponseError) as exc_info: + await _post_json(flaky_server.url, {"x": 1}, {}, timeout=10) + assert exc_info.value.status == 503 + assert flaky_server.state["hits"] == RETRY_ATTEMPTS + + +async def test_404_not_retried_maps_to_endpoint_not_found(flaky_server): + flaky_server.state["script"] = [404] + with pytest.raises(EndpointNotFound): + await _post_json( + flaky_server.url, + {"x": 1}, + {}, + timeout=10, + app_name="a", + resource_name="r", + ) + assert flaky_server.state["hits"] == 1 + + +async def test_client_4xx_not_retried(flaky_server): + flaky_server.state["script"] = [400] + with pytest.raises(aiohttp.ClientResponseError) as exc_info: + await _post_json(flaky_server.url, {"x": 1}, {}, timeout=10) + assert exc_info.value.status == 400 + assert flaky_server.state["hits"] == 1 + + +def test_429_is_retryable(): + assert 429 in RETRYABLE_STATUSES + + +async def test_connection_error_retried(unused_tcp_port): + # nothing listening: pure connection failures, all attempts consumed + url = f"http://127.0.0.1:{unused_tcp_port}/run" + with patch("runpod.apps.targets.RETRY_BASE_DELAY", 0.01): + with pytest.raises(aiohttp.ClientConnectionError): + await _post_json(url, {"x": 1}, {}, timeout=5) diff --git a/tests/test_apps/test_secret.py b/tests/test_apps/test_secret.py new file mode 100644 index 00000000..fe40d8ea --- /dev/null +++ b/tests/test_apps/test_secret.py @@ -0,0 +1,89 @@ +"""secret references, env rendering, and provision-time validation.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from runpod.apps.secret import ( + Secret, + SecretError, + render_env, + secret_names, + validate_secrets, +) + + +class TestSecretRef: + def test_reference_syntax(self): + assert Secret("hf-token").reference == "{{ RUNPOD_SECRET_hf-token }}" + + def test_invalid_names_raise(self): + with pytest.raises(SecretError): + Secret("") + with pytest.raises(SecretError): + Secret("has spaces") + with pytest.raises(SecretError): + Secret("no}}braces") + + def test_valid_names(self): + for name in ("hf-token", "MY_KEY", "a.b-c_d", "x1"): + assert Secret(name).name == name + + +class TestRenderEnv: + def test_mixed_env(self): + rendered = render_env( + {"HF_TOKEN": Secret("hf-token"), "MODE": "prod", "N": 3} + ) + assert rendered == { + "HF_TOKEN": "{{ RUNPOD_SECRET_hf-token }}", + "MODE": "prod", + "N": "3", + } + + def test_empty(self): + assert render_env(None) == {} + assert render_env({}) == {} + + def test_secret_names_extraction(self): + names = secret_names( + {"A": Secret("one"), "B": "plain", "C": Secret("two")} + ) + assert sorted(names) == ["one", "two"] + assert secret_names(None) == [] + + +class TestValidateSecrets: + def test_existing_pass(self): + api = AsyncMock() + api.list_secrets.return_value = [{"name": "hf-token"}] + asyncio.run(validate_secrets(["hf-token"], api=api)) + + def test_missing_raises_with_hint(self): + api = AsyncMock() + api.list_secrets.return_value = [{"name": "other"}] + with pytest.raises(SecretError, match="rp secret add"): + asyncio.run(validate_secrets(["hf-token"], api=api)) + + def test_no_references_no_api_call(self): + api = AsyncMock() + asyncio.run(validate_secrets([], api=api)) + api.list_secrets.assert_not_awaited() + + +class TestSpecManifest: + def test_manifest_renders_secret_references(self): + from runpod.apps.spec import ResourceKind, ResourceSpec + + spec = ResourceSpec( + kind=ResourceKind.QUEUE, + name="q", + cpu=["cpu3c-1-2"], + env={"TOKEN": Secret("tok"), "MODE": "x"}, + ) + manifest = spec.to_manifest() + assert manifest["env"] == { + "TOKEN": "{{ RUNPOD_SECRET_tok }}", + "MODE": "x", + } diff --git a/tests/test_apps/test_shim.py b/tests/test_apps/test_shim.py new file mode 100644 index 00000000..5b55a21b --- /dev/null +++ b/tests/test_apps/test_shim.py @@ -0,0 +1,56 @@ +"""tests for the shell launcher shim.""" + +import base64 +import subprocess + +import pytest + +from runpod.apps.shim import shell_launcher + + +def test_no_inner_single_quotes(): + # the host lexes dockerArgs; an inner single quote would break the + # outer quoting + cmd = shell_launcher("VAR", "/dest.py") + assert cmd.startswith("sh -c '") + assert cmd.endswith("'") + inner = cmd[len("sh -c '") : -1] + assert "'" not in inner + + +def test_posix_sh_not_bash(): + cmd = shell_launcher("VAR", "/dest.py") + assert cmd.startswith("sh -c "), "must not require bash" + + +def test_decodes_and_execs_locally(tmp_path): + """run the inner script under sh with a payload that writes a marker.""" + marker = tmp_path / "marker" + payload = f"open({str(marker)!r}, 'w').write('ran')" + b64 = base64.b64encode(payload.encode()).decode() + + dest = tmp_path / "injected.py" + cmd = shell_launcher("TESTVAR", str(dest)) + inner = cmd[len("sh -c '") : -1] + + result = subprocess.run( + ["sh", "-c", inner], + env={"TESTVAR": b64, "PATH": "/usr/bin:/bin:/usr/local/bin"}, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert marker.read_text() == "ran" + + +def test_probes_beyond_path(): + cmd = shell_launcher("VAR", "/dest.py") + assert "/opt/conda/bin/python" in cmd + assert "/opt/venv/bin/python" in cmd + + +def test_pythonless_image_fails_loudly(): + cmd = shell_launcher("VAR", "/dest.py") + assert "FATAL" in cmd + assert "must include python3" in cmd diff --git a/tests/test_apps/test_targets.py b/tests/test_apps/test_targets.py new file mode 100644 index 00000000..b660c83e --- /dev/null +++ b/tests/test_apps/test_targets.py @@ -0,0 +1,437 @@ +"""unit tests for invocation targets and their helpers.""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import AsyncMock, patch + +import pytest + +from runpod.apps.errors import RemoteExecutionError +from runpod.apps.spec import ResourceKind, ResourceSpec +from runpod.apps.targets import ( + SENTINEL_ID, + LiveTarget, + SentinelTarget, + _api_key, + _headers, + _lb_domain, + _wait_terminal, + args_to_input, + unwrap_job_output, +) + +# http.server's per-request sockets are collected lazily; the unraisable +# checker flags them as ResourceWarnings non-deterministically +pytestmark = pytest.mark.filterwarnings( + "ignore::pytest.PytestUnraisableExceptionWarning" +) + + +class TestApiKey: + def test_env_var(self, monkeypatch): + monkeypatch.setenv("RUNPOD_API_KEY", "sk-env") + assert _api_key() == "sk-env" + + def test_module_fallback(self, monkeypatch): + import runpod + + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + monkeypatch.setattr(runpod, "api_key", "sk-module") + assert _api_key() == "sk-module" + + def test_missing_raises(self, monkeypatch): + import runpod + + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + monkeypatch.setattr(runpod, "api_key", None) + with pytest.raises(RuntimeError, match="rp login"): + _api_key() + + +class TestUrlHelpers: + def test_lb_domain(self, monkeypatch): + import runpod + + monkeypatch.setattr( + runpod, "endpoint_url_base", "https://api.runpod.ai/v2" + ) + assert _lb_domain() == "api.runpod.ai" + + def test_headers(self, monkeypatch): + monkeypatch.setenv("RUNPOD_API_KEY", "sk-test") + headers = _headers({"X-Extra": "1"}) + assert headers["Authorization"] == "Bearer sk-test" + assert headers["X-Extra"] == "1" + + +class TestArgsToInput: + def test_positional_mapping(self): + def fn(a, b, c=3): + pass + + assert args_to_input(fn, (1, 2), {}) == {"a": 1, "b": 2} + + def test_kwargs_merge(self): + def fn(a, b): + pass + + assert args_to_input(fn, (1,), {"b": 2}) == {"a": 1, "b": 2} + + def test_too_many_positional(self): + def fn(a): + pass + + with pytest.raises(TypeError, match="positional"): + args_to_input(fn, (1, 2), {}) + + def test_empty_marker(self): + def fn(): + pass + + assert args_to_input(fn, (), {}) == {"__empty": True} + + +class TestUnwrapJobOutput: + def test_completed(self): + assert unwrap_job_output( + {"status": "COMPLETED", "output": {"x": 1}} + ) == {"x": 1} + + def test_failed_status(self): + with pytest.raises(RemoteExecutionError, match="boom"): + unwrap_job_output({"status": "FAILED", "error": "boom"}) + + def test_error_in_output(self): + with pytest.raises(RemoteExecutionError, match="oops"): + unwrap_job_output( + {"status": "COMPLETED", "output": {"error": "oops"}} + ) + + def test_missing_output_returns_data(self): + data = {"status": "COMPLETED", "value": 7} + assert unwrap_job_output(data) == data + + +class TestWaitTerminal: + async def test_immediate_terminal(self): + data = {"id": "j1", "status": "COMPLETED", "output": 1} + result = await _wait_terminal("http://x", data, {}, timeout=5) + assert result is data + + async def test_polls_to_completion(self): + polls = [ + {"id": "j1", "status": "IN_PROGRESS"}, + {"id": "j1", "status": "COMPLETED", "output": 2}, + ] + seen = [] + with ( + patch( + "runpod.apps.targets._get_json", + AsyncMock(side_effect=polls), + ), + patch("asyncio.sleep", AsyncMock()), + ): + result = await _wait_terminal( + "http://x", + {"id": "j1", "status": "IN_QUEUE"}, + {}, + timeout=30, + on_status=seen.append, + ) + assert result["status"] == "COMPLETED" + assert len(seen) == 3 + + async def test_no_job_id_returns_data(self): + data = {"status": "IN_QUEUE"} + result = await _wait_terminal("http://x", data, {}, timeout=5) + assert result is data + + async def test_timeout(self): + with ( + patch( + "runpod.apps.targets._get_json", + AsyncMock(return_value={"id": "j1", "status": "IN_PROGRESS"}), + ), + patch("asyncio.sleep", AsyncMock()), + patch("time.monotonic", side_effect=[0, 100, 200]), + ): + with pytest.raises(TimeoutError, match="did not complete"): + await _wait_terminal( + "http://x", + {"id": "j1", "status": "IN_QUEUE"}, + {}, + timeout=10, + ) + + +@pytest.fixture +def local_endpoint(monkeypatch): + """local http server standing in for the serverless data plane.""" + state = {"requests": [], "responses": {}} + + class Handler(BaseHTTPRequestHandler): + def _respond(self): + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else b"" + state["requests"].append( + { + "path": self.path, + "method": self.command, + "headers": dict(self.headers), + "body": json.loads(body) if body else None, + } + ) + reply = state["responses"].get( + self.path, {"status": "COMPLETED", "output": {"ok": True}} + ) + payload = json.dumps(reply).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + do_GET = do_POST = do_PUT = _respond + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + import runpod + + monkeypatch.setenv("RUNPOD_API_KEY", "sk-test") + monkeypatch.setattr( + runpod, + "endpoint_url_base", + f"http://127.0.0.1:{server.server_address[1]}", + ) + server.state = state + yield server + server.shutdown() + + +class TestSentinelTarget: + async def test_invoke_routes_through_sentinel(self, local_endpoint): + target = SentinelTarget("demo", "default", "chat") + result = await target.invoke({"input": {"prompt": "hi"}}, timeout=10) + assert result == {"ok": True} + + request = local_endpoint.state["requests"][0] + assert request["path"] == f"/{SENTINEL_ID}/runsync" + assert request["headers"]["X-Flash-App"] == "demo" + assert request["headers"]["X-Flash-Environment"] == "default" + assert request["headers"]["X-Flash-Endpoint"] == "chat" + + async def test_submit_and_wait(self, local_endpoint): + local_endpoint.state["responses"][f"/{SENTINEL_ID}/run"] = { + "id": "j1", + "status": "IN_QUEUE", + } + local_endpoint.state["responses"][f"/{SENTINEL_ID}/status/j1"] = { + "id": "j1", + "status": "COMPLETED", + "output": 42, + } + target = SentinelTarget("demo", "default", "chat") + job = await target.submit({"input": {}}) + assert job["id"] == "j1" + assert await target.wait(job, timeout=10) == 42 + + async def test_job_operations(self, local_endpoint): + local_endpoint.state["responses"][f"/{SENTINEL_ID}/status/j1"] = { + "id": "j1", + "status": "IN_PROGRESS", + } + local_endpoint.state["responses"][f"/{SENTINEL_ID}/cancel/j1"] = { + "id": "j1", + "status": "CANCELLED", + } + local_endpoint.state["responses"][f"/{SENTINEL_ID}/retry/j1"] = { + "id": "j1", + "status": "IN_QUEUE", + } + target = SentinelTarget("demo", "production", "chat") + + assert (await target.job_status("j1"))["status"] == "IN_PROGRESS" + assert (await target.cancel_job("j1"))["status"] == "CANCELLED" + assert (await target.retry_job("j1"))["status"] == "IN_QUEUE" + + requests = local_endpoint.state["requests"] + assert [request["path"] for request in requests] == [ + f"/{SENTINEL_ID}/status/j1", + f"/{SENTINEL_ID}/cancel/j1", + f"/{SENTINEL_ID}/retry/j1", + ] + assert requests[0]["headers"]["X-Flash-Environment"] == "production" + + async def test_stream_job(self, local_endpoint): + local_endpoint.state["responses"][f"/{SENTINEL_ID}/stream/j1"] = { + "status": "COMPLETED", + "stream": [{"output": "a"}, {"output": "b"}], + } + target = SentinelTarget("demo", "default", "chat") + + chunks = [c async for c in target.stream_job("j1", timeout=10)] + assert chunks == ["a", "b"] + request = local_endpoint.state["requests"][0] + assert request["path"] == f"/{SENTINEL_ID}/stream/j1" + assert request["headers"]["X-Flash-App"] == "demo" + + async def test_stream_job_failed(self, local_endpoint): + local_endpoint.state["responses"][f"/{SENTINEL_ID}/stream/j1"] = { + "status": "FAILED", + "error": "boom", + } + target = SentinelTarget("demo", "default", "chat") + + with pytest.raises(RemoteExecutionError, match="boom"): + async for _ in target.stream_job("j1", timeout=10): + pass + + def test_payload_is_plain_kwargs(self): + target = SentinelTarget("demo", "default", "chat") + + def fn(prompt): + pass + + payload = target.build_payload( + fn, ResourceSpec(kind=ResourceKind.QUEUE, name="chat"), ("hi",), {} + ) + assert payload == {"input": {"prompt": "hi"}} + + +class TestLiveTarget: + def _spec(self): + return ResourceSpec(kind=ResourceKind.QUEUE, name="chat") + + def test_payload_carries_source(self): + target = LiveTarget("ep123", "chat") + + def fn(prompt): + return prompt + + payload = target.build_payload(fn, self._spec(), ("hi",), {}) + body = payload["input"] + assert body["function_name"] == "fn" + assert "def fn(" in body["function_code"] + + def test_payload_args_are_plain_json(self): + target = LiveTarget("ep123", "chat") + + def fn(prompt, n): + return prompt + + payload = target.build_payload(fn, self._spec(), ("hi",), {"n": 2}) + body = payload["input"] + assert body["args"] == ["hi"] + assert body["kwargs"] == {"n": 2} + assert body["serialization_format"] == "json" + + def test_payload_rejects_non_json_args(self): + target = LiveTarget("ep123", "chat") + + def fn(x): + return x + + with pytest.raises(TypeError, match="json-serializable"): + target.build_payload(fn, self._spec(), (object(),), {}) + + def test_unwrap_success_response(self): + target = LiveTarget("ep123", "chat") + output = {"success": True, "result": None, "json_result": {"x": 1}} + assert target.unwrap( + {"status": "COMPLETED", "output": output} + ) == {"x": 1} + + def test_unwrap_failure_raises(self): + target = LiveTarget("ep123", "chat") + output = {"success": False, "error": "worker exploded"} + with pytest.raises(RemoteExecutionError, match="worker exploded"): + target.unwrap({"status": "COMPLETED", "output": output}) + + def test_unwrap_passthrough(self): + target = LiveTarget("ep123", "chat") + assert target.unwrap( + {"status": "COMPLETED", "output": {"plain": 1}} + ) == {"plain": 1} + + def test_unwrap_aggregated_plain_function(self): + # live handlers aggregate; a plain function is one unmarked envelope + target = LiveTarget("ep123", "chat") + output = [{"success": True, "json_result": {"x": 1}}] + assert target.unwrap( + {"status": "COMPLETED", "output": output} + ) == {"x": 1} + + def test_unwrap_aggregated_generator(self): + target = LiveTarget("ep123", "chat") + output = [ + {"success": True, "__stream__": True, "json_result": "a"}, + {"success": True, "__stream__": True, "json_result": "b"}, + ] + assert target.unwrap( + {"status": "COMPLETED", "output": output} + ) == ["a", "b"] + + async def test_stream_job_unwraps_chunks(self, local_endpoint): + local_endpoint.state["responses"]["/ep123/stream/j1"] = { + "status": "COMPLETED", + "stream": [ + {"output": {"success": True, "__stream__": True, "json_result": "a"}}, + {"output": {"success": True, "__stream__": True, "json_result": "b"}}, + ], + } + target = LiveTarget("ep123", "chat") + + chunks = [c async for c in target.stream_job("j1", timeout=10)] + assert chunks == ["a", "b"] + + async def test_invoke(self, local_endpoint): + target = LiveTarget("ep123", "chat") + data = await target.invoke({"input": {"x": 1}}, timeout=10) + assert data == {"ok": True} + request = local_endpoint.state["requests"][0] + assert request["path"] == "/ep123/runsync" + + async def test_job_operations(self, local_endpoint): + local_endpoint.state["responses"]["/ep123/status/j1"] = { + "id": "j1", + "status": "IN_PROGRESS", + } + local_endpoint.state["responses"]["/ep123/cancel/j1"] = { + "id": "j1", + "status": "CANCELLED", + } + local_endpoint.state["responses"]["/ep123/retry/j1"] = { + "id": "j1", + "status": "IN_QUEUE", + } + target = LiveTarget("ep123", "chat") + + assert (await target.job_status("j1"))["status"] == "IN_PROGRESS" + assert (await target.cancel_job("j1"))["status"] == "CANCELLED" + assert (await target.retry_job("j1"))["status"] == "IN_QUEUE" + + async def test_sync_source_skips_unchanged(self, monkeypatch): + target = LiveTarget("ep123", "chat") + + def backing(): + return 1 + + target.attach_source(backing, "chat", self._spec()) + post = AsyncMock(return_value={}) + with patch("runpod.apps.targets._post_json", post): + await target._sync_source(timeout=10) + await target._sync_source(timeout=10) + assert post.await_count == 1 + + async def test_sync_source_noop_without_attachment(self): + target = LiveTarget("ep123", "chat") + post = AsyncMock() + with patch("runpod.apps.targets._post_json", post): + await target._sync_source(timeout=10) + post.assert_not_awaited() diff --git a/tests/test_apps/test_task_runner_http.py b/tests/test_apps/test_task_runner_http.py new file mode 100644 index 00000000..645b2d0f --- /dev/null +++ b/tests/test_apps/test_task_runner_http.py @@ -0,0 +1,238 @@ +"""integration tests for the task runner http server. + +boots the real ThreadingHTTPServer on a random port and exercises the +protocol over actual sockets. engine behavior (dependency install, +serialization, stdout capture) is covered in test_executor.py. +""" + +import base64 +import json +import threading +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer + +import cloudpickle +import pytest + +from runpod.runtimes.task import runner as task_runner +from runpod.runtimes.task.runner import Handler + +# http.server's per-request sockets are collected lazily; the unraisable +# checker flags them as ResourceWarnings non-deterministically +pytestmark = pytest.mark.filterwarnings( + "ignore::pytest.PytestUnraisableExceptionWarning" +) + +TOKEN = "test-token" + + +@pytest.fixture() +def server(monkeypatch): + monkeypatch.setattr(task_runner, "TOKEN", TOKEN) + monkeypatch.setattr( + task_runner, "_job_state", {"status": "NONE", "response": None} + ) + httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + httpd.daemon_threads = True + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{httpd.server_port}" + httpd.shutdown() + thread.join(timeout=5) + httpd.server_close() + + +def _request(url, method="GET", body=None, token=TOKEN): + req = urllib.request.Request(url, method=method) + if token: + req.add_header("Authorization", f"Bearer {token}") + data = None + if body is not None: + data = json.dumps(body).encode() + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, data=data, timeout=10) as resp: + return resp.status, json.loads(resp.read()) + + +def _b64(value): + return base64.b64encode(cloudpickle.dumps(value)).decode() + + +def _unb64(value): + return cloudpickle.loads(base64.b64decode(value)) + + +def test_ping_unauthenticated(server): + status, body = _request(f"{server}/ping", token=None) + assert status == 200 + assert body == {"ready": True} + + +def test_execute_requires_auth(server): + with pytest.raises(urllib.error.HTTPError) as exc_info: + _request(f"{server}/execute", method="POST", body={}, token="wrong") + assert exc_info.value.code == 401 + + +def test_execute_roundtrip(server): + status, body = _request( + f"{server}/execute", + method="POST", + body={ + "function_name": "mul", + "function_code": "def mul(a, b):\n return a * b", + "args": [_b64(6), _b64(7)], + "kwargs": {}, + }, + ) + assert status == 200 + assert body["success"] is True + assert _unb64(body["result"]) == 42 + + +def test_submit_and_result(server): + status, body = _request( + f"{server}/submit", + method="POST", + body={ + "function_name": "quick", + "function_code": "def quick():\n return 'done'", + "args": [], + "kwargs": {}, + "serialization_format": "json", + }, + ) + assert status == 200 + assert body == {"status": "RUNNING"} + + import time + + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + status, body = _request(f"{server}/result") + if body["status"] == "DONE": + break + time.sleep(0.1) + + assert body["status"] == "DONE" + assert body["response"]["success"] is True + assert body["response"]["json_result"] == "done" + + +def test_unknown_path_404(server): + with pytest.raises(urllib.error.HTTPError) as exc_info: + _request(f"{server}/nope") + assert exc_info.value.code == 404 + + +def test_single_file_source_is_self_contained(): + """the shipped runner must run without the runpod package installed.""" + import subprocess + import sys + + from runpod.apps.tasks import _runner_source + + source = _runner_source() + assert "from runpod.runtimes.executor import" in source + # compile in an empty namespace with runpod imports failing + probe = ( + "import sys\n" + "sys.modules['runpod'] = None\n" # forces the ImportError branch + "sys.argv = ['runner']\n" + + source.replace('if __name__ == "__main__":', "if False:") + ) + result = subprocess.run( + [sys.executable, "-c", probe], capture_output=True, text=True + ) + assert result.returncode == 0, result.stderr + + +class TestWatchdog: + def test_running_job_never_killed(self): + from runpod.runtimes.task.runner import _should_self_terminate + + assert not _should_self_terminate("RUNNING", 0, 10_000, 600) + + def test_abandoned_before_submit(self): + from runpod.runtimes.task.runner import _should_self_terminate + + assert _should_self_terminate("NONE", 0, 601, 600) + + def test_uncollected_result(self): + from runpod.runtimes.task.runner import _should_self_terminate + + assert _should_self_terminate("DONE", 0, 601, 600) + + def test_live_client_keeps_pod(self): + from runpod.runtimes.task.runner import _should_self_terminate + + # polls every ~2s: last contact is always recent + assert not _should_self_terminate("DONE", 599, 600, 600) + + def test_no_contact_recorded_yet(self): + from runpod.runtimes.task.runner import _should_self_terminate + + assert not _should_self_terminate("NONE", None, 10_000, 600) + + def test_authed_requests_touch_contact(self, server, monkeypatch): + monkeypatch.setattr(task_runner, "_last_contact", {"ts": None}) + _request(f"{server}/result") + assert task_runner._last_contact["ts"] is not None + + +class TestSelfTermination: + def test_terminate_self_calls_graphql_then_exits(self, monkeypatch): + import contextlib + import io as _io + + calls = [] + + @contextlib.contextmanager + def _response(): + yield _io.BytesIO(b"{}") + + def fake_urlopen(req, timeout=None): + calls.append((req.full_url, req.data)) + return _response() + + exits = [] + monkeypatch.setenv("RUNPOD_POD_ID", "pod-1") + monkeypatch.setenv("RUNPOD_API_KEY", "pod-scoped-key") + monkeypatch.setattr( + "urllib.request.urlopen", fake_urlopen + ) + monkeypatch.setattr( + task_runner.os, "_exit", lambda code: exits.append(code) + ) + task_runner._terminate_self() + + assert exits == [0] + url, body = calls[0] + assert url.endswith("/graphql") + assert b"podTerminate" in body + assert b"pod-1" in body + + def test_terminate_self_exits_without_credentials(self, monkeypatch): + exits = [] + monkeypatch.delenv("RUNPOD_POD_ID", raising=False) + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + monkeypatch.setattr( + task_runner.os, "_exit", lambda code: exits.append(code) + ) + task_runner._terminate_self() + assert exits == [0] + + def test_terminate_self_survives_api_failure(self, monkeypatch): + def fail(req, timeout=None): + raise OSError("network down") + + exits = [] + monkeypatch.setenv("RUNPOD_POD_ID", "pod-1") + monkeypatch.setenv("RUNPOD_API_KEY", "k") + monkeypatch.setattr("urllib.request.urlopen", fail) + monkeypatch.setattr( + task_runner.os, "_exit", lambda code: exits.append(code) + ) + task_runner._terminate_self() + assert exits == [0] diff --git a/tests/test_apps/test_tasks.py b/tests/test_apps/test_tasks.py new file mode 100644 index 00000000..91656f10 --- /dev/null +++ b/tests/test_apps/test_tasks.py @@ -0,0 +1,520 @@ +"""tests for the task runner and pod task transport.""" + +# the sync bridge finalizes coroutines on a background loop thread; +# AsyncMock coroutines observed there trip the unraisable checker +# non-deterministically under full-suite gc timing +import pytest as _pytest + +pytestmark = _pytest.mark.filterwarnings( + "ignore::pytest.PytestUnraisableExceptionWarning" +) + +import base64 +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import cloudpickle +import pytest + +import runpod +from runpod.apps import App +from runpod.apps.app import _clear_registry +from runpod.apps.errors import RemoteExecutionError +from runpod.apps.spec import ResourceKind, ResourceSpec +from runpod.runtimes.executor import execute_request +from runpod.apps.tasks import _pod_input, unwrap_task_response +from runpod.apps.targets import PodTarget + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +def _b64(value): + return base64.b64encode(cloudpickle.dumps(value)).decode("utf-8") + + +def _unb64(value): + return cloudpickle.loads(base64.b64decode(value)) + + +class TestExecuteRequest: + def test_simple_function(self): + response = execute_request( + { + "function_name": "add", + "function_code": "def add(a, b):\n return a + b", + "args": [_b64(2), _b64(3)], + "kwargs": {}, + } + ) + assert response["success"] is True + assert _unb64(response["result"]) == 5 + + def test_json_format(self): + response = execute_request( + { + "function_name": "add", + "function_code": "def add(a, b):\n return a + b", + "args": [2, 3], + "kwargs": {}, + "serialization_format": "json", + } + ) + assert response["success"] is True + assert response["json_result"] == 5 + + def test_async_function(self): + response = execute_request( + { + "function_name": "hello", + "function_code": ( + "async def hello(name):\n return f'hi {name}'" + ), + "args": [], + "kwargs": {"name": _b64("world")}, + } + ) + assert response["success"] is True + assert _unb64(response["result"]) == "hi world" + + def test_stdout_captured(self): + response = execute_request( + { + "function_name": "loud", + "function_code": "def loud():\n print('noise')\n return 1", + "args": [], + "kwargs": {}, + "serialization_format": "json", + } + ) + assert response["success"] is True + assert "noise" in response["stdout"] + + def test_exception_reported(self): + response = execute_request( + { + "function_name": "boom", + "function_code": "def boom():\n raise ValueError('bad')", + "args": [], + "kwargs": {}, + } + ) + assert response["success"] is False + assert "ValueError" in response["error"] + + def test_missing_function(self): + response = execute_request( + { + "function_name": "nope", + "function_code": "x = 1", + "args": [], + "kwargs": {}, + } + ) + assert response["success"] is False + assert "not found" in response["error"] + + +class TestPodInput: + def _spec(self, **kw): + defaults = dict(kind=ResourceKind.TASK, name="t") + defaults.update(kw) + return ResourceSpec(**defaults) + + def test_cpu_pod_input(self): + pod = _pod_input(self._spec(cpu=["cpu3c-1-2"]), "tok", "t") + assert pod["instanceIds"] == ["cpu3c-1-2"] + from runpod.apps.images import local_python_version + + assert ( + pod["imageName"] + == f"runpod/task:py{local_python_version()}-latest" + ) + assert pod["ports"] == "8080/http" + assert pod["terminateAfter"] + env = {e["key"]: e["value"] for e in pod["env"]} + assert env["RUNPOD_TASK_TOKEN"] == "tok" + # runtime images have the runner baked in; no env payload + assert "RUNPOD_TASK_RUNNER_B64" not in env + assert "dockerArgs" not in pod + + def test_gpu_pod_input(self): + pod = _pod_input( + self._spec(gpu=["NVIDIA GeForce RTX 4090"], gpu_count=2), "tok", "t" + ) + assert pod["gpuTypeIdList"] == ["NVIDIA GeForce RTX 4090"] + assert pod["gpuCount"] == 2 + # gpu runtime image matched to the local python version + from runpod.apps.images import local_python_version + + assert ( + pod["imageName"] + == f"runpod/task-gpu:py{local_python_version()}-latest" + ) + + def test_custom_image_bootstraps_runner(self): + pod = _pod_input( + self._spec(cpu=["cpu3c-1-2"], image="my/image:1", volume="vol-1"), + "tok", + "t", + ) + assert pod["imageName"] == "my/image:1" + # volumes resolve at TaskExecution.start (placement solve), + # not in the static pod input + assert "networkVolumeId" not in pod + # custom images get the env-injection fallback + env = {e["key"]: e["value"] for e in pod["env"]} + assert "RUNPOD_TASK_RUNNER_B64" in env + decoded = base64.b64decode(env["RUNPOD_TASK_RUNNER_B64"]).decode() + assert "def execute_request" in decoded + assert "task_runner.py" in pod["dockerArgs"] + + def test_datacenter_forwarded(self): + pod = _pod_input( + self._spec(cpu=["cpu3c-1-2"], datacenter=["EU-RO-1"]), "tok", "t" + ) + assert pod["dataCenterIds"] == ["EU-RO-1"] + + +class TestUnwrapTaskResponse: + def test_success_cloudpickle(self): + assert unwrap_task_response({"success": True, "result": _b64(42)}) == 42 + + def test_success_json(self): + assert ( + unwrap_task_response({"success": True, "json_result": {"a": 1}}) + == {"a": 1} + ) + + def test_failure_raises(self): + with pytest.raises(RemoteExecutionError, match="boom"): + unwrap_task_response({"success": False, "error": "boom"}) + + +class TestPodTargetPayload: + def test_payload_carries_source_and_args(self): + app = App("a") + + @app.task(name="t", cpu="cpu3c-1-2", dependencies=["numpy"]) + def t(x): + return x + + target = PodTarget(t.spec, t._fn) + payload = target.build_payload(t._fn, t.spec, (5,), {}) + assert payload["function_name"] == "t" + assert "def t(x):" in payload["function_code"] + assert _unb64(payload["args"][0]) == 5 + assert payload["dependencies"] == ["numpy"] + + def test_task_remote_runs_full_lifecycle(self): + app = App("a") + + @app.task(name="t", cpu="cpu3c-1-2") + def t(x): + return x * 2 + + with patch("runpod.apps.tasks.TaskExecution") as MockExec: + instance = MockExec.return_value + instance.start = AsyncMock() + instance.wait_ready = AsyncMock() + instance.execute = AsyncMock( + return_value={"success": True, "result": _b64(10)} + ) + instance.terminate = AsyncMock() + + result = t.remote(5) + + assert result == 10 + instance.start.assert_awaited_once() + instance.wait_ready.assert_awaited_once() + instance.terminate.assert_awaited_once() + + def test_task_terminates_pod_on_failure(self): + app = App("a") + + @app.task(name="t", cpu="cpu3c-1-2") + def t(): + pass + + with patch("runpod.apps.tasks.TaskExecution") as MockExec: + instance = MockExec.return_value + instance.start = AsyncMock() + instance.wait_ready = AsyncMock( + side_effect=TimeoutError("never ready") + ) + instance.terminate = AsyncMock() + + with pytest.raises(TimeoutError): + t.remote() + + instance.terminate.assert_awaited_once() + + def test_task_spawn_returns_job(self): + app = App("a") + + @app.task(name="t", cpu="cpu3c-1-2") + def t(): + pass + + with patch("runpod.apps.tasks.TaskExecution") as MockExec: + instance = MockExec.return_value + instance.start = AsyncMock() + instance.wait_ready = AsyncMock() + instance.submit = AsyncMock() + instance.pod_id = "pod-1" + + job = t.spawn() + + from runpod.apps.tasks import TaskJob + + assert isinstance(job, TaskJob) + assert job.pod_id == "pod-1" + instance.submit.assert_awaited_once() + + +class TestPollResult: + """poll_result must tolerate proxy propagation 404s.""" + + def _server(self, responses): + """a local server that pops one canned (status, body) per hit.""" + import http.server + import threading + + hits = {"count": 0} + + class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + hits["count"] += 1 + status, body = responses[min(hits["count"], len(responses)) - 1] + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H) + httpd.daemon_threads = True + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd, hits + + def _poll(self, url): + import asyncio + + from runpod.apps.tasks import TaskExecution + + spec = ResourceSpec(kind=ResourceKind.TASK, name="t", cpu=["cpu3c-1-2"]) + execution = TaskExecution(spec) + execution.pod_id = "fake" + with ( + patch("runpod.apps.tasks._proxy_url", return_value=url), + patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()), + ): + return asyncio.run(execution.poll_result()) + + def test_retries_transient_404_then_returns_result(self): + httpd, hits = self._server( + [ + (404, {"error": "not found"}), + (404, {"error": "not found"}), + (200, {"status": "DONE", "response": {"success": True}}), + ] + ) + try: + url = f"http://127.0.0.1:{httpd.server_port}" + assert self._poll(url) == {"success": True} + assert hits["count"] == 3 + finally: + httpd.shutdown() + httpd.server_close() + + def test_persistent_404_raises(self): + import aiohttp + + httpd, hits = self._server([(404, {"error": "not found"})]) + try: + url = f"http://127.0.0.1:{httpd.server_port}" + with pytest.raises(aiohttp.ClientResponseError): + self._poll(url) + assert hits["count"] == 6 + finally: + httpd.shutdown() + httpd.server_close() + + def test_running_returns_none(self): + httpd, _ = self._server([(200, {"status": "RUNNING", "response": None})]) + try: + url = f"http://127.0.0.1:{httpd.server_port}" + assert self._poll(url) is None + finally: + httpd.shutdown() + httpd.server_close() + + +class TestGpuPoolExpansion: + def test_pool_id_expands_to_device_names(self): + from runpod.apps.tasks import _device_names + + assert _device_names(["ADA_24"]) == ["NVIDIA GeForce RTX 4090"] + + def test_device_name_passes_through(self): + from runpod.apps.tasks import _device_names + + assert _device_names(["NVIDIA L40S"]) == ["NVIDIA L40S"] + + def test_none_expands_to_all_devices(self): + # the pod api has no "any" wildcard; unconstrained gpu means + # every known device name + from runpod.apps.gpu import POOLS_TO_TYPES + from runpod.apps.tasks import _device_names + + names = _device_names(None) + assert "any" not in names + all_devices = { + t.value for types in POOLS_TO_TYPES.values() for t in types + } + assert set(names) == all_devices + + def test_mixed_pool_and_device(self): + from runpod.apps.tasks import _device_names + + names = _device_names(["AMPERE_48", "NVIDIA L40S"]) + assert "NVIDIA A40" in names and "NVIDIA L40S" in names + + +class TestTaskExecutionLifecycle: + def _spec(self, **overrides): + from runpod.apps.spec import ResourceKind, ResourceSpec + + defaults = dict(kind=ResourceKind.TASK, name="t", cpu=["cpu3c-1-2"]) + defaults.update(overrides) + return ResourceSpec(**defaults) + + async def test_start_deploys_pod(self): + from runpod.apps.tasks import TaskExecution + + api = MagicMock() + api.deploy_task_pod = AsyncMock(return_value={"id": "pod-9"}) + execution = TaskExecution(self._spec(), api=api) + await execution.start() + assert execution.pod_id == "pod-9" + assert api.deploy_task_pod.call_args[1]["is_cpu"] is True + + async def test_start_resolves_registry_auth(self): + from runpod.apps.tasks import TaskExecution + + api = MagicMock() + api.deploy_task_pod = AsyncMock(return_value={"id": "pod-9"}) + execution = TaskExecution( + self._spec(image="private/img", registry_auth="dh"), api=api + ) + with patch( + "runpod.apps.registry.resolve_registry_auth", + AsyncMock(return_value="auth-1"), + ): + await execution.start() + pod = api.deploy_task_pod.call_args[0][0] + assert pod["containerRegistryAuthId"] == "auth-1" + + async def test_terminate_swallows_api_errors(self): + from runpod.apps.tasks import TaskExecution + + api = MagicMock() + api.terminate_pod = AsyncMock(side_effect=RuntimeError("api down")) + execution = TaskExecution(self._spec(), api=api) + execution.pod_id = "pod-9" + await execution.terminate() # must not raise + assert execution.pod_id is None + + async def test_terminate_noop_without_pod(self): + from runpod.apps.tasks import TaskExecution + + api = MagicMock() + api.terminate_pod = AsyncMock() + execution = TaskExecution(self._spec(), api=api) + await execution.terminate() + api.terminate_pod.assert_not_awaited() + + async def test_execute_polls_to_done(self): + from runpod.apps.tasks import TaskExecution + + execution = TaskExecution(self._spec(), api=MagicMock()) + execution.pod_id = "pod-9" + execution.submit = AsyncMock() + polls = [None, {"success": True, "json_result": 4}] + execution.poll_result = AsyncMock(side_effect=polls) + with patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()): + response = await execution.execute({"fn": "t"}, timeout=60) + assert response == {"success": True, "json_result": 4} + + async def test_execute_timeout(self): + from runpod.apps.tasks import TaskExecution + + execution = TaskExecution(self._spec(), api=MagicMock()) + execution.pod_id = "pod-9" + execution.submit = AsyncMock() + execution.poll_result = AsyncMock(return_value=None) + with ( + patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()), + patch( + "runpod.apps.tasks.time.monotonic", + side_effect=[0, 0, 100], + ), + ): + with pytest.raises(TimeoutError, match="did not finish"): + await execution.execute({"fn": "t"}, timeout=10) + + +class TestTaskJob: + def _job(self): + from runpod.apps.tasks import TaskExecution, TaskJob + + execution = MagicMock(spec=TaskExecution) + execution.pod_id = "pod-9" + execution.terminate = AsyncMock() + return TaskJob(execution), execution + + async def test_wait_returns_result_and_terminates(self): + job, execution = self._job() + execution.poll_result = AsyncMock( + side_effect=[None, {"success": True, "json_result": 9}] + ) + with patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()): + result = await job.wait() + assert result == 9 + execution.terminate.assert_awaited_once() + + async def test_wait_timeout_keeps_pod(self): + job, execution = self._job() + execution.poll_result = AsyncMock(return_value=None) + with ( + patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()), + patch( + "runpod.apps.tasks.time.monotonic", side_effect=[0, 100] + ), + ): + with pytest.raises(TimeoutError): + await job.wait(timeout=10) + execution.terminate.assert_not_awaited() + + async def test_wait_after_done_returns_cached(self): + job, execution = self._job() + execution.poll_result = AsyncMock( + return_value={"success": True, "json_result": 9} + ) + assert await job.wait() == 9 + assert await job.wait() == 9 + assert execution.poll_result.await_count == 1 + + async def test_cancel_terminates(self): + job, execution = self._job() + await job.cancel() + execution.terminate.assert_awaited_once() + assert job._done diff --git a/tests/test_apps/test_volume.py b/tests/test_apps/test_volume.py new file mode 100644 index 00000000..bda7cc44 --- /dev/null +++ b/tests/test_apps/test_volume.py @@ -0,0 +1,180 @@ +"""volume references and provision-time resolution.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from runpod.apps.spec import ResourceKind, ResourceSpec +from runpod.apps.volume import ( + Volume, + VolumeError, + VolumeResolver, + volume_list, +) + + +def _spec(name="r", gpu=None, cpu=None): + return ResourceSpec( + kind=ResourceKind.TASK, name=name, gpu=gpu, cpu=cpu + ) + + +def _api(volumes=None, created=None): + api = AsyncMock() + api.list_network_volumes.return_value = volumes or [] + api.create_network_volume.return_value = created or { + "id": "nv-new", + "name": "models", + "size": 50, + "dataCenterId": "EU-RO-1", + } + # stock queries: everything available everywhere + api.gpu_stock_status.return_value = "High" + api.cpu_stock_status.return_value = "High" + return api + + +class TestVolumeRef: + def test_path_follows_context(self, monkeypatch): + from pathlib import Path + + # task pods mount at /workspace + monkeypatch.delenv("RUNPOD_ENDPOINT_ID", raising=False) + assert Volume("models").path == Path("/workspace") + # endpoint workers mount at /runpod-volume + monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1") + assert Volume("models").path == Path("/runpod-volume") + + def test_empty_name_raises(self): + with pytest.raises(VolumeError): + Volume("") + + def test_volume_list_normalizes(self): + vols = volume_list([Volume("a"), "b"]) + assert [v.name for v in vols] == ["a", "b"] + assert volume_list(None) == [] + assert [v.name for v in volume_list("single")] == ["single"] + + +class TestVolumeResolver: + def test_existing_by_name(self): + api = _api( + volumes=[ + {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"} + ] + ) + resolver = VolumeResolver(api) + resolved = asyncio.run( + resolver.resolve(Volume("models"), [_spec(gpu=None)]) + ) + assert resolved == {"id": "nv-1", "dataCenterId": "EU-RO-1"} + api.create_network_volume.assert_not_awaited() + + def test_existing_by_id(self): + api = _api( + volumes=[ + {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"} + ] + ) + resolver = VolumeResolver(api) + resolved = asyncio.run( + resolver.resolve(Volume("nv-1"), [_spec(gpu=None)]) + ) + assert resolved["id"] == "nv-1" + + def test_missing_creates_with_placement(self): + api = _api() + resolver = VolumeResolver(api) + resolved = asyncio.run( + resolver.resolve(Volume("models"), [_spec(gpu=None)]) + ) + assert resolved["id"] == "nv-new" + api.create_network_volume.assert_awaited_once() + + def test_missing_no_create_raises(self): + api = _api() + resolver = VolumeResolver(api) + with pytest.raises(VolumeError, match="create=False"): + asyncio.run( + resolver.resolve( + Volume("models", create=False), [_spec(gpu=None)] + ) + ) + + def test_duplicate_names_raise(self): + api = _api( + volumes=[ + {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"}, + {"id": "nv-2", "name": "models", "size": 50, "dataCenterId": "US-KS-2"}, + ] + ) + resolver = VolumeResolver(api) + with pytest.raises(VolumeError, match="reference by id"): + asyncio.run( + resolver.resolve(Volume("models"), [_spec(gpu=None)]) + ) + + def test_resolution_cached_per_name(self): + api = _api( + volumes=[ + {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"} + ] + ) + resolver = VolumeResolver(api) + + async def run(): + await resolver.resolve(Volume("models"), [_spec(gpu=None)]) + await resolver.resolve(Volume("models"), [_spec(gpu=None)]) + + asyncio.run(run()) + assert api.list_network_volumes.await_count == 1 + + def test_created_event_emitted(self): + events = [] + + class Sink: + def volume_created(self, name, size, dc): + events.append((name, size, dc)) + + api = _api() + resolver = VolumeResolver(api, events=Sink()) + asyncio.run(resolver.resolve(Volume("models"), [_spec(gpu=None)])) + assert len(events) == 1 + name, size, dc = events[0] + assert (name, size) == ("models", 50) + assert dc # placement picks a concrete datacenter + + +class TestTaskVolume: + def test_single_volume_only(self): + from runpod.apps.tasks import TaskExecution + + spec = ResourceSpec( + kind=ResourceKind.TASK, + name="t", + cpu=["cpu3c-1-2"], + volume=[Volume("a"), Volume("b")], + ) + execution = TaskExecution(spec, api=_api()) + with pytest.raises(VolumeError, match="exactly one volume"): + asyncio.run(execution._attach_volume({})) + + def test_pod_pins_to_volume_dc(self): + from runpod.apps.tasks import TaskExecution + + api = _api( + volumes=[ + {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"} + ] + ) + spec = ResourceSpec( + kind=ResourceKind.TASK, + name="t", + cpu=["cpu3c-1-2"], + volume=Volume("models"), + ) + execution = TaskExecution(spec, api=api) + pod = asyncio.run(execution._attach_volume({})) + assert pod["networkVolumeId"] == "nv-1" + assert pod["dataCenterIds"] == ["EU-RO-1"] diff --git a/tests/test_apps/test_watch.py b/tests/test_apps/test_watch.py new file mode 100644 index 00000000..62193ba1 --- /dev/null +++ b/tests/test_apps/test_watch.py @@ -0,0 +1,62 @@ +"""tests for the dev-session file watcher.""" + +import time + +from runpod.apps.watch import FileWatcher + + +def test_no_change_initially(tmp_path): + (tmp_path / "a.py").write_text("x = 1") + watcher = FileWatcher([tmp_path]) + assert watcher.changed() is False + + +def test_detects_modification(tmp_path): + f = tmp_path / "a.py" + f.write_text("x = 1") + watcher = FileWatcher([tmp_path]) + time.sleep(0.01) + f.write_text("x = 2") + # mtime granularity can be coarse; force it + import os + + os.utime(f, (time.time() + 1, time.time() + 1)) + assert watcher.changed() is True + assert watcher.changed() is False + + +def test_detects_new_file(tmp_path): + (tmp_path / "a.py").write_text("x = 1") + watcher = FileWatcher([tmp_path]) + (tmp_path / "b.py").write_text("y = 2") + assert watcher.changed() is True + + +def test_detects_deleted_file(tmp_path): + f = tmp_path / "a.py" + f.write_text("x = 1") + watcher = FileWatcher([tmp_path]) + f.unlink() + assert watcher.changed() is True + + +def test_ignores_non_python(tmp_path): + (tmp_path / "a.py").write_text("x = 1") + watcher = FileWatcher([tmp_path]) + (tmp_path / "notes.txt").write_text("hi") + assert watcher.changed() is False + + +def test_ignores_skip_dirs(tmp_path): + (tmp_path / "a.py").write_text("x = 1") + watcher = FileWatcher([tmp_path]) + venv = tmp_path / ".venv" + venv.mkdir() + (venv / "pkg.py").write_text("z = 3") + assert watcher.changed() is False + + +def test_wait_for_change_timeout(tmp_path): + (tmp_path / "a.py").write_text("x = 1") + watcher = FileWatcher([tmp_path]) + assert watcher.wait_for_change(poll_interval=0.05, timeout=0.2) is False diff --git a/tests/test_cli/test_cli_groups/test_config_commands.py b/tests/test_cli/test_cli_groups/test_config_commands.py deleted file mode 100644 index 16da467a..00000000 --- a/tests/test_cli/test_cli_groups/test_config_commands.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Runpod | Tests | CLI | Commands -""" - -import unittest -from unittest.mock import patch - -from click.testing import CliRunner - -from runpod.cli.entry import runpod_cli - - -class TestCommands(unittest.TestCase): - """A collection of tests for the CLI commands.""" - - def setUp(self): - self.runner = CliRunner() - - def test_config_wizard(self): - """Tests the config command.""" - with patch("click.echo") as mock_echo, patch( - "runpod.cli.groups.config.commands.set_credentials" - ) as mock_set_credentials, patch( - "runpod.cli.groups.config.commands.check_credentials" - ) as mock_check_creds, patch( - "click.confirm", return_value=True - ) as mock_confirm, patch( - "click.prompt", return_value="KEY" - ) as mock_prompt: - - # Assuming credentials aren't set (doesn't prompt for overwrite) - mock_check_creds.return_value = (False, None) - - # Successful Call with Direct Key - result = self.runner.invoke( - runpod_cli, ["config", "--profile", "test", "KEY"] - ) - assert result.exit_code == 0 - mock_set_credentials.assert_called_with("KEY", "test", overwrite=True) - assert mock_echo.call_count == 1 - - # Successful Call with Prompted Key (since direct key isn't provided) - result = self.runner.invoke(runpod_cli, ["config", "--profile", "test"]) - assert result.exit_code == 0 - mock_set_credentials.assert_called_with("KEY", "test", overwrite=True) - mock_prompt.assert_called_with( - " > Runpod API Key", hide_input=False, confirmation_prompt=False - ) # pylint: disable=line-too-long - - # Simulating existing credentials, prompting for overwrite - mock_check_creds.return_value = (True, None) - result = self.runner.invoke(runpod_cli, ["config", "--profile", "test"]) - mock_confirm.assert_called_with( - "Credentials already set for profile: test. Overwrite?", abort=True - ) - - # Unsuccessful Call - mock_set_credentials.side_effect = ValueError() - result = self.runner.invoke( - runpod_cli, ["config", "--profile", "test", "KEY"] - ) - assert result.exit_code == 1 - - def test_check_flag(self): - """Tests the --check flag.""" - with patch( - "runpod.cli.groups.config.commands.check_credentials" - ) as mock_check_creds: - # Assuming credentials are set - mock_check_creds.return_value = (True, None) - result = self.runner.invoke( - runpod_cli, ["config", "--check", "--profile", "test"] - ) - assert result.exit_code == 0 - - # Assuming credentials aren't set - mock_check_creds.return_value = (False, "Credentials not found.") - result = self.runner.invoke( - runpod_cli, ["config", "--check", "--profile", "test"] - ) - assert result.exit_code == 1 - - def test_output_messages(self): - """Tests the output messages for the config command.""" - with patch("click.echo") as mock_echo, patch( - "runpod.cli.groups.config.commands.set_credentials" - ) as mock_set_credentials, patch( - "runpod.cli.groups.config.commands.check_credentials", - return_value=(False, None), - ) as mock_check_creds: # pylint: disable=line-too-long - result = self.runner.invoke( - runpod_cli, ["config", "KEY", "--profile", "test"] - ) - mock_set_credentials.assert_called_with("KEY", "test", overwrite=True) - mock_echo.assert_any_call( - "Credentials set for profile: test in ~/.runpod/config.toml" - ) - assert result.exit_code == 0 - assert mock_check_creds.call_count == 1 - - def test_api_key_prompt(self): - """Tests the API key prompt.""" - with patch("click.prompt", return_value="KEY") as mock_prompt, patch( - "runpod.cli.groups.config.commands.set_credentials" - ) as mock_set_credentials, patch( - "runpod.cli.groups.config.commands.check_credentials", - return_value=(False, None) - ): - result = self.runner.invoke(runpod_cli, ["config", "--profile", "test"]) - mock_prompt.assert_called_with( - " > Runpod API Key", hide_input=False, confirmation_prompt=False - ) # pylint: disable=line-too-long - mock_set_credentials.assert_called_with("KEY", "test", overwrite=True) - assert result.exit_code == 0 diff --git a/tests/test_cli/test_cli_groups/test_exec_commands.py b/tests/test_cli/test_cli_groups/test_exec_commands.py deleted file mode 100644 index d8a0edf6..00000000 --- a/tests/test_cli/test_cli_groups/test_exec_commands.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Tests for Runpod CLI exec commands. -""" - -import tempfile -import unittest -from unittest.mock import patch - -import click -from click.testing import CliRunner - -from runpod.cli.entry import runpod_cli - - -class TestExecCommands(unittest.TestCase): - """Tests for Runpod CLI exec commands.""" - def setUp(self): - self.runner = CliRunner() - self.runner = click.testing.CliRunner() - - def test_remote_python_with_provided_pod_id(self): - """Tests the remote_python command when pod_id is provided directly.""" - with tempfile.NamedTemporaryFile() as temp_file, patch( - "runpod.cli.groups.exec.commands.python_over_ssh" - ) as mock_python_over_ssh: - result = self.runner.invoke( - runpod_cli, - ["exec", "python", "--pod_id", "sample_pod_id", temp_file.name], - ) - assert result.exit_code == 0 - mock_python_over_ssh.assert_called_with("sample_pod_id", temp_file.name) - - def test_remote_python_without_provided_pod_id_stored(self): - """Tests the remote_python command when pod_id is retrieved from storage.""" - with tempfile.NamedTemporaryFile() as temp_file, patch( - "runpod.cli.groups.exec.commands.python_over_ssh" - ) as mock_python_over_ssh, patch( - "runpod.cli.groups.exec.commands.get_session_pod", - return_value="stored_pod_id", - ) as mock_get_pod_id: # pylint: disable=line-too-long - mock_python_over_ssh.return_value = None - result = self.runner.invoke(runpod_cli, ["exec", "python", temp_file.name]) - assert result.exit_code == 0 - mock_get_pod_id.assert_called_once() - mock_python_over_ssh.assert_called_with("stored_pod_id", temp_file.name) - - def test_remote_python_without_provided_pod_id_prompt(self): - """Tests the remote_python command when pod_id is prompted to user.""" - with tempfile.NamedTemporaryFile() as temp_file, patch( - "runpod.cli.groups.exec.commands.python_over_ssh" - ) as mock_python_over_ssh, patch( - "runpod.cli.groups.exec.commands.get_session_pod", - return_value="prompted_pod_id", - ) as mock_get_pod_id: # pylint: disable=line-too-long - mock_python_over_ssh.return_value = None - result = self.runner.invoke(runpod_cli, ["exec", "python", temp_file.name]) - assert result.exit_code == 0 - mock_get_pod_id.assert_called_once() - mock_python_over_ssh.assert_called_with("prompted_pod_id", temp_file.name) diff --git a/tests/test_cli/test_cli_groups/test_exec_functions.py b/tests/test_cli/test_cli_groups/test_exec_functions.py deleted file mode 100644 index 511ebe30..00000000 --- a/tests/test_cli/test_cli_groups/test_exec_functions.py +++ /dev/null @@ -1,29 +0,0 @@ -""" Tests for CLI group `exec functions` """ - -import unittest -from unittest.mock import MagicMock, patch - -from runpod.cli.groups.exec import functions - - -class TestExecFunctions(unittest.TestCase): - """Tests for CLI group `exec functions`""" - - @patch("runpod.cli.groups.exec.functions.ssh_cmd.SSHConnection") - def test_python_over_ssh(self, mock_ssh_connection): - """ - Test `python_over_ssh` - """ - mock_ssh = MagicMock() - mock_ssh_connection.return_value = mock_ssh - - pod_id = "pod_id" - file_name = "file_name" - - functions.python_over_ssh(pod_id, file_name) - - self.assertTrue(functions.python_over_ssh) - mock_ssh_connection.assert_called_once_with(pod_id) - mock_ssh.put_file.assert_called_once_with(file_name, f"/root/{file_name}") - mock_ssh.run_commands.assert_called_once_with([f"python3.10 /root/{file_name}"]) - mock_ssh.close.assert_called_once_with() diff --git a/tests/test_cli/test_cli_groups/test_exec_helpers.py b/tests/test_cli/test_cli_groups/test_exec_helpers.py deleted file mode 100644 index 248a684c..00000000 --- a/tests/test_cli/test_cli_groups/test_exec_helpers.py +++ /dev/null @@ -1,74 +0,0 @@ -""" Unit tests for the runpod.cli.groups.exec.helpers module. """ - -import unittest -from unittest.mock import mock_open, patch - -from runpod.cli.groups.exec.helpers import POD_ID_FILE, get_session_pod - - -class TestGetSessionPod(unittest.TestCase): - """Unit tests for get_session_pod""" - - def setUp(self): - self.mocked_pod_id = "sample_pod_id" - - @patch("os.path.exists") - @patch("runpod.cli.groups.exec.helpers.get_pod") - def test_existing_pod_id_file_and_valid_pod_id(self, mock_get_pod, mock_exists): - """Test get_session_pod when the pod_id file exists and the pod_id is valid""" - mock_exists.return_value = True - mock_get_pod.return_value = True - with patch("builtins.open", mock_open(read_data=self.mocked_pod_id)): - result = get_session_pod() - self.assertEqual(result, self.mocked_pod_id) - - @patch("os.path.exists") - @patch("runpod.cli.groups.exec.helpers.get_pod") - def test_existing_pod_id_file_and_invalid_pod_id(self, mock_get_pod, mock_exists): - """Test get_session_pod when the pod_id file exists and the pod_id is invalid""" - mock_exists.return_value = True - mock_get_pod.return_value = None - with patch("builtins.open", mock_open(read_data="invalid_pod_id")): - with patch("click.prompt", return_value=self.mocked_pod_id): - result = get_session_pod() - self.assertEqual(result, self.mocked_pod_id) - - @patch("os.path.exists") - @patch("runpod.cli.groups.exec.helpers.get_pod") - def test_no_pod_id_file(self, mock_get_pod, mock_exists): - """Test get_session_pod when the pod_id file doesn't exist""" - mock_exists.return_value = False - mock_get_pod.return_value = None - with patch("click.prompt", return_value=self.mocked_pod_id): - result = get_session_pod() - self.assertEqual(result, self.mocked_pod_id) - - @patch("os.path.exists") - @patch("runpod.cli.groups.exec.helpers.get_pod") - def test_pod_id_file_written_to_when_not_existing(self, mock_get_pod, mock_exists): - """Test get_session_pod when the pod_id file doesn't exist""" - mock_exists.return_value = False - mock_get_pod.return_value = None - mocked = mock_open() - with patch("builtins.open", mocked): - with patch("click.prompt", return_value=self.mocked_pod_id): - get_session_pod() - mocked.assert_called_once_with(POD_ID_FILE, "w", encoding="UTF-8") - handle = mocked() - handle.write.assert_called_once_with(self.mocked_pod_id) - - @patch("os.path.exists") - @patch("runpod.cli.groups.exec.helpers.get_pod") - def test_pod_id_file_written_to_when_invalid_pod_id_in_file( - self, mock_get_pod, mock_exists - ): - """Test get_session_pod when the pod_id file exists and the pod_id is invalid""" - mock_exists.return_value = True - mock_get_pod.return_value = None - mocked = mock_open(read_data="invalid_pod_id") - with patch("builtins.open", mocked): - with patch("click.prompt", return_value=self.mocked_pod_id): - get_session_pod() - mocked.assert_called_with(POD_ID_FILE, "w", encoding="UTF-8") - handle = mocked() - handle.write.assert_called_with(self.mocked_pod_id) diff --git a/tests/test_cli/test_cli_groups/test_pod_commands.py b/tests/test_cli/test_cli_groups/test_pod_commands.py index 1eefbe79..2ce00b2b 100644 --- a/tests/test_cli/test_cli_groups/test_pod_commands.py +++ b/tests/test_cli/test_cli_groups/test_pod_commands.py @@ -184,7 +184,7 @@ def test_sync_pods_no_ssh_keys(self, mock_ssh_connection, mock_echo, mock_uuid): # Verify error message was shown mock_echo.assert_any_call("❌ No SSH keys found in your Runpod account!") mock_echo.assert_any_call("🔑 To create an SSH key, run:") - mock_echo.assert_any_call(" runpod ssh add-key") + mock_echo.assert_any_call(" rp ssh add") @patch("runpod.cli.groups.pod.commands.uuid.uuid4") @patch("runpod.cli.groups.pod.commands.click.echo") diff --git a/tests/test_cli/test_cli_groups/test_project_commands.py b/tests/test_cli/test_cli_groups/test_project_commands.py deleted file mode 100644 index 54a9fab0..00000000 --- a/tests/test_cli/test_cli_groups/test_project_commands.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -Runpod | CLI | Groups | Project | Commands | Tests -""" - -import unittest -from unittest.mock import patch - -from click.testing import CliRunner - -from runpod.cli.groups.project.commands import ( - deploy_project, - new_project_wizard, - start_project_pod, -) - - -class TestProjectCLI(unittest.TestCase): - """A collection of tests for the Project CLI commands.""" - - def setUp(self): - self.runner = CliRunner() - - def test_new_project_wizard_no_network_volumes(self): - """ - Tests the new_project_wizard command with no network volumes. - """ - with patch("runpod.cli.groups.project.commands.get_user") as mock_get_user: - mock_get_user.return_value = {"networkVolumes": []} - - result = self.runner.invoke(new_project_wizard) - - self.assertEqual(result.exit_code, 1) - self.assertIn("You do not have any network volumes.", result.output) - - def test_new_project_wizard_success(self): - """ - Tests the new_project_wizard command. - """ - with patch("click.prompt") as mock_prompt, patch( - "click.confirm", return_value=True - ) as mock_confirm, patch( - "runpod.cli.groups.project.commands.create_new_project" - ) as mock_create, patch( - "runpod.cli.groups.project.commands.get_user" - ) as mock_get_user, patch( - "runpod.cli.groups.project.commands.cli_select" - ) as mock_select: - mock_get_user.return_value = { - "networkVolumes": [ - { - "id": "XYZ_VOLUME", - "name": "XYZ_VOLUME", - "size": 100, - "dataCenterId": "XYZ", - } - ] - } # pylint: disable=line-too-long - mock_prompt.side_effect = ["TestProject", "11.8.0", "3.10"] - mock_select.return_value = {"volume-id": "XYZ_VOLUME"} - - result = self.runner.invoke( - new_project_wizard, - ["--type", "llama2", "--model", "meta-llama/Llama-2-7b"], - ) # pylint: disable=line-too-long - - self.assertEqual(result.exit_code, 0) - mock_confirm.assert_called_with("Do you want to continue?", abort=True) - mock_create.assert_called() - mock_prompt.assert_called() - mock_create.assert_called_with( - "TestProject", - "XYZ_VOLUME", - "11.8.0", - "3.10", - "llama2", - "meta-llama/Llama-2-7b", - False, - ) # pylint: disable=line-too-long - self.assertIn("Project TestProject created successfully!", result.output) - - def test_new_project_wizard_success_init_current_dir(self): - """ - Tests the new_project_wizard command with the --init flag. - """ - with patch("click.prompt") as mock_prompt, patch( - "click.confirm", return_value=True - ) as mock_confirm, patch( - "runpod.cli.groups.project.commands.create_new_project" - ) as mock_create, patch( - "runpod.cli.groups.project.commands.get_user" - ) as mock_get_user, patch( - "runpod.cli.groups.project.commands.cli_select" - ) as mock_select, patch( - "os.getcwd" - ) as mock_getcwd: - mock_get_user.return_value = { - "networkVolumes": [ - { - "id": "XYZ_VOLUME", - "name": "XYZ_VOLUME", - "size": 100, - "dataCenterId": "XYZ", - } - ] - } # pylint: disable=line-too-long - mock_select.return_value = {"volume-id": "XYZ_VOLUME"} - mock_prompt.side_effect = ["XYZ_VOLUME", "11.8.0", "3.10"] - - self.runner.invoke(new_project_wizard, ["--init"]) - assert mock_confirm.called - assert mock_create.called - assert mock_getcwd.called - - def test_new_project_wizard_invalid_name(self): - """ - Tests the new_project_wizard command with an invalid project name. - """ - with patch("runpod.cli.groups.project.commands.get_user") as mock_get_user: - mock_get_user.return_value = {"networkVolumes": ["XYZ_VOLUME"]} - - result = self.runner.invoke(new_project_wizard, ["--name", "Invalid/Name"]) - - self.assertEqual(result.exit_code, 2) - self.assertIn("Project name contains an invalid character", result.output) - - def test_start_project_pod(self): - """ - Tests the start_project_pod command. - """ - with patch("click.confirm", return_value=True) as mock_confirm, patch( - "runpod.cli.groups.project.commands.start_project" - ) as mock_start: - mock_start.return_value = None - result = self.runner.invoke(start_project_pod) - - mock_confirm.assert_called_with("Do you want to continue?", abort=True) - self.assertEqual(result.exit_code, 0) - self.assertIn("Starting project development pod...", result.output) - - @patch("runpod.cli.groups.project.commands.click.echo") - @patch("runpod.cli.groups.project.commands.create_project_endpoint") - def test_deploy_project(self, mock_create_project_endpoint, mock_click_echo): - """Test the deploy_project function.""" - mock_create_project_endpoint.return_value = "test_endpoint_id" - - result = self.runner.invoke(deploy_project) - - mock_create_project_endpoint.assert_called_once() - - mock_click_echo.assert_any_call("Deploying project...") - mock_click_echo.assert_any_call("The following urls are available:") - mock_click_echo.assert_any_call( - " - https://api.runpod.ai/v2/test_endpoint_id/runsync" - ) - mock_click_echo.assert_any_call( - " - https://api.runpod.ai/v2/test_endpoint_id/run" - ) - mock_click_echo.assert_any_call( - " - https://api.runpod.ai/v2/test_endpoint_id/health" - ) - - self.assertEqual(result.exit_code, 0) diff --git a/tests/test_cli/test_cli_groups/test_project_functions.py b/tests/test_cli/test_cli_groups/test_project_functions.py deleted file mode 100644 index 667d71e6..00000000 --- a/tests/test_cli/test_cli_groups/test_project_functions.py +++ /dev/null @@ -1,338 +0,0 @@ -""" Test functions in runpod.cli.groups.project.functions module. """ - -import os -import shutil -import unittest -from unittest.mock import mock_open, patch - -from runpod.cli.groups.project.functions import ( - STARTER_TEMPLATES, - create_new_project, - create_project_endpoint, - start_project, -) - - -class TestCreateNewProject(unittest.TestCase): - """Test the create_new_project function.""" - - def tearDown(self): - toml_file_location = os.path.join(os.getcwd(), "test_project") - if os.path.exists(toml_file_location): - shutil.rmtree(toml_file_location) - - @patch("os.makedirs") - @patch("os.path.exists", return_value=False) - @patch("os.getcwd", return_value="/current/path") - @patch("runpod.cli.groups.project.functions.copy_template_files") - def test_create_project_folder( - self, mock_copy_template_files, mock_getcwd, mock_exists, mock_makedirs - ): # pylint: disable=line-too-long - """Test that a new project folder is created if init_current_dir is False.""" - with patch("builtins.open", new_callable=mock_open): - create_new_project("test_project", "volume_id", "11.1.1", "3.8") - mock_makedirs.assert_called_once_with("/current/path/test_project") - assert mock_copy_template_files.called - assert mock_getcwd.called - assert mock_exists.called - - @patch("os.makedirs") - @patch("os.path.exists", return_value=False) - @patch("os.getcwd", return_value="/tmp/testdir") - @patch("builtins.open", new_callable=mock_open) - def test_create_new_project_init_current_dir( - self, mock_file_open, mock_getcwd, mock_path_exists, mock_makedirs - ): # pylint: disable=line-too-long - """Test that a new project folder is not created if init_current_dir is True.""" - project_name = "test_project" - runpod_volume_id = "12345" - cuda_version = "11.1.1" - python_version = "3.9" - - create_new_project( - project_name, - runpod_volume_id, - cuda_version, - python_version, - init_current_dir=True, - ) - mock_makedirs.assert_not_called() - mock_file_open.assert_called_with( - "/tmp/testdir/runpod.toml", "w", encoding="UTF-8" - ) - assert mock_getcwd.called - assert mock_path_exists.called is False - - @patch("os.makedirs") - @patch("os.path.exists", return_value=False) - @patch("os.getcwd", return_value="/current/path") - @patch("runpod.cli.groups.project.functions.copy_template_files") - def test_copy_template_files( - self, mock_copy_template_files, mock_getcwd, mock_exists, mock_makedirs - ): # pylint: disable=line-too-long - """Test that template files are copied to the new project folder.""" - with patch("builtins.open", new_callable=mock_open): - create_new_project("test_project", "volume_id", "11.1.1", "3.8") - mock_copy_template_files.assert_called_once_with( - STARTER_TEMPLATES + "/default", "/current/path/test_project" - ) # pylint: disable=line-too-long - assert mock_getcwd.called - assert mock_exists.called - assert mock_makedirs.called - - @patch("os.path.exists", return_value=True) - @patch( - "builtins.open", - new_callable=mock_open, - read_data="data with <> placeholder", - ) # pylint: disable=line-too-long - def test_replace_placeholders_in_handler( - self, mock_open_file, mock_exists - ): # pylint: disable=line-too-long - """Test that placeholders in handler.py are replaced if model_name is given.""" - with patch("runpod.cli.groups.project.functions.copy_template_files"): - create_new_project( - "test_project", "volume_id", "11.8.0", "3.8", model_name="my_model" - ) - assert mock_open_file.called - assert mock_exists.called - - @patch("os.path.exists", return_value=False) - @patch("builtins.open", new_callable=mock_open) - def test_create_runpod_toml(self, mock_open_file, mock_exists): - """Test that runpod.toml configuration file is created.""" - with patch("runpod.cli.groups.project.functions.copy_template_files"): - create_new_project("test_project", "volume_id", "11.8.0", "3.8") - toml_file_location = os.path.join(os.getcwd(), "test_project", "runpod.toml") - mock_open_file.assert_called_with( - toml_file_location, "w", encoding="UTF-8" - ) # pylint: disable=line-too-long - assert mock_exists.called - - @patch("os.path.exists", return_value=True) - @patch("builtins.open", new_callable=mock_open, read_data="<> placeholder") - def test_update_requirements_file(self, mock_open_file, mock_exists): - """Test that placeholders in requirements.txt are replaced correctly.""" - with patch("runpod.cli.groups.project.functions.__version__", "dev"), patch( - "runpod.cli.groups.project.functions.copy_template_files" - ): - create_new_project("test_project", "volume_id", "11.8.0", "3.8") - assert mock_open_file.called - assert mock_exists.called - - @patch("os.path.exists", return_value=True) - @patch("builtins.open", new_callable=mock_open, read_data="<> placeholder") - def test_update_requirements_file_non_dev(self, mock_open_file, mock_exists): - """Test that placeholders in requirements.txt are replaced for non-dev versions.""" - with patch("runpod.cli.groups.project.functions.__version__", "1.0.0"), patch( - "runpod.cli.groups.project.functions.copy_template_files" - ): - create_new_project("test_project", "volume_id", "11.8.0", "3.8") - assert mock_open_file.called - assert mock_exists.called - - -class TestStartProject(unittest.TestCase): - """Test the start_project function.""" - - @patch("runpod.cli.groups.project.functions.load_project_config") - @patch("runpod.cli.groups.project.functions.get_project_pod") - @patch("runpod.cli.groups.project.functions.attempt_pod_launch") - @patch("runpod.cli.groups.project.functions.get_pod") - @patch("runpod.cli.groups.project.functions.SSHConnection") - @patch("os.getcwd", return_value="/current/path") - def test_start_nonexistent_successfully( - self, - mock_getcwd, - mock_ssh_connection, - mock_get_pod, - mock_attempt_pod_launch, - mock_get_project_pod, - mock_load_project_config, - ): # pylint: disable=line-too-long, too-many-arguments - """Test that a project is launched successfully.""" - mock_load_project_config.return_value = { - "project": { - "uuid": "123456", - "name": "test_project", - "volume_mount_path": "/mount/path", - "env_vars": {"ENV_VAR": "value"}, - "gpu": "NVIDIA GPU", - }, - "runtime": { - "python_version": "3.8", - "handler_path": "handler.py", - "requirements_path": "requirements.txt", - }, - } - - mock_get_project_pod.return_value = False - - mock_attempt_pod_launch.return_value = { - "id": "new_pod_id", - "desiredStatus": "PENDING", - "runtime": None, - } - - mock_get_pod.return_value = { - "id": "new_pod_id", - "desiredStatus": "RUNNING", - "runtime": "ONLINE", - } - - mock_ssh_instance = mock_ssh_connection.return_value - mock_ssh_instance.__enter__.return_value = mock_ssh_instance - mock_ssh_instance.run_commands.return_value = None - - with patch( - "runpod.cli.groups.project.functions.sync_directory" - ) as mock_sync_directory: - start_project() - - mock_attempt_pod_launch.assert_called() - mock_get_pod.assert_called_with("new_pod_id") - mock_ssh_connection.assert_called_with("new_pod_id") - mock_ssh_instance.run_commands.assert_called() - assert mock_getcwd.called - assert mock_sync_directory.called - - @patch("runpod.cli.groups.project.functions.get_project_pod") - @patch("runpod.cli.groups.project.functions.attempt_pod_launch") - def test_failed_pod_launch(self, mock_attempt_pod, mock_get_pod): - """Test that a project is not launched if pod launch fails.""" - mock_attempt_pod.return_value = None - mock_get_pod.return_value = None - - with patch("builtins.print") as mock_print, patch( - "runpod.cli.groups.project.functions.load_project_config" - ): - - start_project() - mock_print.assert_called_with( - "Selected GPU types unavailable, try again later or use a different type." - ) # pylint: disable=line-too-long - - -class TestStartProjectAPI(unittest.TestCase): - """Test the start_project_api function.""" - - @patch("runpod.cli.groups.project.functions.load_project_config") - @patch("runpod.cli.groups.project.functions.get_project_pod") - @patch("runpod.cli.groups.project.functions.SSHConnection") - @patch("os.getcwd", return_value="/current/path") - @patch("runpod.cli.groups.project.functions.sync_directory") - def test_start_project_api_successfully( - self, - mock_sync_directory, - mock_getcwd, - mock_ssh_connection, - mock_get_project_pod, - mock_load_project_config, - ): # pylint: disable=line-too-long, too-many-arguments - """Test that a project API is started successfully.""" - mock_load_project_config.return_value = { - "project": { - "uuid": "123456", - "name": "test_project", - "volume_mount_path": "/mount/path", - }, - "runtime": { - "python_version": "3.8", - "handler_path": "handler.py", - "requirements_path": "requirements.txt", - }, - } - - mock_get_project_pod.return_value = {"id": "pod_id"} - - mock_ssh_instance = mock_ssh_connection.return_value - mock_ssh_instance.__enter__.return_value = mock_ssh_instance - mock_ssh_instance.run_commands.return_value = None - - start_project() - - mock_get_project_pod.assert_called_with("123456") - mock_ssh_connection.assert_called_with({"id": "pod_id"}) - mock_sync_directory.assert_called_with( - mock_ssh_instance, "/current/path", "/mount/path/123456/dev" - ) - mock_ssh_instance.run_commands.assert_called() - assert mock_getcwd.called - - -class TestCreateProjectEndpoint(unittest.TestCase): - """Test the create_project_endpoint function.""" - - @patch("runpod.cli.groups.project.functions.SSHConnection") - @patch("runpod.cli.groups.project.functions.load_project_config") - @patch("runpod.cli.groups.project.functions.create_template") - @patch("runpod.cli.groups.project.functions.create_endpoint") - @patch("runpod.cli.groups.project.functions.update_endpoint_template") - @patch("runpod.cli.groups.project.functions.get_project_pod") - @patch("runpod.cli.groups.project.functions.get_project_endpoint") - def test_create_project_endpoint( - self, - mock_get_project_endpoint, - mock_get_project_pod, - mock_update_endpoint, - mock_create_endpoint, # pylint: disable=too-many-arguments,line-too-long - mock_create_template, - mock_load_project_config, - mock_ssh_connection, - ): # pylint: disable=line-too-long - """Test that a project endpoint is created successfully.""" - mock_get_project_endpoint.return_value = False - - mock_get_project_pod.return_value = None - with patch( - "runpod.cli.groups.project.functions._launch_dev_pod" - ) as mock_launch_dev_pod: - mock_launch_dev_pod.return_value = None - assert create_project_endpoint() is None - - mock_get_project_pod.return_value = {"id": "test_pod_id"} - mock_load_project_config.return_value = { - "project": { - "name": "test_project", - "volume_mount_path": "/runpod-volume/123456", - "uuid": "123456", - "env_vars": {"TEST_VAR": "value"}, - "base_image": "test_image", - "container_disk_size_gb": 10, - "storage_id": "test_storage_id", - }, - "runtime": { - "python_version": "3.8", - "handler_path": "handler.py", - "requirements_path": "requirements.txt", - }, - } - mock_create_template.return_value = {"id": "test_template_id"} - mock_create_endpoint.return_value = {"id": "test_endpoint_id"} - - mock_ssh_instance = mock_ssh_connection.return_value - mock_ssh_instance.__enter__.return_value = mock_ssh_instance - mock_ssh_instance.run_commands.return_value = None - - with patch("runpod.cli.groups.project.functions.datetime") as mock_datetime: - mock_datetime.now.return_value = "123456" - result = create_project_endpoint() - - self.assertEqual(result, "test_endpoint_id") - mock_create_template.assert_called_with( - name="test_project-endpoint | 123456 | 123456", - image_name="test_image", - container_disk_in_gb=10, - docker_start_cmd='bash -c ". /runpod-volume/123456/prod/venv/bin/activate && python -u /runpod-volume/123456/prod/test_project/handler.py"', # pylint: disable=line-too-long - env={"TEST_VAR": "value"}, - is_serverless=True, - ) - mock_create_endpoint.assert_called_with( - name="test_project-endpoint | 123456", - template_id="test_template_id", - network_volume_id="test_storage_id", - ) - - mock_update_endpoint.return_value = {"id": "test_endpoint_id"} - mock_get_project_endpoint.return_value = {"id": "test_endpoint_id"} - self.assertEqual(create_project_endpoint(), "test_endpoint_id") diff --git a/tests/test_cli/test_cli_groups/test_project_helpers.py b/tests/test_cli/test_cli_groups/test_project_helpers.py deleted file mode 100644 index 9b3437c8..00000000 --- a/tests/test_cli/test_cli_groups/test_project_helpers.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tests for the project helpers.""" - -import unittest -from unittest.mock import mock_open, patch - -import click - -from runpod import error as rp_error -from runpod.cli.groups.project.helpers import ( - attempt_pod_launch, - copy_template_files, - get_project_endpoint, - get_project_pod, - load_project_config, - validate_project_name, -) - - -class TestHelpers(unittest.TestCase): - """Test the project helpers.""" - - def test_validate_project_name_valid(self): - """Test the validate_project_name function with valid input.""" - name = "validProjectName" - result = validate_project_name(name) - self.assertEqual(result, name) - - def test_validate_project_name_invalid(self): - """Test the validate_project_name function with invalid input.""" - name = "invalid:name" - with self.assertRaises(click.BadParameter): - validate_project_name(name) - - @patch("runpod.cli.groups.project.helpers.get_pods") - def test_get_project_pod_exists(self, mock_get_pods): - """Test the get_project_pod function when the project pod exists.""" - mock_get_pods.return_value = [{"name": "test-1234", "id": "pod_id"}] - result = get_project_pod("1234") - self.assertEqual(result, "pod_id") - - @patch("runpod.cli.groups.project.helpers.get_pods") - def test_get_project_pod_not_exists(self, mock_get_pods): - """Test the get_project_pod function when the project pod doesn't exist.""" - mock_get_pods.return_value = [{"name": "another-5678", "id": "another_pod_id"}] - result = get_project_pod("1234") - self.assertIsNone(result) - - @patch("runpod.cli.groups.project.helpers.get_endpoints") - def test_get_project_endpoint_exists(self, mock_get_endpoints): - """Test the get_project_endpoint function when the project endpoint exists.""" - mock_get_endpoints.return_value = [] - assert get_project_endpoint("1234") is None - - mock_get_endpoints.return_value = [{"name": "test-1234", "id": "endpoint_id"}] - result = get_project_endpoint("1234") - self.assertEqual(result, {"name": "test-1234", "id": "endpoint_id"}) - - @patch("os.listdir") - @patch("os.path.isdir", return_value=False) - @patch("shutil.copy2") - def test_copy_template_files(self, mock_copy, mock_isdir, mock_listdir): - """Test the copy_template_files function.""" - mock_listdir.return_value = ["file1.txt", "file2.txt"] - copy_template_files("/template", "/destination") - self.assertEqual(mock_copy.call_count, 2) - assert mock_isdir.called - - @patch("os.listdir") - @patch("os.path.isdir", return_value=True) - @patch("shutil.copytree") - def test_copy_template_files_dir(self, mock_copy, mock_isdir, mock_listdir): - """Test the copy_template_files function.""" - mock_listdir.return_value = ["file1.txt", "file2.txt"] - copy_template_files("/template", "/destination") - self.assertEqual(mock_copy.call_count, 2) - assert mock_isdir.called - - @patch("runpod.cli.groups.project.helpers.create_pod") - def test_attempt_pod_launch_success(self, mock_create_pod): - """Test the attempt_pod_launch function when it succeeds.""" - mock_create_pod.return_value = "pod_id" - config = { - "project": { - "name": "test", - "uuid": "1234", - "base_image": "base_image", - "gpu_types": ["gpu_type"], - "gpu_count": "1", - "ports": "ports", - "storage_id": "storage_id", - "volume_mount_path": "volume_mount_path", - "container_disk_size_gb": "1", - } - } - environment_variables = {"key": "value"} - result = attempt_pod_launch(config, environment_variables) - self.assertEqual(result, "pod_id") - - mock_create_pod.side_effect = rp_error.QueryError("error") - assert attempt_pod_launch(config, environment_variables) is None - - @patch("os.path.exists", return_value=True) - @patch("builtins.open", new_callable=mock_open, read_data="[project]\nname='test'") - def test_load_project_config(self, mock_file, mock_exists): - """Test the load_project_config function.""" - config = load_project_config() - self.assertEqual(config["project"]["name"], "test") - assert mock_exists.called - assert mock_file.called - - with patch("os.path.exists", return_value=False), self.assertRaises( - FileNotFoundError - ): - load_project_config() diff --git a/tests/test_cli/test_cli_groups/test_ssh_dispatch.py b/tests/test_cli/test_cli_groups/test_ssh_dispatch.py new file mode 100644 index 00000000..fa74cc13 --- /dev/null +++ b/tests/test_cli/test_cli_groups/test_ssh_dispatch.py @@ -0,0 +1,41 @@ +"""rp ssh: pod-id dispatch and subcommand resolution.""" + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from runpod.rp_cli.main import cli + + +class TestSSHDispatch: + def test_pod_id_routes_to_connect(self): + runner = CliRunner() + with patch( + "runpod.cli.utils.ssh_cmd.SSHConnection" + ) as connection: + connection.return_value = MagicMock() + result = runner.invoke(cli, ["ssh", "pod-abc123"]) + assert "Connecting to pod pod-abc123" in result.output + connection.assert_called_once_with("pod-abc123") + + def test_connect_subcommand(self): + runner = CliRunner() + with patch( + "runpod.cli.utils.ssh_cmd.SSHConnection" + ) as connection: + connection.return_value = MagicMock() + result = runner.invoke(cli, ["ssh", "connect", "pod-abc123"]) + assert "Connecting to pod pod-abc123" in result.output + + def test_named_subcommands_still_resolve(self): + runner = CliRunner() + with patch( + "runpod.cli.groups.ssh.commands.get_user_pub_keys", return_value=[] + ): + result = runner.invoke(cli, ["ssh", "list"]) + assert result.exit_code == 0 + + def test_bare_ssh_shows_help(self): + runner = CliRunner() + result = runner.invoke(cli, ["ssh"]) + assert "Commands:" in result.output diff --git a/tests/test_cli/test_cli_utils/test_info.py b/tests/test_cli/test_cli_utils/test_info.py index 8f2d4ecc..464c96a8 100644 --- a/tests/test_cli/test_cli_utils/test_info.py +++ b/tests/test_cli/test_cli_utils/test_info.py @@ -41,5 +41,5 @@ def test_get_pod_ssh_ip_port_timeout(self): mock_get_pod.return_value = {} - with pytest.raises(TimeoutError): + with pytest.raises(ValueError, match="not found"): get_pod_ssh_ip_port("pod_id", timeout=0.1) diff --git a/tests/test_cli/test_cli_utils/test_sync.py b/tests/test_cli/test_cli_utils/test_sync.py deleted file mode 100644 index adfc998f..00000000 --- a/tests/test_cli/test_cli_utils/test_sync.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Tests for runpod.cli.utils.rp_sync module.""" - -import time -import unittest -from unittest.mock import ANY, MagicMock, patch - -from runpod.cli import STOP_EVENT -from runpod.cli.utils.rp_sync import WatcherHandler, start_watcher, sync_directory - - -class TestWatcherHandler(unittest.TestCase): - """Tests for the WatcherHandler class.""" - - @patch("runpod.cli.utils.rp_sync.should_ignore") - def test_on_any_event_with_ignored_file(self, mock_should_ignore): - """Test that the action function is not called when the file is ignored.""" - mock_should_ignore.return_value = True - mock_action_function = MagicMock() - handler = WatcherHandler(mock_action_function, "some_path") - - event_mock = MagicMock() - event_mock.is_directory = False - event_mock.src_path = "some_path/ignored_file.txt" - - handler.on_any_event(event_mock) - mock_action_function.assert_not_called() - - @patch("runpod.cli.utils.rp_sync.should_ignore") - def test_on_any_event_with_not_ignored_file(self, mock_should_ignore): - """Test that the action function is called when the file is not ignored.""" - mock_should_ignore.return_value = False - mock_action_function = MagicMock() - handler = WatcherHandler(mock_action_function, "some_path") - - event_mock = MagicMock() - event_mock.is_directory = False - event_mock.src_path = "some_path/not_ignored_file.txt" - - handler.on_any_event(event_mock) - handler.on_any_event(event_mock) # Call it twice to test the debouncer - time.sleep(2) - mock_action_function.assert_called_once() - - def test_on_any_event_with_directory(self): - """Test that the action function is not called when the event is a directory.""" - mock_action_function = MagicMock() - handler = WatcherHandler(mock_action_function, "some_path") - - event_mock = MagicMock() - event_mock.is_directory = True - - handler.on_any_event(event_mock) - time.sleep(2) - mock_action_function.assert_not_called() - - -class TestSyncDirectory(unittest.TestCase): - """Tests for the sync_directory function.""" - - @patch("runpod.cli.utils.rp_sync.threading.Thread") - @patch("runpod.cli.utils.rp_sync.start_watcher") - def test_sync_directory(self, mock_start_watcher, mock_thread_class): - """Test that the sync_directory function calls the start_watcher function.""" - mock_ssh_client = MagicMock() - - local_path = "local_path" - remote_path = "remote_path" - - sync_directory(mock_ssh_client, local_path, remote_path) - - target_function = mock_thread_class.call_args[1]["target"] - target_function() - - mock_start_watcher.assert_called_once() - - @patch("runpod.cli.utils.rp_sync.threading.Thread") - @patch("runpod.cli.utils.rp_sync.start_watcher") - def test_sync_directory_sync_function(self, mock_start_watcher, mock_thread_class): - """Test that the sync_directory function calls the start_watcher function.""" - mock_ssh_client = MagicMock() - - local_path = "local_path" - remote_path = "remote_path" - - sync_function = sync_directory(mock_ssh_client, local_path, remote_path) - sync_function() - - mock_ssh_client.rsync.assert_called_once_with( - local_path, remote_path, quiet=True - ) - - mock_thread_class.assert_called_once() - mock_thread_class.assert_called_with( - target=mock_start_watcher, daemon=True, args=(ANY, local_path) - ) - - assert mock_start_watcher.called is False - - -class TestStartWatcher(unittest.TestCase): - """Tests for the start_watcher function.""" - - @patch("runpod.cli.utils.rp_sync.Observer") - @patch("runpod.cli.utils.rp_sync.WatcherHandler") - def test_start_watcher(self, mock_watch_handler, mock_observer_class): - """Test that the start_watcher function starts the watcher correctly.""" - fake_action = MagicMock() - local_path = "/path/to/watch" - - mock_observer_instance = mock_observer_class.return_value - - STOP_EVENT.clear() - with patch("runpod.cli.utils.rp_sync.time.sleep") as mock_sleep: - - def side_effect(*args, **kwargs): - del args, kwargs - STOP_EVENT.set() - - mock_sleep.side_effect = side_effect - start_watcher(fake_action, local_path) - - mock_watch_handler.assert_called_once_with(fake_action, local_path) - - mock_observer_instance.schedule.assert_called_once_with( - mock_watch_handler.return_value, local_path, recursive=True - ) - - mock_observer_instance.start.assert_called_once() - mock_observer_instance.stop.assert_called_once() - mock_observer_instance.join.assert_called_once() diff --git a/tests/test_cli/test_console.py b/tests/test_cli/test_console.py new file mode 100644 index 00000000..4fe4717a --- /dev/null +++ b/tests/test_cli/test_console.py @@ -0,0 +1,229 @@ +"""rendering tests for the rp cli console module.""" + +import io + +import pytest +from rich.console import Console + +from runpod.rp_cli import console as ui + + +@pytest.fixture(autouse=True) +def capture_console(monkeypatch): + """swap the module console for one writing to a buffer.""" + buffer = io.StringIO() + test_console = Console( + file=buffer, + highlight=False, + theme=ui.theme, + force_terminal=False, + width=120, + ) + monkeypatch.setattr(ui, "console", test_console) + yield buffer + + +def _out(buffer): + return buffer.getvalue() + + +class TestGenericLines: + def test_info(self, capture_console): + ui.info("hello") + assert "hello" in _out(capture_console) + + def test_success(self, capture_console): + ui.success("done") + assert "✓ done" in _out(capture_console) + + def test_error(self, capture_console): + ui.error("bad") + assert "✗ bad" in _out(capture_console) + + def test_warn(self, capture_console): + ui.warn("careful") + assert "! careful" in _out(capture_console) + + +class TestHelpers: + def test_endpoint_url(self): + assert ui.endpoint_url("ep1").endswith("/ep1?tab=overview") + + def test_endpoint_link_markup(self): + assert "ep1 ↗" in ui.endpoint_link("ep1") + + def test_name_width_padding(self): + ui.set_name_width(["a", "longer"]) + assert ui._padded("a") == "a " + ui.set_name_width([]) + assert ui._padded("a") == "a" + + def test_kind_badge_known_and_unknown(self): + assert "queue" in ui.kind_badge("queue") + assert "other" in ui.kind_badge("other") + + def test_fmt_elapsed(self): + assert ui._fmt_elapsed(0.5) == "500ms" + assert ui._fmt_elapsed(3.21) == "3.2s" + assert ui._fmt_elapsed(150) == "2m30s" + + def test_bar_bounds(self): + assert "━" in ui._bar(0.5) + ui._bar(-1) + ui._bar(2) + + def test_sand_spinner_registered(self): + from rich.spinner import SPINNERS + + assert ui.RUNPOD_SAND_SPINNER in SPINNERS + assert ( + SPINNERS[ui.RUNPOD_SAND_SPINNER]["frames"] + == ui.RUNPOD_SAND_SPINNER_FRAMES + ) + + +class TestBanners: + def test_dev_banner(self, capture_console): + ui.dev_banner(["demo"], "main.py") + assert "demo in main.py" in _out(capture_console) + + def test_deploy_plan(self, capture_console): + ui.deploy_plan([("demo", "main.py", 2), ("other", "", 1)]) + out = _out(capture_console) + assert "2 apps" in out + assert "main.py, 2 resources" in out + assert "1 resource" in out + + def test_deploy_banner(self, capture_console): + ui.deploy_banner("demo", "prod", [("chat", "queue")], source="main.py") + out = _out(capture_console) + assert "demo" in out + assert "prod" in out + assert "chat" in out + + def test_deploy_banner_no_resources(self, capture_console): + ui.deploy_banner("demo", "prod", []) + assert "(no resources)" in _out(capture_console) + + def test_resources_table(self, capture_console): + ui.resources_table( + [ + ("chat", "queue", "4090", "ep1"), + ("train", "task", "H100", "per-call"), + ] + ) + out = _out(capture_console) + assert "chat" in out + assert "per-call" in out + + def test_resources_table_empty(self, capture_console): + ui.resources_table([]) + assert _out(capture_console) == "" + + def test_reload_banner(self, capture_console): + ui.reload_banner("main.py") + assert "reloading" in _out(capture_console) + + def test_entrypoint_lines(self, capture_console): + ui.entrypoint_header("main") + ui.entrypoint_success(1.2) + ui.entrypoint_failure(3.4, "boom") + out = _out(capture_console) + assert "main()" in out + assert "done" in out + assert "boom" in out + + +class TestDeployEvents: + def test_phase_lifecycle(self, capture_console): + events = ui.DeployEvents() + events.phase("vendor") + events.vendor_progress(3, "torch") + events.phase("upload") + events.upload_progress(512 * 1024, 1024 * 1024) + events.phase("endpoints") + events.endpoint_ready("chat", "ep1") + events.close() + assert events.endpoints == {"chat": "ep1"} + + def test_progress_updates_ignored_outside_phase(self): + events = ui.DeployEvents() + events.vendor_progress(1, "torch") + events.upload_progress(1, 2) + events.close() + + +class TestDevEvents: + def test_request_lifecycle(self, capture_console): + events = ui.DevEvents() + events.dispatch("chat", "queued") + events.worker_ready("chat", "worker123456789") + events.worker_log("chat", "processing [1/3]") + events.worker_status("chat", {"initializing": 1}) + events.task_status("chat", "pod starting") + events.request_completed("chat", 2.5) + events.request_failed("chat", 1.0) + out = _out(capture_console) + assert "→ chat()" in out + assert "worker worker123456 ready" in out + assert "processing [1/3]" in out # markup escaped, shown verbatim + assert "waiting: 1 initializing" in out + assert "pod starting" in out + assert "✓ chat()" in out + assert "✗ chat()" in out + + def test_provision_rows_without_progress(self, capture_console): + events = ui.DevEvents() + events.provisioning("chat", "queue", "4090") + events.adopted("chat", "ep1") + events.ready("chat", "ep1") + events.deleted("chat") + out = _out(capture_console) + assert "provisioning queue on 4090" in out + assert "− chat" in out + + def test_session_progress_lifecycle(self, capture_console): + events = ui.DevEvents() + events.session_starting() + events.provisioning("chat", "queue", "4090") + events.ready("chat", "ep1") + events.session_started() + assert events._progress is None + + def test_refresh_diff(self, capture_console): + events = ui.DevEvents() + events.resource_added("new", "queue", "4090") + events.resource_changed("chat", ["gpu"]) + events.resource_changed("chat", []) + events.resource_removed("old") + events.volume_created("data", 10, "US-KS-2") + out = _out(capture_console) + assert "+ new" in out + assert "~ chat" in out + assert "− old" in out + assert "volume data" in out + + +class TestCleanupEvents: + def test_zero_total_is_silent(self, capture_console): + events = ui.CleanupEvents() + events.cleanup_started(0) + events.deleting("x") + events.deleted("x") + events.close() + + def test_delete_progress(self, capture_console): + events = ui.CleanupEvents() + events.cleanup_started(2) + events.deleting("ep1") + events.deleted("ep1") + events.delete_failed("ep2") + events.close() + assert "could not delete ep2" in _out(capture_console) + + +class TestTimer: + def test_elapsed(self): + with ui.Timer() as t: + assert t.so_far >= 0 + assert t.elapsed >= 0 diff --git a/tests/test_cli/test_deploy_command.py b/tests/test_cli/test_deploy_command.py new file mode 100644 index 00000000..8a019656 --- /dev/null +++ b/tests/test_cli/test_deploy_command.py @@ -0,0 +1,223 @@ +"""tests for rp deploy and rp dev command wiring.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from click.testing import CliRunner + +from runpod.apps.deploy import DeployResult +from runpod.rp_cli.main import cli + + +@pytest.fixture(autouse=True) +def clean_entrypoints(): + from runpod.apps.entrypoint import _clear_entrypoints + + _clear_entrypoints() + yield + _clear_entrypoints() + +APP_SOURCE = ''' +import runpod + +app = runpod.App("demo") + + +@app.queue(name="chat", gpu="4090") +def chat(prompt: str): + return prompt +''' + +TWO_APP_SOURCE = APP_SOURCE + ''' + +other = runpod.App("other") + + +@other.queue(name="embed", gpu="4090") +def embed(text: str): + return text +''' + +ENTRYPOINT_SOURCE = APP_SOURCE + ''' + +@runpod.local_entrypoint +def main(): + pass +''' + + +def _runner(): + return CliRunner() + + +def _result(name="demo", endpoints=None): + return DeployResult( + app_name=name, + build_id="b1", + environment_id="env1", + resources=["chat"], + endpoints=endpoints or {"chat": "ep1"}, + ) + + +class TestDeploy: + def test_deploys_discovered_app(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock(return_value=_result()) + with patch("runpod.apps.deploy.deploy_app", deploy_app): + result = _runner().invoke(cli, ["deploy"]) + assert result.exit_code == 0, result.output + assert "demo/default" in result.output + assert "ep1" in result.output + deploy_app.assert_awaited_once() + + def test_env_flag(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock(return_value=_result()) + with patch("runpod.apps.deploy.deploy_app", deploy_app): + result = _runner().invoke(cli, ["deploy", "--env", "prod"]) + assert result.exit_code == 0 + assert deploy_app.call_args[1]["env_name"] == "prod" + assert "demo/prod" in result.output + + def test_multi_app_plan(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(TWO_APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock( + side_effect=[_result("demo"), _result("other", {"embed": "ep2"})] + ) + with patch("runpod.apps.deploy.deploy_app", deploy_app): + result = _runner().invoke(cli, ["deploy"]) + assert result.exit_code == 0, result.output + assert "2 apps" in result.output + assert deploy_app.await_count == 2 + + def test_missing_target(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = _runner().invoke(cli, ["deploy", "missing_dir"]) + assert result.exit_code == 1 + assert "does not exist" in result.output + + def test_no_apps_found(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text("x = 1\n") + monkeypatch.chdir(tmp_path) + result = _runner().invoke(cli, ["deploy"]) + assert result.exit_code == 1 + assert "no runpod.App found" in result.output + + def test_engine_error_is_clean(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock(side_effect=RuntimeError("upload failed")) + with patch("runpod.apps.deploy.deploy_app", deploy_app): + result = _runner().invoke(cli, ["deploy"]) + assert result.exit_code == 1 + assert "upload failed" in result.output + assert "Traceback" not in result.output + + def test_build_only(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + artifact = tmp_path / "demo-artifact.tar.gz" + artifact.write_bytes(b"x" * 100) + build = MagicMock(return_value=artifact) + with patch("runpod.apps.deploy.build_artifact", build): + result = _runner().invoke(cli, ["deploy", "--build-only"]) + assert result.exit_code == 0, result.output + assert "demo-artifact.tar.gz" in result.output + assert build.call_args[1]["output"] == artifact + + def test_exclude_passthrough(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock(return_value=_result()) + with patch("runpod.apps.deploy.deploy_app", deploy_app): + result = _runner().invoke( + cli, ["deploy", "--exclude", "torch,numpy"] + ) + assert result.exit_code == 0 + assert deploy_app.call_args[1]["exclude"] == ["torch", "numpy"] + + def test_python_version_fallback_warns(self, tmp_path, monkeypatch): + (tmp_path / "main.py").write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + deploy_app = AsyncMock(return_value=_result()) + with ( + patch("runpod.apps.deploy.deploy_app", deploy_app), + patch( + "runpod.apps.images.local_python_version", + side_effect=RuntimeError("3.9 unsupported"), + ), + ): + result = _runner().invoke(cli, ["deploy"]) + assert result.exit_code == 0 + assert "no runtime image" in result.output + + +class TestDev: + def test_once_runs_entrypoint_and_stops(self, tmp_path, monkeypatch): + module = tmp_path / "main.py" + module.write_text(ENTRYPOINT_SOURCE) + monkeypatch.chdir(tmp_path) + + session = MagicMock() + session.start = AsyncMock() + session.stop = AsyncMock() + session.refresh = AsyncMock() + session._endpoints = {"dev-demo-chat": "ep1"} + + def make_session(apps, events=None): + session.apps = apps + return session + + with patch("runpod.apps.dev.DevSession", side_effect=make_session): + result = _runner().invoke(cli, ["dev", str(module), "--once"]) + assert result.exit_code == 0, result.output + session.start.assert_awaited_once() + session.stop.assert_awaited_once() + + def test_once_entrypoint_failure_exits_nonzero(self, tmp_path, monkeypatch): + module = tmp_path / "main.py" + module.write_text( + APP_SOURCE + + "\n@runpod.local_entrypoint\ndef main():\n" + + " raise RuntimeError('assertion blew up')\n" + ) + monkeypatch.chdir(tmp_path) + + session = MagicMock() + session.start = AsyncMock() + session.stop = AsyncMock() + session._endpoints = {} + + def make_session(apps, events=None): + session.apps = apps + return session + + with patch("runpod.apps.dev.DevSession", side_effect=make_session): + result = _runner().invoke(cli, ["dev", str(module), "--once"]) + assert result.exit_code == 1 + session.stop.assert_awaited_once() + + def test_module_without_entrypoint(self, tmp_path, monkeypatch): + module = tmp_path / "main.py" + module.write_text(APP_SOURCE) + monkeypatch.chdir(tmp_path) + result = _runner().invoke(cli, ["dev", str(module), "--once"]) + assert result.exit_code == 1 + assert "local_entrypoint" in result.output + + def test_module_without_app(self, tmp_path, monkeypatch): + module = tmp_path / "main.py" + module.write_text("x = 1\n") + monkeypatch.chdir(tmp_path) + result = _runner().invoke(cli, ["dev", str(module), "--once"]) + assert result.exit_code == 1 + assert "no runpod.App" in result.output + + def test_missing_module(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = _runner().invoke(cli, ["dev", "missing.py", "--once"]) + assert result.exit_code == 2 diff --git a/tests/test_cli/test_init_command.py b/tests/test_cli/test_init_command.py new file mode 100644 index 00000000..48d969ea --- /dev/null +++ b/tests/test_cli/test_init_command.py @@ -0,0 +1,82 @@ +"""rp init: project scaffolding.""" + +from pathlib import Path + +from click.testing import CliRunner + +from runpod.apps.init import create_project, detect_conflicts +from runpod.rp_cli.main import cli + + +class TestCreateProject: + def test_writes_skeleton(self, tmp_path): + written = create_project(tmp_path, "my-app") + names = {p.name for p in written} + assert names == {"main.py", "requirements.txt", ".runpodignore"} + main = (tmp_path / "main.py").read_text() + assert 'App("my-app")' in main + assert "@runpod.local_entrypoint" in main + + def test_skeleton_module_is_valid_python(self, tmp_path): + create_project(tmp_path, "my-app") + compile((tmp_path / "main.py").read_text(), "main.py", "exec") + + def test_existing_files_kept_without_overwrite(self, tmp_path): + (tmp_path / "main.py").write_text("original") + written = create_project(tmp_path, "my-app") + assert (tmp_path / "main.py").read_text() == "original" + assert Path(tmp_path / "requirements.txt") not in written or True + assert (tmp_path / "requirements.txt").exists() + + def test_overwrite_replaces_files(self, tmp_path): + (tmp_path / "main.py").write_text("original") + create_project(tmp_path, "my-app", overwrite=True) + assert 'App("my-app")' in (tmp_path / "main.py").read_text() + + def test_creates_directory(self, tmp_path): + target = tmp_path / "new-project" + create_project(target, "new-project") + assert (target / "main.py").exists() + + +class TestDetectConflicts: + def test_empty_dir_no_conflicts(self, tmp_path): + assert detect_conflicts(tmp_path) == [] + + def test_existing_files_reported(self, tmp_path): + (tmp_path / "main.py").write_text("x") + assert detect_conflicts(tmp_path) == ["main.py"] + + +class TestInitCommand: + def test_init_new_project(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(cli, ["init", "demo"]) + assert result.exit_code == 0, result.output + assert (tmp_path / "demo" / "main.py").exists() + + def test_init_current_directory(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(cli, ["init", "."]) + assert result.exit_code == 0, result.output + assert (tmp_path / "main.py").exists() + assert tmp_path.name in (tmp_path / "main.py").read_text() + + def test_init_conflicts_fail_without_force(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "main.py").write_text("keep me") + runner = CliRunner() + result = runner.invoke(cli, ["init", "."]) + assert result.exit_code != 0 + assert "main.py" in result.output + assert (tmp_path / "main.py").read_text() == "keep me" + + def test_init_force_overwrites(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "main.py").write_text("old") + runner = CliRunner() + result = runner.invoke(cli, ["init", ".", "--force"]) + assert result.exit_code == 0, result.output + assert "App(" in (tmp_path / "main.py").read_text() diff --git a/tests/test_cli/test_management_commands.py b/tests/test_cli/test_management_commands.py new file mode 100644 index 00000000..131aceff --- /dev/null +++ b/tests/test_cli/test_management_commands.py @@ -0,0 +1,357 @@ +"""tests for app/env/secret/registry/logs cli commands.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from click.testing import CliRunner + +from runpod.apps.manage import UndeployResult +from runpod.rp_cli.main import cli + + +def _runner(): + return CliRunner() + + +class TestAppCommands: + def test_list_empty(self): + with patch("runpod.apps.manage.list_apps", AsyncMock(return_value=[])): + result = _runner().invoke(cli, ["app", "list"]) + assert result.exit_code == 0 + assert "no apps deployed" in result.output + + def test_list(self): + apps = [ + { + "name": "demo", + "flashEnvironments": [{"name": "default"}, {"name": "prod"}], + }, + {"name": "other", "flashEnvironments": []}, + ] + with patch( + "runpod.apps.manage.list_apps", AsyncMock(return_value=apps) + ): + result = _runner().invoke(cli, ["app", "list"]) + assert result.exit_code == 0 + assert "demo" in result.output + assert "default, prod" in result.output + + def test_get(self): + entry = { + "name": "demo", + "flashEnvironments": [ + {"name": "default", "activeBuildId": "build123456789"} + ], + } + with patch( + "runpod.apps.manage.get_app", AsyncMock(return_value=entry) + ): + result = _runner().invoke(cli, ["app", "get", "demo"]) + assert result.exit_code == 0 + assert "build1234567" in result.output + + def test_get_error(self): + with patch( + "runpod.apps.manage.get_app", + AsyncMock(side_effect=RuntimeError("no app named 'demo'")), + ): + result = _runner().invoke(cli, ["app", "get", "demo"]) + assert result.exit_code == 1 + assert "no app named" in result.output + + def test_delete_confirmed(self): + outcome = UndeployResult(endpoints_deleted=2, failures=[]) + with patch( + "runpod.apps.manage.delete_app", AsyncMock(return_value=outcome) + ): + result = _runner().invoke(cli, ["app", "delete", "demo", "--yes"]) + assert result.exit_code == 0 + + def test_delete_aborts_without_confirmation(self): + result = _runner().invoke(cli, ["app", "delete", "demo"], input="n\n") + assert result.exit_code == 1 + + def test_delete_failures_keep_app(self): + outcome = UndeployResult(endpoints_deleted=0, failures=["ep1 stuck"]) + with patch( + "runpod.apps.manage.delete_app", AsyncMock(return_value=outcome) + ): + result = _runner().invoke(cli, ["app", "delete", "demo", "--yes"]) + assert result.exit_code == 1 + assert "undeploy incomplete" in result.output + + +class TestEnvCommands: + def test_list(self): + entry = { + "flashEnvironments": [ + { + "name": "default", + "activeBuildId": "build123456789", + "createdAt": "2026-01-02T03:04:05Z", + } + ] + } + with patch( + "runpod.apps.manage.get_app", AsyncMock(return_value=entry) + ): + result = _runner().invoke( + cli, ["env", "list", "--app", "demo"] + ) + assert result.exit_code == 0 + assert "default" in result.output + + def test_list_empty(self): + with patch( + "runpod.apps.manage.get_app", + AsyncMock(return_value={"flashEnvironments": []}), + ): + result = _runner().invoke(cli, ["env", "list", "--app", "demo"]) + assert result.exit_code == 0 + assert "no environments" in result.output + + def test_get(self): + entry = { + "name": "prod", + "activeBuildId": "build123456789", + "endpoints": [{"name": "chat", "id": "ep1"}], + } + with patch( + "runpod.apps.manage.get_environment", + AsyncMock(return_value=entry), + ): + result = _runner().invoke( + cli, ["env", "get", "prod", "--app", "demo"] + ) + assert result.exit_code == 0 + assert "demo/prod" in result.output + assert "chat" in result.output + + def test_add(self): + client = MagicMock() + client.create_environment = AsyncMock(return_value={"id": "env1"}) + with ( + patch("runpod.apps.api.AppsApiClient", return_value=client), + patch( + "runpod.apps.manage.get_app", + AsyncMock(return_value={"id": "app1"}), + ), + ): + result = _runner().invoke( + cli, ["env", "add", "staging", "--app", "demo"] + ) + assert result.exit_code == 0 + client.create_environment.assert_awaited_once_with("app1", "staging") + + def test_delete(self): + outcome = UndeployResult(endpoints_deleted=1, failures=[]) + with patch( + "runpod.apps.manage.undeploy_environment", + AsyncMock(return_value=outcome), + ): + result = _runner().invoke( + cli, ["env", "delete", "prod", "--app", "demo", "--yes"] + ) + assert result.exit_code == 0 + + def test_resolve_app_name_fails_without_unique_app(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = _runner().invoke(cli, ["env", "list"]) + assert result.exit_code == 1 + assert "--app" in result.output + + +class TestUndeploy: + def test_undeploy(self): + outcome = UndeployResult(endpoints_deleted=3, failures=[]) + with patch( + "runpod.apps.manage.undeploy_environment", + AsyncMock(return_value=outcome), + ): + result = _runner().invoke( + cli, ["undeploy", "--app", "demo", "--yes"] + ) + assert result.exit_code == 0 + assert "3 endpoints removed" in result.output + + def test_undeploy_failures(self): + outcome = UndeployResult(endpoints_deleted=0, failures=["boom"]) + with patch( + "runpod.apps.manage.undeploy_environment", + AsyncMock(return_value=outcome), + ): + result = _runner().invoke( + cli, ["undeploy", "--app", "demo", "--yes"] + ) + assert result.exit_code == 1 + + +class TestSecretCommands: + def _client(self): + client = MagicMock() + client.create_secret = AsyncMock(return_value={"id": "s1"}) + client.list_secrets = AsyncMock( + return_value=[ + {"id": "s1", "name": "tok", "description": "a token"} + ] + ) + client.delete_secret = AsyncMock(return_value=True) + return client + + def test_add(self): + client = self._client() + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke( + cli, ["secret", "add", "tok", "--value", "v"] + ) + assert result.exit_code == 0 + client.create_secret.assert_awaited_once_with("tok", "v", "") + + def test_add_prompts(self): + client = self._client() + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke( + cli, ["secret", "add"], input="tok\nv\n" + ) + assert result.exit_code == 0 + + def test_list(self): + client = self._client() + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke(cli, ["secret", "list"]) + assert result.exit_code == 0 + assert "tok" in result.output + + def test_list_empty(self): + client = self._client() + client.list_secrets = AsyncMock(return_value=[]) + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke(cli, ["secret", "list"]) + assert "no secrets" in result.output + + def test_delete(self): + client = self._client() + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke( + cli, ["secret", "delete", "tok", "--yes"] + ) + assert result.exit_code == 0 + client.delete_secret.assert_awaited_once_with("s1") + + def test_delete_unknown(self): + client = self._client() + client.list_secrets = AsyncMock(return_value=[]) + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke( + cli, ["secret", "delete", "tok", "--yes"] + ) + assert result.exit_code == 1 + assert "no secret named" in result.output + + +class TestRegistryCommands: + def _client(self): + client = MagicMock() + client.create_registry_auth = AsyncMock(return_value={"id": "r1"}) + client.list_registry_auths = AsyncMock( + return_value=[{"id": "r1", "name": "dockerhub"}] + ) + client.delete_registry_auth = AsyncMock(return_value=True) + return client + + def test_add(self): + client = self._client() + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke( + cli, + [ + "registry", "add", "dockerhub", + "--username", "u", "--password", "p", + ], + ) + assert result.exit_code == 0 + client.create_registry_auth.assert_awaited_once_with( + "dockerhub", "u", "p" + ) + + def test_list(self): + client = self._client() + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke(cli, ["registry", "list"]) + assert result.exit_code == 0 + assert "dockerhub" in result.output + + def test_delete(self): + client = self._client() + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke( + cli, ["registry", "delete", "dockerhub", "--yes"] + ) + assert result.exit_code == 0 + client.delete_registry_auth.assert_awaited_once_with("r1") + + def test_delete_unknown(self): + client = self._client() + client.list_registry_auths = AsyncMock(return_value=[]) + with patch("runpod.apps.api.AppsApiClient", return_value=client): + result = _runner().invoke( + cli, ["registry", "delete", "nope", "--yes"] + ) + assert result.exit_code == 1 + + +class TestLogsCommand: + def test_snapshot(self): + logs = {"system": ["booted"], "container": ["hello"]} + with patch( + "runpod.apps.logs.pod_logs", AsyncMock(return_value=logs) + ): + result = _runner().invoke(cli, ["logs", "pod1"]) + assert result.exit_code == 0 + assert "[system] booted" in result.output + assert "[container] hello" in result.output + + def test_follow(self): + async def fake_stream(pod_id, **kwargs): + yield {"source": "container", "line": "streamed"} + + with patch("runpod.apps.logs.stream_pod_logs", fake_stream): + result = _runner().invoke(cli, ["logs", "pod1", "--follow"]) + assert result.exit_code == 0 + assert "[container] streamed" in result.output + + +class TestLoginCommand: + def test_api_key_flag(self): + with patch( + "runpod.cli.groups.config.functions.set_credentials" + ) as creds: + result = _runner().invoke( + cli, ["login", "--api-key", "sk-test"] + ) + assert result.exit_code == 0 + creds.assert_called_once_with("sk-test", overwrite=True) + + def test_browser_flow(self): + with ( + patch( + "runpod.apps.auth.browser_login", + AsyncMock(return_value="sk-granted"), + ), + patch( + "runpod.cli.groups.config.functions.set_credentials" + ) as creds, + ): + result = _runner().invoke(cli, ["login", "--no-open"]) + assert result.exit_code == 0 + creds.assert_called_once_with("sk-granted", overwrite=True) + + def test_browser_flow_error(self): + from runpod.apps.auth import LoginError + + with patch( + "runpod.apps.auth.browser_login", + AsyncMock(side_effect=LoginError("expired")), + ): + result = _runner().invoke(cli, ["login", "--no-open"]) + assert result.exit_code == 1 + assert "expired" in result.output diff --git a/tests/test_cli/test_update_command.py b/tests/test_cli/test_update_command.py new file mode 100644 index 00000000..7c941e65 --- /dev/null +++ b/tests/test_cli/test_update_command.py @@ -0,0 +1,264 @@ +"""rp update and the passive update check.""" + +import json +import threading +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from runpod.rp_cli import update as upd +from runpod.rp_cli.main import cli + + +class TestParseVersion: + def test_release(self): + assert upd.parse_version("1.8.0") == (1, 8, 0) + + def test_dev_suffix_ignored(self): + assert upd.parse_version("1.8.0.dev3") == (1, 8, 0) + + def test_two_part(self): + assert upd.parse_version("2.0") == (2, 0) + + +class TestCompareVersions: + def test_orders(self): + assert upd.compare_versions((1, 0), (2, 0)) < 0 + assert upd.compare_versions((2, 1), (2, 0)) > 0 + + def test_pads_shorter(self): + assert upd.compare_versions((2, 0), (2, 0, 0)) == 0 + + +class TestInstallCommand: + def test_prefers_uv(self): + with patch.object(upd.shutil, "which", return_value="/usr/bin/uv"): + cmd = upd.install_command("1.2.3") + assert cmd[0] == "uv" + assert "runpod==1.2.3" in cmd + + def test_falls_back_to_pip(self): + with patch.object(upd.shutil, "which", return_value=None): + cmd = upd.install_command("1.2.3") + assert cmd[1:4] == ["-m", "pip", "install"] + + +class TestUpdateCommand: + def test_already_current(self): + runner = CliRunner() + with patch.object( + upd, "fetch_pypi_metadata", return_value=("9.9.9", {"9.9.9"}) + ), patch.object(upd, "current_version", return_value="9.9.9"), patch( + "runpod.rp_cli.main.cli.callback" + ): + result = runner.invoke(cli, ["update"]) + assert result.exit_code == 0, result.output + assert "nothing to do" in result.output + + def test_unknown_version_fails(self): + runner = CliRunner() + with patch.object( + upd, "fetch_pypi_metadata", return_value=("9.9.9", {"9.9.9"}) + ): + result = runner.invoke(cli, ["update", "--version", "0.0.0.404"]) + assert result.exit_code != 0 + assert "not found" in result.output + + def test_installs_target(self): + runner = CliRunner() + with patch.object( + upd, "fetch_pypi_metadata", return_value=("9.9.9", {"9.9.9"}) + ), patch.object(upd, "current_version", return_value="1.0.0"), patch.object( + upd, "run_install" + ) as install: + result = runner.invoke(cli, ["update"]) + assert result.exit_code == 0, result.output + install.assert_called_once_with("9.9.9") + + +class TestBackgroundCheckCache: + def test_fresh_cache_skips_fetch(self, tmp_path, monkeypatch): + cache = tmp_path / "update_check.json" + monkeypatch.setattr(upd, "CACHE_PATH", cache) + from datetime import datetime, timezone + + cache.write_text( + json.dumps( + { + "last_checked_utc": datetime.now(timezone.utc).isoformat(), + "latest_version": "99.0.0", + } + ) + ) + monkeypatch.setattr(upd, "_newer_version", None) + with patch.object(upd, "fetch_pypi_metadata") as fetch, patch.object( + upd, "current_version", return_value="1.0.0" + ): + upd._run_check() + fetch.assert_not_called() + assert upd._newer_version == "99.0.0" + + def test_stale_cache_refetches(self, tmp_path, monkeypatch): + cache = tmp_path / "update_check.json" + monkeypatch.setattr(upd, "CACHE_PATH", cache) + cache.write_text( + json.dumps( + { + "last_checked_utc": "2000-01-01T00:00:00+00:00", + "latest_version": "0.1.0", + } + ) + ) + monkeypatch.setattr(upd, "_newer_version", None) + with patch.object( + upd, "fetch_pypi_metadata", return_value=("99.0.0", {"99.0.0"}) + ), patch.object(upd, "current_version", return_value="1.0.0"): + upd._run_check() + assert upd._newer_version == "99.0.0" + assert json.loads(cache.read_text())["latest_version"] == "99.0.0" + + def test_current_version_newer_no_notice(self, tmp_path, monkeypatch): + monkeypatch.setattr(upd, "CACHE_PATH", tmp_path / "update_check.json") + monkeypatch.setattr(upd, "_newer_version", None) + with patch.object( + upd, "fetch_pypi_metadata", return_value=("1.0.0", {"1.0.0"}) + ), patch.object(upd, "current_version", return_value="2.0.0"): + upd._run_check() + assert upd._newer_version is None + + def test_check_never_raises(self, tmp_path, monkeypatch): + monkeypatch.setattr(upd, "CACHE_PATH", tmp_path / "update_check.json") + with patch.object( + upd, "fetch_pypi_metadata", side_effect=RuntimeError("boom") + ), patch.object(upd, "current_version", return_value="1.0.0"): + upd._run_check() + + +class TestFetchPypiMetadata: + def _response(self, payload: bytes): + import contextlib + import io + + @contextlib.contextmanager + def _cm(url, timeout=None): + yield io.BytesIO(payload) + + return _cm + + def test_parses_metadata(self, monkeypatch): + import json as _json + + payload = _json.dumps( + {"info": {"version": "1.9.0"}, "releases": {"1.8.0": [], "1.9.0": []}} + ).encode() + monkeypatch.setattr( + upd.urllib.request, "urlopen", self._response(payload) + ) + latest, releases = upd.fetch_pypi_metadata() + assert latest == "1.9.0" + assert releases == {"1.8.0", "1.9.0"} + + def test_network_error(self, monkeypatch): + import urllib.error + + def _raise(url, timeout=None): + raise urllib.error.URLError("no route") + + monkeypatch.setattr(upd.urllib.request, "urlopen", _raise) + with pytest.raises(ConnectionError, match="could not reach pypi"): + upd.fetch_pypi_metadata() + + def test_http_error(self, monkeypatch): + import urllib.error + + def _raise(url, timeout=None): + raise urllib.error.HTTPError(url, 503, "unavailable", {}, None) + + monkeypatch.setattr(upd.urllib.request, "urlopen", _raise) + with pytest.raises(RuntimeError, match="HTTP 503"): + upd.fetch_pypi_metadata() + + def test_garbage_response(self, monkeypatch): + monkeypatch.setattr( + upd.urllib.request, "urlopen", self._response(b"not json") + ) + with pytest.raises(RuntimeError, match="unexpected response"): + upd.fetch_pypi_metadata() + + def test_missing_version_key(self, monkeypatch): + monkeypatch.setattr( + upd.urllib.request, "urlopen", self._response(b"{}") + ) + with pytest.raises(RuntimeError, match="missing version"): + upd.fetch_pypi_metadata() + + +class TestRunInstall: + def test_success(self, monkeypatch): + from unittest.mock import MagicMock + + result = MagicMock(returncode=0) + run = MagicMock(return_value=result) + monkeypatch.setattr(upd.subprocess, "run", run) + upd.run_install("1.9.0") + assert "runpod==1.9.0" in run.call_args[0][0] + + def test_failure_raises(self, monkeypatch): + from unittest.mock import MagicMock + + result = MagicMock(returncode=1, stderr="resolution failed") + monkeypatch.setattr( + upd.subprocess, "run", MagicMock(return_value=result) + ) + with pytest.raises(RuntimeError, match="resolution failed"): + upd.run_install("1.9.0") + + +class TestUpdateNotice: + def test_notice_printed_when_newer(self, monkeypatch, capsys): + monkeypatch.setattr(upd, "_newer_version", "9.9.9") + upd._check_done.set() + upd._print_update_notice() + err = capsys.readouterr().err + assert "9.9.9" in err + assert "rp update" in err + + def test_no_notice_when_current(self, monkeypatch, capsys): + monkeypatch.setattr(upd, "_newer_version", None) + upd._check_done.set() + upd._print_update_notice() + assert capsys.readouterr().err == "" + + def test_no_notice_when_check_incomplete(self, monkeypatch, capsys): + monkeypatch.setattr(upd, "_newer_version", "9.9.9") + monkeypatch.setattr(upd, "_check_done", threading.Event()) + upd._print_update_notice() + assert capsys.readouterr().err == "" + + +class TestStartBackgroundCheck: + def test_starts_once(self, monkeypatch): + monkeypatch.setattr(upd, "_started", False) + monkeypatch.setattr(upd, "_is_interactive", lambda: True) + threads = [] + monkeypatch.setattr( + upd.threading, + "Thread", + lambda **kw: threads.append(kw) or MagicMock(), + ) + upd.start_background_check() + upd.start_background_check() + assert len(threads) == 1 + + def test_skips_non_tty(self, monkeypatch): + monkeypatch.setattr(upd, "_started", False) + monkeypatch.setattr(upd, "_is_interactive", lambda: False) + threads = [] + monkeypatch.setattr( + upd.threading, + "Thread", + lambda **kw: threads.append(kw) or MagicMock(), + ) + upd.start_background_check() + assert threads == [] diff --git a/tests/test_init.py b/tests/test_init.py index 91242328..52339fce 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -98,6 +98,10 @@ def test_all_covers_expected_public_api(self): 'check_credentials', 'get_credentials', 'set_credentials', # Endpoint classes 'AsyncioEndpoint', 'AsyncioJob', 'Endpoint', + # Apps surface + 'Api', 'App', 'CpuInstanceType', 'DataCenter', 'EndpointNotFound', 'GpuGroup', + 'GpuType', 'Job', 'Model', 'Queue', 'Secret', 'Volume', 'delete', 'get', + 'init', 'is_local', 'local_entrypoint', 'patch', 'post', 'put', 'schedule', # Serverless module 'serverless', # Logger class diff --git a/tests/test_shared/test_auth.py b/tests/test_shared/test_auth.py index 00f85848..2cb93def 100644 --- a/tests/test_shared/test_auth.py +++ b/tests/test_shared/test_auth.py @@ -3,6 +3,7 @@ """ import importlib +import os import unittest from unittest.mock import mock_open, patch @@ -24,6 +25,9 @@ def test_use_file_credentials(self, _mock_exists, mock_file): """ import runpod # pylint: disable=import-outside-toplevel - importlib.reload(runpod) + # the env var outranks file credentials; clear it so the file wins + with patch.dict(os.environ): + os.environ.pop("RUNPOD_API_KEY", None) + importlib.reload(runpod) self.assertEqual(runpod.api_key, "RUNPOD_API_KEY") assert mock_file.called