Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 67 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,15 @@ Before you can use Flash, you'll need:
pip install runpod-flash
```

### Step 2: Set your API key
### Step 2: Authenticate

Generate an API key from the [Runpod account settings](https://docs.runpod.io/get-started/api-keys) page and set it as an environment variable:
The easiest way to authenticate is with `flash login`, which opens the RunPod console in your browser and stores the API key automatically:

```bash
flash login
```

Alternatively, you can set an API key manually. Generate one from the [Runpod account settings](https://docs.runpod.io/get-started/api-keys) page and either export it as an environment variable:

```bash
export RUNPOD_API_KEY=[YOUR_API_KEY]
Expand Down Expand Up @@ -197,25 +203,19 @@ uv sync # recommended
pip install -r requirements.txt
```

### Step 4: Configure your API key
### Step 4: Set your API key

Open the `.env` template file in a text editor and add your [Runpod API key](https://docs.runpod.io/get-started/api-keys):
Add your [Runpod API key](https://docs.runpod.io/get-started/api-keys) to the `.env` file.

```bash
# Use your text editor of choice, e.g.
cursor .env
```
Uncomment the `RUNPOD_API_KEY` line and set it to your actual API key:

Remove the `#` symbol from the beginning of the `RUNPOD_API_KEY` line and replace `your_api_key_here` with your actual Runpod API key:

```txt
```env
RUNPOD_API_KEY=your_api_key_here
# FLASH_HOST=localhost
# FLASH_PORT=8888
# LOG_LEVEL=INFO
```

Save the file and close it.

### Step 5: Start the local API server

Expand Down Expand Up @@ -272,6 +272,7 @@ Flash provides a command-line interface for project management, development, and

### Main Commands

- **`flash login`** - Authenticate via the RunPod console and store credentials locally
- **`flash init`** - Initialize a new Flash project with template structure
- **`flash run`** - Start local development server to test your `@remote` functions with auto-reload
- **`flash build`** - Build deployment artifact with all dependencies
Expand Down Expand Up @@ -663,6 +664,60 @@ export FLASH_LOG_DIR=/var/log/flash

File logging is automatically disabled in deployed containers. See [flash-logging.md](src/runpod_flash/cli/docs/flash-logging.md) for complete documentation.

### Environment variables

Flash uses the following environment variables. Values are resolved in the listed precedence order where applicable.

#### Authentication

| Variable | Description |
|----------|-------------|
| `RUNPOD_API_KEY` | RunPod API key. Takes precedence over stored credentials. |
| `RUNPOD_CREDENTIALS_FILE` | Path to a TOML credentials file. Defaults to `~/.config/runpod/credentials.toml` (or `$XDG_CONFIG_HOME/runpod/credentials.toml`). |

**Credential precedence:** `RUNPOD_API_KEY` env var > credentials file (`flash login` stores the key here) > none (error).

#### API and runtime

| Variable | Description |
|----------|-------------|
| `RUNPOD_API_BASE_URL` | Base URL for the RunPod API. |
| `RUNPOD_REST_API_URL` | Base URL for the RunPod REST API. |
| `RUNPOD_ENDPOINT_ID` | Set automatically inside deployed workers. |
| `RUNPOD_POD_ID` | Set automatically inside deployed pods. |
| `CONSOLE_BASE_URL` | Base URL for the RunPod console UI. |

#### Flash configuration

| Variable | Description |
|----------|-------------|
| `LOG_LEVEL` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`). Default `INFO`. |
| `FLASH_HOST` | Host for the local dev server. Default `localhost`. |
| `FLASH_PORT` | Port for the local dev server. Default `8888`. |
| `FLASH_FILE_LOGGING_ENABLED` | Enable or disable file logging (`true`/`false`). |
| `FLASH_LOG_RETENTION_DAYS` | Number of days to retain log files. Default `30`. |
| `FLASH_LOG_DIR` | Custom directory for log files. |

#### Deployment and build

| Variable | Description |
|----------|-------------|
| `FLASH_RESOURCE_NAME` | Set on deployed workers to identify the resource. |
| `FLASH_ENVIRONMENT_ID` | Flash environment ID for the current deployment. |
| `FLASH_IMAGE_TAG` | Docker image tag for deployment. |
| `FLASH_GPU_IMAGE` | Docker image for GPU workers. |
| `FLASH_CPU_IMAGE` | Docker image for CPU workers. |
| `FLASH_LB_IMAGE` | Docker image for GPU load-balanced endpoints. |
| `FLASH_CPU_LB_IMAGE` | Docker image for CPU load-balanced endpoints. |

#### Runtime features

| Variable | Description |
|----------|-------------|
| `FLASH_CIRCUIT_BREAKER_ENABLED` | Enable circuit breaker for remote calls. |
| `FLASH_LB_STRATEGY` | Load balancer routing strategy. |
| `FLASH_RETRY_ENABLED` | Enable automatic retries for failed remote calls. |

## Workflow examples

### Basic GPU workflow
Expand Down
22 changes: 22 additions & 0 deletions docs/Flash_Deploy_Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ graph TB

## CLI Commands Reference

### flash login

Authenticate the CLI and store a RunPod API key locally.

```bash
flash login [--no-open] [--timeout <seconds>]
```

**What it does:**
1. Creates an auth request via GraphQL
2. Opens a browser to approve the request
3. Polls for approval and receives an API key
4. Stores the key at `~/.config/runpod/credentials.toml` (or `RUNPOD_CREDENTIALS_FILE`)

**Notes:**
- `RUNPOD_API_KEY` still takes precedence if set
- Use `--no-open` to print the URL only

**Implementation:** `src/runpod_flash/cli/commands/login.py`

---

### flash env create

Create a new deployment environment.
Expand Down
8 changes: 6 additions & 2 deletions src/runpod_flash/cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def init_command(
title = "Project Initialized" if is_current_dir else "Project Created"
console.print(Panel(panel_content, title=title, expand=False))

# Next steps
# next steps
console.print("\n[bold]Next steps:[/bold]")
steps_table = Table(show_header=False, box=None, padding=(0, 1))
steps_table.add_column("Step", style="bold cyan")
Expand All @@ -107,13 +107,17 @@ def init_command(
step_num += 1
steps_table.add_row(f"{step_num}.", "cp .env.example .env")
step_num += 1
steps_table.add_row(f"{step_num}.", "Add your RUNPOD_API_KEY to .env")
steps_table.add_row(
f"{step_num}.", "Add your RUNPOD_API_KEY to .env (or run flash login)"
)
step_num += 1
steps_table.add_row(f"{step_num}.", "flash run")

console.print(steps_table)

console.print("\n[bold]Get your API key:[/bold]")
console.print(" https://docs.runpod.io/get-started/api-keys")
console.print("\n[bold]Or authenticate with flash:[/bold]")
console.print(" flash login")
console.print("\nVisit http://localhost:8888/docs after running")
console.print("\nCheck out the README.md for more")
85 changes: 85 additions & 0 deletions src/runpod_flash/cli/commands/login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import asyncio
import datetime as dt
from typing import Optional

import typer
from rich.console import Console

from runpod_flash.core.api.runpod import RunpodGraphQLClient
from runpod_flash.core.credentials import save_api_key
from runpod_flash.core.resources.constants import CONSOLE_BASE_URL

console = Console()

POLL_INTERVAL_SECONDS = 2.0
DEFAULT_TIMEOUT_SECONDS = 600.0


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


async def _login(open_browser: bool, timeout_seconds: float) -> None:
async with RunpodGraphQLClient(require_api_key=False) as client:
request = await client.create_flash_auth_request()
request_id = request.get("id")
if not request_id:
raise RuntimeError("auth request failed to initialize")

auth_url = f"{CONSOLE_BASE_URL}/flash/login?request={request_id}"

console.print()
console.print("[bold]Authorize flash in your browser:[/bold]")
console.print(f" [link={auth_url}]{auth_url}[/link]")
console.print()

if open_browser:
typer.launch(auth_url)

expires_at = _parse_expires_at(request.get("expiresAt"))
deadline = dt.datetime.now(dt.timezone.utc) + dt.timedelta(
seconds=timeout_seconds
)
if expires_at and expires_at < deadline:
deadline = expires_at

with console.status("[dim]Waiting for authorization...[/dim]"):
while True:
status_payload = await client.get_flash_auth_request_status(request_id)
status = status_payload.get("status")
api_key = status_payload.get("apiKey")

if api_key and status in {"APPROVED", "CONSUMED"}:
path = save_api_key(api_key)
console.print(
f"[green]Logged in.[/green] Credentials saved to [dim]{path}[/dim]"
)
console.print()
return

if status in {"DENIED", "EXPIRED", "CONSUMED"}:
raise RuntimeError(f"login failed: {status.lower()}")

if dt.datetime.now(dt.timezone.utc) >= deadline:
raise RuntimeError("login timed out")

await asyncio.sleep(POLL_INTERVAL_SECONDS)


def login_command(
no_open: bool = typer.Option(False, "--no-open", help="do not open the browser"),
timeout: float = typer.Option(
DEFAULT_TIMEOUT_SECONDS, "--timeout", help="max wait time in seconds"
),
):
"""Authenticate and save a Runpod API key for flash."""
try:
asyncio.run(_login(open_browser=not no_open, timeout_seconds=timeout))
except RuntimeError as exc:
console.print(f"[red]error:[/red] {exc}")
raise typer.Exit(code=1)
2 changes: 2 additions & 0 deletions src/runpod_flash/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
env,
apps,
undeploy,
login,
)


Expand All @@ -38,6 +39,7 @@ def get_version() -> str:
app.command("init")(init.init_command)
app.command("run")(run.run_command)
app.command("build")(build.build_command)
app.command("login")(login.login_command)
app.command("deploy")(deploy.deploy_command)
# app.command("report")(resource.report_command)

Expand Down
70 changes: 52 additions & 18 deletions src/runpod_flash/core/api/runpod.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import aiohttp
from aiohttp.resolver import ThreadedResolver

from runpod_flash.core.credentials import get_api_key
from runpod_flash.core.exceptions import RunpodAPIKeyError
from runpod_flash.runtime.exceptions import GraphQLMutationError, GraphQLQueryError

Expand Down Expand Up @@ -59,10 +60,15 @@ class RunpodGraphQLClient:

GRAPHQL_URL = f"{RUNPOD_API_BASE_URL}/graphql"

def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("RUNPOD_API_KEY")
if not self.api_key:
raise RunpodAPIKeyError()
def __init__(self, api_key: Optional[str] = None, require_api_key: bool = True):
# skip loading stored credentials for unauthenticated flows (e.g. login)
# so an expired key is never sent to the server
if require_api_key:
self.api_key = api_key or get_api_key()
if not self.api_key:
raise RunpodAPIKeyError()
else:
self.api_key = api_key

self.session: Optional[aiohttp.ClientSession] = None

Expand All @@ -73,13 +79,15 @@ async def _get_session(self) -> aiohttp.ClientSession:

timeout = aiohttp.ClientTimeout(total=300) # 5 minute timeout
connector = aiohttp.TCPConnector(resolver=ThreadedResolver())
headers = {
"User-Agent": get_user_agent(),
"Content-Type": "application/json",
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self.session = aiohttp.ClientSession(
timeout=timeout,
headers={
"User-Agent": get_user_agent(),
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
headers=headers,
connector=connector,
)
return self.session
Expand Down Expand Up @@ -781,6 +789,33 @@ async def endpoint_exists(self, endpoint_id: str) -> bool:
log.debug(f"Error checking endpoint existence: {e}")
return False

async def create_flash_auth_request(self) -> Dict[str, Any]:
mutation = """
mutation createFlashAuthRequest {
createFlashAuthRequest {
id
status
expiresAt
}
}
"""
result = await self._execute_graphql(mutation)
return result.get("createFlashAuthRequest", {})

async def get_flash_auth_request_status(self, request_id: str) -> Dict[str, Any]:
query = """
query flashAuthRequestStatus($flashAuthRequestId: String!) {
flashAuthRequestStatus(flashAuthRequestId: $flashAuthRequestId) {
id
status
expiresAt
apiKey
}
}
"""
result = await self._execute_graphql(query, {"flashAuthRequestId": request_id})
return result.get("flashAuthRequestStatus", {})

async def close(self):
"""Close the HTTP session."""
if self.session and not self.session.closed:
Expand All @@ -800,7 +835,7 @@ class RunpodRestClient:
"""

def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or os.getenv("RUNPOD_API_KEY")
self.api_key = api_key or get_api_key()
if not self.api_key:
raise RunpodAPIKeyError()

Expand All @@ -812,14 +847,13 @@ async def _get_session(self) -> aiohttp.ClientSession:
from runpod_flash.core.utils.user_agent import get_user_agent

timeout = aiohttp.ClientTimeout(total=300) # 5 minute timeout
self.session = aiohttp.ClientSession(
timeout=timeout,
headers={
"User-Agent": get_user_agent(),
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
headers = {
"User-Agent": get_user_agent(),
"Content-Type": "application/json",
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
self.session = aiohttp.ClientSession(timeout=timeout, headers=headers)
return self.session

async def _execute_rest(
Expand Down
Loading
Loading