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
34 changes: 34 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Build
run: uv build

- name: GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/*
generate_release_notes: true
fail_on_unmatched_files: true
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python
## Layout, images, tests

- 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 and job images install the library with ``uv_pip_install`` from GitHub (``PACKAGE_GIT`` in ``meta.py``) until PyPI. Named Job Image ``"{name}-job"`` is still published at ``Runner.create``; webhook ``Job.create`` uses ``Image.from_name`` only. No Path / ``add_local_dir`` for package install.
- Images: control plane and job images install the library with ``uv_pip_install`` from the pinned ``PACKAGE_SPEC`` in ``meta.py`` (git tag until PyPI). Named Job Image ``"{name}-job"`` is published at ``Runner.create``; webhook ``Job.create`` uses ``Image.from_name``. 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, admission (repo + labels), and capacity semantics — not only exports / signatures.
Expand Down
27 changes: 20 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,34 @@

## Unreleased

### Breaking

- `Runner.create` requires non-empty `labels`; default `min_containers=0`.
- `cpu` / `memory` / `gpu` on `Runner.create` are Job Sandbox defaults (not Server resources).
- Images install a **pinned** git tag (`PACKAGE_SPEC`); Actions runner tarball is checksum-verified.
- Removed `StageClock` / `runner_modal.profile` (tracing TBD).
- Job entrypoint: `JobSpec.start` / `JobSpec.mint_jitconfig` (no free `mint_jitconfig` helper).

### Added

- Durable delivery bind (`bind_object_durable`) before accept; bind failure → terminate + 503.
- Webhook outcome headers (`X-Runner-Modal-Outcome`, `X-Runner-Modal-Reason`) and richer `/health`.
- `Runner.admit_reason` for fail-loud admission.
- Consumer kits: `examples/basic/`, `examples/gpu/`.
- GitHub Release workflow on `v*` tags (source dist; not PyPI).

### Changed

- Quick start installs with `uv add git+https://github.com/modal-projects/runner-modal` (not PyPI yet).
- Control-plane and job Images install the package from GitHub via `uv_pip_install` (works outside this repo clone).
- Quick start documents webhook UI, token scopes, and troubleshooting.

## 0.1.0

### 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).
- `Runner.create` requires `github_secret`, `webhook_secret`, and non-empty `repositories`.
- `Runner.Job.create` takes `github_secret=`.
- Webhook admission requires repository allowlist; `X-GitHub-Delivery` required.
- 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
Expand Down
153 changes: 53 additions & 100 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,67 +4,79 @@ Self-hosted [GitHub Actions](https://docs.github.com/en/actions) runners on [Mod

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
- Python >= 3.12, [Modal](https://modal.com) account + CLI (`modal setup`)
- Repo admin on GitHub (webhook + token)
- Two named Modal Secrets: `GITHUB_TOKEN` (Jobs) and `WEBHOOK_SECRET` (Server)

## Quick start
## Quick start (basic CI)

### 1. Install

```bash
uv add git+https://github.com/modal-projects/runner-modal
uv add git+https://github.com/modal-projects/runner-modal.git@v0.1.0
```

Images install the same git tag. After upgrading the pin, redeploy so `{name}-job` republishes.

### 2. Secrets

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

Not on PyPI yet — install from the GitHub repo. Then put this in e.g. `app.py` and `modal deploy app.py`:
Prefer a fine-scoped GitHub App or PAT that can call `POST /repos/{owner}/{repo}/actions/runners/generate-jitconfig` on allowlisted repos (classic: `repo` + admin on self-hosted runners; App: Administration read/write for runners).

```python
import modal
from runner_modal import Runner
### 3. Deploy

app = modal.App("my-runners")
github = modal.Secret.from_name("github-token", required_keys=["GITHUB_TOKEN"])
webhook = modal.Secret.from_name("github-webhook", required_keys=["WEBHOOK_SECRET"])
Copy [`examples/basic/app.py`](examples/basic/app.py), set `repositories=["YOUR_ORG/YOUR_REPO"]`, then:

Runner.create(
app=app,
name="ci",
github_secret=github,
webhook_secret=webhook,
repositories=["YOUR_ORG/YOUR_REPO"],
compute_region="us-east",
labels=["self-hosted", "modal", "ci"],
max_concurrent=20,
min_containers=0,
idle_timeout=900,
)
```bash
modal deploy examples/basic/app.py
```

### 4. Webhook URL

```python
print(Runner.from_name("ci").url) # None until ready
# Webhook URL: {url}/github
from runner_modal import Runner
print(Runner.from_name("ci").url) # None until the Server is ready
# GET {url}/health
```

```yaml
runs-on:
- self-hosted
- modal
- ci
- job-${{ github.run_id }}-${{ github.job }}
```
In GitHub → Settings → Webhooks → Add webhook:

| Field | Value |
|-------|--------|
| Payload URL | `{url}/github` |
| Content type | `application/json` |
| Secret | same value as `WEBHOOK_SECRET` |
| Events | **Workflow jobs** only |

### 5. Workflow

Copy [`examples/basic/workflow.yml`](examples/basic/workflow.yml) into `.github/workflows/`. Pool labels must match `Runner.create(labels=…)`. Keep the unique `job-${{ github.run_id }}-${{ github.job }}` pin.

## Examples

| Kit | When |
|-----|------|
| [`examples/basic/`](examples/basic/) | Standard checkout + test CI |
| [`examples/gpu/`](examples/gpu/) | CUDA / ML jobs (`gpu="T4"` Job defaults) |

One `Runner.create` per App. It publishes named Image `{name}-job` (package installed from GitHub into the image). 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.
One `Runner.create` per App = one resource profile. GPU pool = separate App (see `examples/gpu/`). Docker-in-CI: `experimental_options={"vm_runtime": True}` on `Runner.create` (no GPU).

| 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` |
`cpu` / `memory` / `gpu` on `Runner.create` are **Job** Sandbox defaults. The control-plane Server stays small.

Never put both keys in one Secret. Never put tokens or JIT in Sandbox `env=`.
## Troubleshooting

| Symptom | Check |
|---------|--------|
| HTTP 401 | Webhook secret ≠ Modal `WEBHOOK_SECRET` |
| HTTP 204 + `X-Runner-Modal-Reason` | Admit miss: repo allowlist, labels, missing `self-hosted`, empty pool |
| HTTP 503 capacity / busy | Soft `max_concurrent`, or delivery in flight |
| Job queued forever | Modal Sandbox logs (JIT 401/403, runner boot); `GET /health` |

## How it works

Expand All @@ -82,78 +94,21 @@ GitHub --workflow_job--> RunnerServer
mint JIT -> ./run.sh --jitconfig …
```

| Piece | Modal object | Role |
|-------|--------------|------|
| 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 (`cache=True`); same-pool Volume, not `actions/cache` |
| Control plane | Server `RunnerServer` | HMAC, admission, create/cancel |
| Job | Sandbox | `python -m runner_modal.job` |

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).

## Performance

Time-to-start, cold vs warm, concurrency, and idle cost (not workflow pytest time):

![Time to runner ready](docs/time-to-ready.png)

![Warm queue budget](docs/queue-budget.png)

![Concurrency / burst](docs/concurrency.png)

![Idle vs active cost](docs/idle-cost.png)

Charts are illustrative. Regenerate with `uv run --group dev python scripts/charts.py`.

`max_concurrent` is soft (list-then-create TOCTOU). Idle cost is mostly `min_containers`; jobs are ephemeral Sandboxes.
`max_concurrent` is soft (list-then-create). See [SECURITY.md](SECURITY.md).

## Imperative jobs

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

job = Runner.Job.create(
runner,
repository="YOUR_ORG/YOUR_REPO",
labels=["modal", "ci", "job-1"],
github_secret=github,
gpu="t4",
gpu="T4", # overrides Runner defaults when set
)
job.wait()
```

Docker / VM (no GPU): `experimental_options={"vm_runtime": True}`. Job kwargs are Modal Sandbox-shaped: `cpu`, `memory`, `gpu`, `experimental_options`.

## API

| Call | Notes |
|------|-------|
| `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=…, github_secret=…)` | Eager job Sandbox |
| `Runner.Job.from_id` / `from_name` / `wait` | Lookup / block |
| `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 delivery | `AuthError` / HTTP 401 |
| At `max_concurrent` | `ConcurrencyLimitError` / HTTP 503 |
| Delivery in progress | HTTP 503 |
| `job.wait` timeout | `JobTimeoutError` |

## Security

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

```bash
Expand All @@ -164,8 +119,6 @@ uv run ruff format --check src/runner_modal tests examples
uv run ty check
```

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

## License

[Apache License 2.0](LICENSE)
10 changes: 9 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
| `repositories` allowlist | Repo is explicitly permitted | Workflow code is trusted |
| Label match (`pool ⊆ runs-on`) | Job targeted this pool | Soft capacity will not overshoot |

Admission is fail-closed: repository must be in `repositories`, pool labels must match, and `self-hosted` must be present.
Admission is fail-closed: repository must be in `repositories`, pool labels must match, and `self-hosted` must be present. Ignored deliveries return HTTP 204 with `X-Runner-Modal-Reason`.

## Job resources

`cpu` / `memory` / `gpu` on `Runner.create` are default **Job** Sandbox resources. The webhook control-plane Server does not take those knobs. One Runner = one resource profile.

## Secrets

Expand All @@ -35,6 +39,10 @@ Prefer a fine-scoped GitHub App or PAT limited to runner JIT / admin on the allo

`max_concurrent` is a soft list-then-create check. Concurrent creators can overshoot. Treat it as cost guidance, not a hard reservation.

## Delivery bind

After a Job Sandbox is created, the control plane durable-binds `object_id` onto the delivery claim before HTTP 200. Pending claims **with** `object_id` are never TTL-reclaimed. If bind cannot be made durable, the Job is terminated, the unbound claim is released, and GitHub receives HTTP 503 so it can retry.

## Fork PRs and untrusted workflows

Self-hosted runners execute workflow steps with access to the Job environment (including `GITHUB_TOKEN`). Treat fork PRs and untrusted workflows as hostile. Prefer private repos, trusted branches, and unique `runs-on` pins (`job-${{ github.run_id }}-${{ github.job }}`).
Expand Down
34 changes: 34 additions & 0 deletions examples/basic/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Minimal CPU CI pool — copy and deploy.

modal secret create github-token GITHUB_TOKEN=ghp_xxx
modal secret create github-webhook WEBHOOK_SECRET=$(openssl rand -hex 32)
modal deploy examples/basic/app.py

Webhook: {Runner.from_name("ci").url}/github
Copy examples/basic/workflow.yml into your repo.
"""

from __future__ import annotations

import modal

from runner_modal import Runner

app = modal.App("runner-modal-basic")
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="ci",
github_secret=github,
webhook_secret=webhook,
repositories=["YOUR_ORG/YOUR_REPO"],
compute_region="us-east",
labels=["self-hosted", "modal", "ci"],
cpu=2.0,
memory=4096,
max_concurrent=20,
min_containers=0,
idle_timeout=900,
)
26 changes: 26 additions & 0 deletions examples/basic/workflow.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copy into YOUR_REPO/.github/workflows/
# Pool labels must match Runner.create(labels=...); keep the unique pin.
name: CI

on:
push:
pull_request:
workflow_dispatch:

jobs:
test:
runs-on:
- self-hosted
- modal
- ci
- job-${{ github.run_id }}-${{ github.job }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- name: Run tests
run: |
# Replace with your project test command, e.g.:
# uv sync && uv run pytest
# npm test
echo "runner-modal basic pool is ready"
Loading
Loading