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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
runs-on:
- self-hosted
- modal
- acme
- ci
- job-${{ github.run_id }}-${{ github.job }}
timeout-minutes: 30
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ dist/
.DS_Store
.modal/
.agents/
.cursor/

skills-lock.json
41 changes: 28 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agent rules - runner-modal
# Agent rules runner-modal

Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python, and library-native APIs. Do not treat this as a product README.
Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python, and library-native APIs. Do not treat this as a product README. Code is liability: keep it small; complexity only when warranted.

## Naming

Expand All @@ -17,10 +17,18 @@ Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python

- Public surface is entity-based: `Runner` + nested `Runner.Job`. Prefer Modal SDK vocabulary (`create` / `from_name` / `objects` / `ephemeral`; Job ≈ Sandbox).
- Export only via `__all__`. Do not underscore-prefix types for “privacy”; omit them from `__all__` instead.
- No free public helpers (`spawn`, `parse_labels`, pools, ASGI attach helpers).
- No free public helpers (`spawn`, `parse_labels`, pools, ASGI attach helpers). If a concern is a lead responsibility, put it on an entity (or a method on the owning entity).
- One `Runner.create` per App. Modal Server class is stable `RunnerServer` (Servers have no parameters). Runner identity is `RUNNER_MODAL_NAME`.
- Soft capacity (`has_capacity` / `max_concurrent`) is **soft** (list-then-create TOCTOU). Document it as soft; never present it as a linearizable lock.
- **Job resources:** Modal-twin flat kwargs — `cpu`, `memory`, `gpu`, `experimental_options` (e.g. `{"vm_runtime": True}` for Docker/VM). Do **not** invent `ResourceSpec | DockerResources`, xor validators, or `isinstance` resource dispatch. Prefer `docker_image()` when `vm_runtime` is set; let Modal enforce GPU vs VM limits.
- **Admission:** `repositories` is required (non-empty). `admits(labels, repository)` fail-closed. No admit-all mode.

## Explicit config (Modal twin)

- Public primitives take kwargs / `modal.Secret` handles. No ambient `os.environ.get` for credentials or product config on `Runner` / `Job` / `WebhookApp`.
- Clients and scripts may use env / Modal CLI tokens. Optional `client=` like Modal entities.
- In-container entrypoints (`RunnerServer.start`, `python -m runner_modal.job`) may read only values **mounted** via `secrets=` / `env=` at create time; KeyError if missing.
- Split Secrets: `github_secret` (`GITHUB_TOKEN`, Jobs only) and `webhook_secret` (`WEBHOOK_SECRET`, Server only). Never co-mount.

## Python construction & style

Expand All @@ -39,23 +47,24 @@ Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python
- Do not wrap Modal / httpx failures in `RunnerError`. Propagate; map only product/auth cases.
- FastAPI: HTTP status conveys success. Response models without `ok: bool`. Use `HTTPException` for 401 / 400 / 503.
- Sync I/O (Modal SDK, httpx): use sync `def` endpoints or `asyncio.to_thread`. Never block `async def` handlers with sync Modal/httpx calls.
- Credentials via required named `modal.Secret` on `Runner.create(secret=…)` and `Job.create(secret=…)`. Never put `GITHUB_TOKEN`, `WEBHOOK_SECRET`, or JIT strings in Sandbox `env=` dicts. No `os.environ.get` credential fallbacks — require explicit kwargs / Secret injection.
- JIT mint runs only inside the Job (`python -m runner_modal.job`) from `GITHUB_TOKEN` injected by `secret=`. `WebhookApp` takes an explicit `webhook_secret=`; Server start requires `WEBHOOK_SECRET` from the mounted Secret (KeyError if missing).
- Verify GitHub webhooks with `hmac.compare_digest` on the raw body before parse.
- Credentials via required named Secrets on `Runner.create(github_secret=…, webhook_secret=…)` and `Job.create(github_secret=…)`. Never put `GITHUB_TOKEN`, `WEBHOOK_SECRET`, or JIT strings in Sandbox `env=` dicts.
- JIT mint runs only inside the Job (`python -m runner_modal.job`) from `GITHUB_TOKEN` injected by `github_secret=`. `WebhookApp` takes an explicit `webhook_secret=`; Server start requires `WEBHOOK_SECRET` from the mounted Secret (KeyError if missing).
- Verify GitHub webhooks with `hmac.compare_digest` on the raw body before parse. Require `X-GitHub-Delivery` (no job-id fallback).

## Concurrency & shared state

- Webhook idempotency: **claim** the delivery ID before side effects (`put` / `skip_if_exists` or CAS). Never mark done only after `Job.create` (duplicate Sandboxes on retry/race).
- Webhook idempotency: **claim** the delivery ID before side effects (`put` / `skip_if_exists` or CAS). Bind `object_id` after successful create before relying on TTL reclaim. Never release after successful create (duplicate Sandboxes on retry/race).
- Soft list-based capacity is enough — never fake linearizable concurrency locks.
- Shared Modal Dict writes: prefer conditional writes for init and idempotency keys. Do not overwrite shared meta without merge / CAS intent.
- Do not full-scan / trim entire Dict stores on every request without an explicit GC strategy (separate hot path from opportunistic cleanup).

## Layout, images, tests

- Modules by responsibility: `runner` / `job` / `api` / `server` / `exceptions`. Keep nested `Job` if it matches the Modal twin; extract collaborators (JIT, images, meta) if `runner.py` grows further — don’t pile every concern into one god module.
- Modules by responsibility: `runner` / `meta` / `job` / `api` / `server` / `exceptions`. Keep nested `Job` if it matches the Modal twin; extract collaborators if `runner.py` grows further.
- Images: control plane builds on deploy (`control_plane_image`: `uv_sync` + `add_local_python_source`). Job Sandboxes use a **named Image** (`"{name}-job"`): `Runner.create` does `Image.build(App.lookup(...)).publish(...)` from the repo root; webhook `Job.create` uses `Image.from_name` only. Do not use `uv_pip_install("runner-modal")` until published. No Path / `add_local_dir` for package install.
- Default `cache=False`. Shared `/cache` Volume is same-pool only — not Actions cache.
- One unit test file per impl file (`test_runner.py`, `test_job.py`, `test_api.py`, `test_server.py`, `test_exceptions.py`).
- Test HMAC failure, delivery claim, and capacity semantics — not only exports / signatures.
- Test HMAC failure, delivery claim, admission (repo + labels), and capacity semantics — not only exports / signatures.
- Retries (tenacity): transient network / timeouts / 5xx only. Never retry 401 / 403 / validation errors.

## Never do
Expand All @@ -69,25 +78,31 @@ Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python
- `ok: bool` on HTTP response models
- Blanket `except Exception: raise RunnerError(...)`
- Exception taxonomy for every Modal failure mode
- Tokens or JIT in `env=`; `os.environ.get` credential fallbacks; minting JIT in the parent process
- Tokens or JIT in `env=`; `os.environ.get` credential fallbacks; minting JIT in the parent process; co-mounting `WEBHOOK_SECRET` into Jobs
- Blocking the asyncio event loop with Modal / httpx in `async def`
- Claim-after-create webhook deliveries
- Claim-after-create webhook deliveries; reclaiming pending claims that already have `object_id`
- Treating soft capacity as a hard reservation
- Inheriting Modal SDK types for the product API
- Free public helper functions as the primary DX
- Path / `add_local_dir` instead of uv-native Image install
- Substituting export/signature tests for security and race/idempotency tests
- Admit-all repositories / empty `repositories` on `Runner.create`

## Learned User Preferences

- Optimize from measured profile data only; do not infer bottlenecks. Prefer general, non-overfit changes that keep functionality — no micro-hack opts.
- README perf content: Python-generated charts for general CI-runner metrics people care about (including vs GitHub Actions when relevant); do not overfit the README to a single profile run. Use the readme skill when revamping READMEs.
- README perf content: Python-generated charts for general CI-runner metrics people care about (including vs GitHub Actions when relevant); do not overfit the README to a single profile run. Use the readme skill when revamping READMEs. Keep README concise; no em dashes.
- Create repos under `modal-projects` as `internal` (never `--public`); treat public creation as a mistake to prevent.
- Keep the webhook control plane on Modal Server (`RunnerServer`); do not migrate it to Functions/`asgi_app` unless evidence clearly outweighs the warm concurrent spawn twin.
- After dogfood or profiling sessions, stop/remove Modal apps, Sandboxes, and leftover GitHub self-hosted runners you spun up.
- Operator-deployed library (each user deploys themselves), not a managed multi-tenant SaaS; no backward compatibility required for the OSS revamp.
- Public DX stays entity-only: no free helper functions; put lead concerns on entities or methods on the owning entity.
- Brand as `runner-modal` without posing as a Modal product; quiet non-affiliation is enough (do not over-emphasize).
- Keep `.cursor/` gitignored and uncommitted.

## Learned Workspace Facts

- Profiling / bench harness lives under `scripts/` (e.g. `scripts/profile.py`, `scripts/charts.py`), not under `examples/`.
- This repo is a uv project; prefer `uv run modal` (or project-local Modal) over ad-hoc `uvx modal` when Modal is a dependency.
- Self-hosted dogfood CI needs a live deployed control plane (e.g. `acme-ci`); patching the GitHub webhook URL without also setting the secret desyncs HMAC from Modal `WEBHOOK_SECRET`.
- This repo’s dogfood control plane is Modal App `runner-modal-ci-app` (`examples/github_webhook.py`) with `min_containers=0` — keep it deployed rather than stopping after sessions. Patching the GitHub webhook URL without also setting the secret desyncs HMAC from Modal `WEBHOOK_SECRET`.
- Profile and e2e dogfood against the current repo `modal-projects/runner-modal`, not placeholder or octocat repos.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

## Unreleased

### Breaking

- `Runner.create` requires `github_secret`, `webhook_secret`, and non-empty `repositories` (replaces single `secret=`).
- `Runner.Job.create` takes `github_secret=` (replaces `secret=`).
- Webhook admission requires repository allowlist match; cancel uses the same gate.
- `X-GitHub-Delivery` is required (no fallback to workflow job id).
- Default `cache=False`.
- `RunnerMeta` / `RunnerInfo` use `github_secret_name` / `webhook_secret_name` (no `secret_name`).
- Server mounts only `webhook_secret`; Jobs mount only `github_secret`.

### Added

- `SECURITY.md`, `CONTRIBUTING.md`
- CI on `ubuntu-latest`
- Delivery `bind_object` so successful creates are not TTL-reclaimed into a second Sandbox
21 changes: 21 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Contributing

## Setup

```bash
uv sync
uv run pytest
uv run ruff check src/runner_modal tests examples
uv run ruff format --check src/runner_modal tests examples
uv run ty check
```

## Conventions

See [AGENTS.md](AGENTS.md): entity-based public DX (`Runner` / `Runner.Job`), no free public helpers, explicit kwargs / named Secrets (no ambient credential `getenv` in primitives), claim-before-create, soft capacity.

## PRs

- Prefer small PRs with tests for security and race paths (HMAC, delivery claim, admission), not only export/signature checks.
- Do not add process-local registries, `ok: bool` response fields, or co-mounted webhook + GitHub secrets.
- Temporary profiling / dogfood harnesses stay out of product PRs.
70 changes: 37 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@

Self-hosted [GitHub Actions](https://docs.github.com/en/actions) runners on [Modal](https://modal.com) Sandboxes.

You deploy your own Modal App and Secrets. This is not a managed multi-tenant service.

`Runner` registers a webhook control plane. Jobs run as Sandboxes via `python -m runner_modal.job`.

## Requirements

- Python >= 3.12, [Modal](https://modal.com) CLI
- Named Modal Secret with `GITHUB_TOKEN` and `WEBHOOK_SECRET`
- Two named Modal Secrets: `GITHUB_TOKEN` (Jobs) and `WEBHOOK_SECRET` (Server)

## Quick start

```bash
uv sync

modal secret create github-runner \
GITHUB_TOKEN=ghp_xxx \
WEBHOOK_SECRET=$(openssl rand -hex 32)
modal secret create github-token GITHUB_TOKEN=ghp_xxx
modal secret create github-webhook WEBHOOK_SECRET=$(openssl rand -hex 32)

modal deploy examples/github_webhook.py
```
Expand All @@ -25,50 +26,53 @@ modal deploy examples/github_webhook.py
import modal
from runner_modal import Runner

app = modal.App("acme-ci")
gh = modal.Secret.from_name(
"github-runner",
required_keys=["GITHUB_TOKEN", "WEBHOOK_SECRET"],
)
app = modal.App("runner-modal-ci-app")
github = modal.Secret.from_name("github-token", required_keys=["GITHUB_TOKEN"])
webhook = modal.Secret.from_name("github-webhook", required_keys=["WEBHOOK_SECRET"])

Runner.create(
app=app,
name="acme",
secret=gh,
name="ci",
github_secret=github,
webhook_secret=webhook,
repositories=["modal-projects/runner-modal"],
compute_region="us-east",
labels=["self-hosted", "modal", "acme"],
labels=["self-hosted", "modal", "ci"],
max_concurrent=20,
min_containers=0,
idle_timeout=900,
)
```

```python
print(Runner.from_name("acme").url) # None until ready
print(Runner.from_name("ci").url) # None until ready
# Webhook URL: {url}/github
```

```yaml
runs-on:
- self-hosted
- modal
- acme
- ci
- job-${{ github.run_id }}-${{ github.job }}
```

One `Runner.create` per App. It publishes named Image `{name}-job` and stores `secret_name` / `job_image_name` for the webhook path. Include every pool label plus a unique pin so one runner maps to one job.
One `Runner.create` per App. It publishes named Image `{name}-job`. Include every pool label plus a unique pin so one runner maps to one job. Use `min_containers=0` to keep the App deployed while scaling the control plane to zero when idle.

| Key | Used by | Purpose |
|-----|---------|---------|
| `GITHUB_TOKEN` | Job Sandbox | Mint JIT inside the job |
| `WEBHOOK_SECRET` | Server | HMAC on `POST /github` |
| Key | Secret | Mounted on | Purpose |
|-----|--------|------------|---------|
| `GITHUB_TOKEN` | `github_secret` | Job Sandbox only | Mint JIT inside the job |
| `WEBHOOK_SECRET` | `webhook_secret` | Server only | HMAC on `POST /github` |

Named Secrets only. Never put tokens or JIT in Sandbox `env=`.
Never put both keys in one Secret. Never put tokens or JIT in Sandbox `env=`.

## How it works

```text
GitHub --workflow_job--> RunnerServer
|
admit (repo + labels)
|
claim delivery ID
|
Job.create -> Sandbox
Expand All @@ -80,14 +84,14 @@ GitHub --workflow_job--> RunnerServer

| Piece | Modal object | Role |
|-------|--------------|------|
| Meta | Dict `{name}-runner-meta` | Labels, capacity, secret/image names |
| Meta | Dict `{name}-runner-meta` | Labels, repos, capacity, secret/image names |
| Deliveries | Dict `{name}-runner-deliveries` | Claim before create |
| Job image | Named Image `{name}-job` | Published at `Runner.create` |
| Cache | Volume `{name}-cache` → `/cache` | Optional; not `actions/cache` |
| Cache | Volume `{name}-cache` → `/cache` | Optional (`cache=True`); same-pool Volume, not `actions/cache` |
| Control plane | Server `RunnerServer` | HMAC, admission, create/cancel |
| Job | Sandbox | `python -m runner_modal.job` |

Admission: job has `self-hosted`, pool is non-empty, and `pool ⊆ job.labels`.
Admission: repository is in `repositories`, job has `self-hosted`, pool is non-empty, and `pool ⊆ job.labels`.

Examples: [`github_webhook.py`](examples/github_webhook.py), [`imperative_create.py`](examples/imperative_create.py).

Expand All @@ -110,13 +114,13 @@ Charts are illustrative. Regenerate with `uv run --group dev python scripts/char
## Imperative jobs

```python
gh = modal.Secret.from_name("github-runner", required_keys=["GITHUB_TOKEN"])
github = modal.Secret.from_name("github-token", required_keys=["GITHUB_TOKEN"])

job = Runner.Job.create(
runner,
repository="acme/api",
labels=["modal", "acme", "job-1"],
secret=gh,
repository="modal-projects/runner-modal",
labels=["modal", "ci", "job-1"],
github_secret=github,
gpu="t4",
)
job.wait()
Expand All @@ -128,27 +132,27 @@ Docker / VM (no GPU): `experimental_options={"vm_runtime": True}`. Job kwargs ar

| Call | Notes |
|------|-------|
| `Runner.create(app, name, secret=…)` | Registers Server + publishes job Image |
| `Runner.create(app, name, github_secret=…, webhook_secret=…, repositories=…)` | Registers Server + publishes job Image |
| `Runner.from_name` / `objects` / `ephemeral` | Named handle / admin |
| `runner.url` | Server URL, or `None` until ready |
| `Runner.Job.create(…, repository=…, secret=…)` | Eager job Sandbox |
| `Runner.Job.create(…, repository=…, github_secret=…)` | Eager job Sandbox |
| `Runner.Job.from_id` / `from_name` / `wait` | Lookup / block |
| `POST {url}/github` | Claim → create → 200 / 204 / 5xx |
| `POST {url}/github` | Admit → claim → create → 200 / 204 / 5xx |

| Situation | Result |
|-----------|--------|
| Undeployed | `runner.url is None` |
| Ignored webhook | HTTP 204 |
| Bad args | `ValueError` |
| Missing meta | `LookupError` |
| Bad HMAC / missing secret | `AuthError` |
| Bad HMAC / missing delivery | `AuthError` / HTTP 401 |
| At `max_concurrent` | `ConcurrencyLimitError` / HTTP 503 |
| Delivery in progress | HTTP 503 |
| `job.wait` timeout | `JobTimeoutError` |

## Security

Self-hosted runners execute workflow code with access to the runner environment. Treat fork PRs and untrusted workflows as hostile. Prefer private repos or trusted branches before exposing a shared org webhook.
See [SECURITY.md](SECURITY.md). Short version: HMAC proves the webhook secret, not repo ACL; `repositories` is required; Jobs never see `WEBHOOK_SECRET`; treat fork PRs as hostile; `max_concurrent` is soft.

## Development

Expand All @@ -160,7 +164,7 @@ uv run ruff format --check src/runner_modal tests examples
uv run ty check
```

See [`AGENTS.md`](AGENTS.md) for conventions.
See [`AGENTS.md`](AGENTS.md) for conventions and [`CONTRIBUTING.md`](CONTRIBUTING.md) for PRs.

## License

Expand Down
Loading