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
44 changes: 14 additions & 30 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python
- One `Runner.create` per App. Control plane is a Runner-registered Function `@modal.asgi_app` named `webhook` with `@modal.concurrent`. Runner identity is `RUNNER_MODAL_NAME`. Inputs queue on cold start — do not use Modal Server for GitHub webhooks (503 on zero→one).
- 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.
- **Admission:** `repositories` is required (non-empty). `admit_reason(labels, repository)` fail-closed. No admit-all mode. `Job.create` enforces the same rules.

## 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 (`webhook()` ASGI entry, `python -m runner_modal.job`) may read only values **mounted** via `secrets=` / `env=` at create time; KeyError if missing.
- In-container entrypoints (`webhook()` ASGI entry, `python -m runner_modal.entrypoint`) 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`, webhook Function only). Never co-mount webhook into Jobs.

## Python construction & style
Expand All @@ -48,22 +48,22 @@ Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python
- 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 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=`; ASGI entry `webhook()` requires mounted `WEBHOOK_SECRET` / `RUNNER_MODAL_NAME` (KeyError if missing).
- JIT mint runs only inside the Job (`python -m runner_modal.entrypoint`) from `GITHUB_TOKEN` injected by `github_secret=`. `WebhookApp` takes an explicit `webhook_secret=`; ASGI entry `webhook()` requires mounted `WEBHOOK_SECRET` / `RUNNER_MODAL_NAME` (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). Bind `object_id` after successful create before relying on TTL reclaim. Never release after successful create (duplicate Sandboxes on retry/race).
- Webhook idempotency: **claim** the delivery ID before side effects (`put` / `skip_if_exists`). Bind `object_id` after successful create before relying on TTL reclaim. Never release after successful create unless terminate is confirmed.
- 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.
- Shared Modal Dict writes: prefer conditional writes for init and idempotency keys. Redeploy overwrites Runner meta (last deploy wins).
- 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` / `meta` / `job` / `webhook` / `exceptions`. Keep nested `Job` if it matches the Modal twin; extract collaborators if `runner.py` grows further.
- Images: install runtime deps with ``uv_pip_install(*CONTROL_PLANE_DEPS|JOB_DEPS)`` and bake ``add_local_python_source("runner_modal", copy=True)`` until the package is public. ``PACKAGE_SPEC`` documents the operator ``uv add`` pin. Named Job Image ``"{name}-job"`` is published at ``Runner.create``; webhook ``Job.create`` uses ``Image.from_name``. No Path / ``add_local_dir``.
- Modules by responsibility: `runner` / `meta` / `entrypoint` / `webhook` / `exceptions`. Keep nested `Job` if it matches the Modal twin.
- Images: install runtime deps with ``uv_pip_install(*CONTROL_PLANE_DEPS|JOB_DEPS)`` and bake ``add_local_python_source("runner_modal", copy=True)`` until a PyPI release. ``PACKAGE_SPEC`` documents the operator ``uv add`` pin. Named Job Image ``"{name}-job"`` is published at ``Runner.create``; webhook ``Job.create`` uses ``Image.from_name``. No Path / ``add_local_dir``.
- 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_webhook.py`, `test_exceptions.py`, `test_meta.py`).
- One unit test file per impl file (`test_runner.py`, `test_entrypoint.py`, `test_webhook.py`, `test_exceptions.py`, `test_meta.py`).
- 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.

Expand All @@ -86,25 +86,9 @@ Concise always-on rules for this repo. Prefer Modal SDK shapes, idiomatic Python
- 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. Keep README concise; no em dashes.
- Create experimental scratch repos under `modal-projects` as `internal` by default. `runner-modal` itself is intended to be public OSS.
- Prefer Modal-idiomatic control-plane primitives and highest architecture quality; full revamp OK with no backward compat. Control plane is Function `@modal.asgi_app` (queue on cold start), not Modal Server.
- 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. Do not invent entities where they are not appropriate; minimize entities.
- Unit tests: no free helper functions in test files; inline setup in each test (module-level constants OK). Prefer behavior/outcome assertions over internals.
- 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.
- 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.
- This repo’s GitHub Actions CI should run on self-hosted `runner-modal` runners, not GitHub-hosted runners.
- Admit-all repositories / empty `repositories` on `Runner.create` or `objects.create`

## Workspace

- This repo’s GitHub Actions CI and Release workflows run on self-hosted `runner-modal` runners (`self-hosted`, `modal`, `ci`, unique `job-…` pin), not GitHub-hosted runners.
- Maintainer CI App is `scripts/ci_app.py` (App `runner-modal-ci-app`).
45 changes: 24 additions & 21 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,42 @@

### Breaking

- Control plane is a Function `@modal.asgi_app` (not Modal Server). URL host is `*.modal.run`; re-point GitHub webhooks after redeploy.
- `Runner.create` uses `region=` (not `compute_region=`). Meta field `webhook_function` replaces `server_name`.
- `Runner.create` requires non-empty `labels`; default `min_containers=0`, `buffer_containers=1`.
- `cpu` / `memory` / `gpu` on `Runner.create` are Job Sandbox defaults (not control-plane resources).
- Images install runtime deps from PyPI and bake local `runner_modal` (`add_local_python_source`); Actions runner tarball is checksum-verified. `PACKAGE_SPEC` documents the operator install pin (`@main` until public tag).
- Removed `StageClock` / `runner_modal.profile` (tracing TBD).
- Job entrypoint: `JobSpec.start` / `JobSpec.mint_jitconfig` (no free `mint_jitconfig` helper).
- Job Sandbox entrypoint module is `runner_modal.entrypoint` (`python -m runner_modal.entrypoint`); was `runner_modal.job`.
- `Runner.Job.wait()` matches unbounded `Sandbox.wait` (no `timeout=` / `JobTimeoutError` path).
- `DeliveryStore.claim` / `bind` replace `try_claim` / `bind_object` / `bind_object_durable`.
- `Runner.admits` removed; use `admit_reason` (returns `None` when admitted).
- `Runner.objects.create` requires non-empty `repositories` (`owner/repo`), same as `Runner.create`.
- `Runner.Job.create` rejects jobs that fail admission (repo allowlist + labels).

### 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).
- `Runner.objects.delete` also removes `{name}-runner-deliveries`.
- Stateful delivery claim/bind/release tests (in-memory Dict).

### Changed

- Quick start documents webhook UI, token scopes, and troubleshooting.
- Module `api` / `server` → `webhook` (ASGI entry + FastAPI collaborators).
- Bind failure: release claim only after confirmed `terminate`; otherwise leave claim and return 503.
- Stale delivery reclaim overwrites in place (no delete-then-put).
- Maintainer CI App lives at `scripts/ci_app.py` (not under `examples/`).
- Docs: install/Image bake, SECURITY Advisories URL, LICENSE copyright, last-deploy-wins meta.

## 0.1.0

### Breaking

- `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`.
- Server mounts only `webhook_secret`; Jobs mount only `github_secret`.
- Control plane is a Function `@modal.asgi_app` (not Modal Server). URL host is `*.modal.run`.
- `Runner.create` uses `region=` (not `compute_region=`). Meta field `webhook_function` replaces `server_name`.
- `Runner.create` requires `github_secret`, `webhook_secret`, non-empty `repositories` and `labels`.
- `cpu` / `memory` / `gpu` on `Runner.create` are Job Sandbox defaults (not control-plane resources).
- Default `min_containers=0`, `buffer_containers=1`, `cache=False`.
- Images install runtime deps from PyPI and bake local `runner_modal` (`add_local_python_source`).
- `Runner.Job.create` takes `github_secret=`. Server/webhook mounts only `webhook_secret`; Jobs mount only `github_secret`.
- Module `api` / `server` → `webhook`. Removed `StageClock` / `runner_modal.profile`.

### Added

- Delivery claim/bind before accept; webhook outcome headers and `/health`.
- `Runner.admit_reason` for fail-loud admission.
- Consumer kits: `examples/basic/`, `examples/gpu/`.
- GitHub Release workflow on `v*` tags (source dist; not PyPI).
- `SECURITY.md`, `CONTRIBUTING.md`
- Delivery `bind_object` so successful creates are not TTL-reclaimed into a second Sandbox
- Dogfood App `runner-modal-ci-app` with `min_containers=0`
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ See [AGENTS.md](AGENTS.md): entity-based public DX (`Runner` / `Runner.Job`), no

- 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.
- Temporary profiling harnesses (`scripts/`) stay out of library PRs.
25 changes: 25 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,28 @@
of your accepting any such warranty or additional liability.

END OF TERMS AND CONDITIONS

APPENDIX: How to apply the Apache License to your work.

To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright 2026 modal-projects

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@ You deploy your own Modal App and Secrets. This is not a managed multi-tenant se

### 1. Install

Until a PyPI release, install from GitHub (prefer a tag once one exists):

```bash
uv add git+https://github.com/modal-projects/runner-modal.git@main
# after first release: …runner-modal.git@v0.1.0
```

Images bake the local ``runner_modal`` package at deploy time. After a public release, switch Images to the published pin and redeploy so `{name}-job` republishes.
Deploy from a checkout (or editable install) so Modal Image builds can
`add_local_python_source("runner_modal")`. Copy `examples/basic/app.py`, set
`repositories=[…]`, then `modal deploy …`. GitHub Releases publish sdists;
PyPI publish is not set up yet.

Redeploying `Runner.create` overwrites Runner meta (last deploy wins).

### 2. Secrets

Expand Down Expand Up @@ -89,7 +97,7 @@ GitHub --workflow_job--> webhook (Function + asgi_app)
|
Job.create -> Sandbox
|
python -m runner_modal.job
python -m runner_modal.entrypoint
|
mint JIT -> ./run.sh --jitconfig …
```
Expand All @@ -102,7 +110,7 @@ GitHub --workflow_job--> webhook (Function + asgi_app)
job = Runner.Job.create(
runner,
repository="YOUR_ORG/YOUR_REPO",
labels=["modal", "ci", "job-1"],
labels=["self-hosted", "modal", "ci", "job-1"],
github_secret=github,
gpu="T4", # overrides Runner defaults when set
)
Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Security

`runner-modal` is an **operator-deployed** library. You run it in your Modal workspace with your GitHub credentials. It is not a managed multi-tenant service and does not isolate customers from each other.
`runner-modal` is an **operator-deployed** library. You run it in your Modal workspace with your GitHub credentials. It is not a managed multi-tenant service and does not provide multi-tenant isolation across Modal workspaces or GitHub orgs; you operate one deployment in your account.

## Trust model

Expand Down Expand Up @@ -41,12 +41,12 @@ Prefer a fine-scoped GitHub App or PAT limited to runner JIT / admin on the allo

## 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.
After a Job Sandbox is created, the control plane binds `object_id` onto the delivery claim before HTTP 200. Pending claims **with** `object_id` are never TTL-reclaimed. If bind fails, the Job is terminated when possible; the claim is released only after a confirmed terminate. Otherwise the claim is left in place and GitHub receives HTTP 503. Redeploying `Runner.create` overwrites Runner meta (last deploy wins).

## 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 }}`).

## Reporting issues

Open a private security report via GitHub Security Advisories on this repository when available, or contact the maintainers listed in the repo.
Please use [GitHub Security Advisories](https://github.com/modal-projects/runner-modal/security/advisories/new) for this repository. Do not open public issues for undisclosed vulnerabilities.
6 changes: 3 additions & 3 deletions examples/github_webhook.py → scripts/ci_app.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Dogfood control plane for modal-projects/runner-modal (not a consumer template).
"""Maintainer CI control plane for modal-projects/runner-modal.

Prefer examples/basic/ or examples/gpu/ when onboarding.
Not a consumer template — copy examples/basic/ or examples/gpu/ instead.

modal deploy examples/github_webhook.py
modal deploy scripts/ci_app.py
"""

from __future__ import annotations
Expand Down
4 changes: 2 additions & 2 deletions src/runner_modal/job.py → src/runner_modal/entrypoint.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Job Sandbox entrypoint — mint JIT from Secret, run Actions runner.

``python -m runner_modal.job``
``python -m runner_modal.entrypoint``

- ``GITHUB_TOKEN`` — from Modal Secret (required)
- ``RUNNER_JOB_SPEC`` — JSON ``JobSpec`` (required)
Expand Down Expand Up @@ -37,7 +37,7 @@ class JitResponse(BaseModel):


class JobSpec(BaseModel):
"""Non-secret inputs for ``python -m runner_modal.job``."""
"""Non-secret inputs for ``python -m runner_modal.entrypoint``."""

model_config = ConfigDict(frozen=True)

Expand Down
4 changes: 2 additions & 2 deletions src/runner_modal/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
DEFAULT_RUNNER_SHA256 = (
"04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d"
)
JOB_MODULE = "runner_modal.job"
JOB_MODULE = "runner_modal.entrypoint"
WEBHOOK_FUNCTION = "webhook"
PACKAGE_VERSION = "0.1.0"
# Documented install pin for operators (`uv add …`). Image builds install
# runtime deps from PyPI and bake local ``runner_modal`` via
# ``add_local_python_source`` until the package is public.
# ``add_local_python_source`` until a PyPI release.
PACKAGE_SPEC = "git+https://github.com/modal-projects/runner-modal.git@main"
CONTROL_PLANE_DEPS = (
"fastapi>=0.115.0",
Expand Down
Loading