- Python 98.6%
- Jinja 1%
- Shell 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Two deployment failures back to back on a fresh host:
- default formikaro_install_source was the git.example.com placeholder
-> pip clone 'Could not resolve host'. The repo's public home is the
correct default (anonymous clone verified rc=0); examples now carry
a do-not-embed-tokens warning since the URL lands in ansible output.
- pip git+https needs a git binary; the role never installed one
(same latent gap class as the python3-debian miss). Added to the
python3-venv apt task.
|
||
| ansible | ||
| docs | ||
| examples | ||
| openspec | ||
| scripts | ||
| src/formikaro | ||
| tests | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| AGENTS.md | ||
| ansible.cfg | ||
| pyproject.toml | ||
| README.md | ||
formikaro
Esperanto: formikaro — ant colony. Each worker is born, does one job, and is gone.
formikaro is an ephemeral Forgejo Actions runner dispatcher. Every job
gets a pristine, disposable runner: the dispatcher watches Forgejo's
waiting-jobs API, and when a job is available it creates an ephemeral runner
record, runs forgejo-runner one-job in a --rm container for exactly that
job, then destroys everything. One job at a time per host — nothing persists
between jobs: no shared work dirs, no caches, no long-lived runner daemon.
The dispatcher never executes jobs itself; it owns the poll loop and the registration lifecycle. Job execution is the stock runner binary in a throwaway container, creating sibling job containers via the host's docker socket. No docker-in-docker.
Quickstart (Ansible)
Requirements on the control node: Ansible (ansible-core ≥ 2.15) and SSH access with sudo to the target hosts.
-
Copy the example inventory and site vars:
cp -r ansible/examples my-inventory # then edit hosts + vars -
Put your secrets in
group_vars/formikaro_hosts/vault.yml(seeansible/examples/group_vars/formikaro_hosts/vault.yml.example) and encrypt it:ansible-vault encrypt my-inventory/group_vars/formikaro_hosts/vault.yml -
Run the playbook:
ansible-playbook -i my-inventory/inventory.ini ansible/playbooks/formikaro.yml --ask-vault-pass
The role installs docker-ce (flag-gated), creates a formikaro service
user, installs the dispatcher into /opt/formikaro/venv, renders
/etc/formikaro/config.yml and the secret env file, pre-pulls the runner
and job images, and enables + starts the formikaro systemd service.
To refresh images after a tag bump:
ansible-playbook -i my-inventory/inventory.ini ansible/playbooks/formikaro.yml --tags images
Quickstart (manual)
On a host with docker already installed:
python3 -m venv /opt/formikaro/venv
/opt/formikaro/venv/bin/pip install .
sudo useradd --system --home-dir /var/lib/formikaro --create-home formikaro
sudo usermod -aG docker formikaro
sudo cp examples/config.yml /etc/formikaro/config.yml # adjust to your site
sudo cp examples/env /etc/formikaro/env # 0600, your token
sudo cp examples/formikaro.service /etc/systemd/system/
sudo systemctl enable --now formikaro
Token scope requirements
The dispatcher talks to Forgejo's runner-management REST API; creating
runners requires a token with runner-management write scope (read-only
tokens get 403). The scope you dispatch for dictates who must own the
token:
| Config scope | Token must belong to |
|---|---|
user |
the user themselves (personal token) |
orgs/<name> |
an organization owner |
repos/<owner>/<repo> |
a repository admin |
There is no instance-wide scope outside admin-only endpoints, which this
tool does not use. See docs/api-notes.md for the verified endpoint
families.
The playbook never mints tokens — provision one per site and pass it via
the (vault-encrypted) formikaro_api_token variable; it is rendered only
into /etc/formikaro/env (0600 root:root).
Configuration reference
/etc/formikaro/config.yml — rendered by the role from group_vars:
| Key | Default (role) | Meaning |
|---|---|---|
instance_url |
https://git.example.com |
Forgejo instance to poll |
api_token_env |
FORMIKARO_API_TOKEN |
Env var name holding the API token (value lives in the env file) |
poll_interval_seconds |
15 |
Delay between waiting-jobs polls |
backoff_max_seconds |
300 |
Cap for the linear backoff after errors |
scopes |
[user, orgs/example] |
Scope list: user, orgs/<name>, repos/<owner>/<repo> |
labels |
ubuntu-latest: docker://registry… |
Runner label → job image (fully qualified) |
runner.image |
registry.example.com/ns/forgejo-runner:12.13.2 |
Runner image executing one-job |
runner.socket |
/var/run/docker.sock |
Host docker socket mounted into the runner container |
runner.network_mode |
host |
Docker network mode for the runner container |
Concurrency is fixed at one job per host (locked; see design.md) — scale by adding hosts, each running its own dispatcher.
/etc/formikaro/env — the only secret-bearing file:
| Variable | Meaning |
|---|---|
FORMIKARO_API_TOKEN |
Forgejo API token (runner-management write) |
DOCKER_CONFIG |
/var/lib/formikaro/.docker (isolated registry credentials) |
Host assumptions
- A plain Linux VM (role's docker install targets Ubuntu; anything with an
existing docker works with
formikaro_manage_docker: false). - systemd, python3 + venv support, and network access to your Forgejo instance and container registry.
- Host docker socket available at
/var/run/docker.sock; theformikarouser getsSupplementaryGroups=docker— it can start sibling containers by design (that is the job-execution mechanism), so treat the host as dedicated to this purpose.
Security notes
- The API token lives only in
/etc/formikaro/env(0600 root:root, loaded via systemdEnvironmentFile) and in dispatcher memory. The registry login task runs withno_log: true; no secret ever appears in a template, the unit file, or journald via the playbook. - Per-job registration tokens exist only in dispatcher memory and the
short-lived
one-jobprocess argv; the ephemeral runner record is auto-deleted server-side when the job finishes. - No docker-in-docker: the runner container mounts the host socket
(
SupplementaryGroups=docker). Job containers are siblings, created fresh per job and removed on exit. - Each job executes in a never-before-used runner container (
--rm) with a fresh registration — the repo's core invariant.
Development
pip install pre-commit # or brew/pipx
pre-commit install # hygiene + ruff + openspec validate --strict
pre-commit run --all-files
pytest # dispatcher unit tests
Spec-driven repo: work flows through openspec/changes/<id>/ — read the
active change's proposal/design/tasks before touching code, and keep
openspec validate <change-id> --strict green. Architecture and verified
API facts: openspec/changes/add-ephemeral-runner-dispatcher/design.md and
docs/api-notes.md.