Provision Ubuntu workstations and servers with Ansible.
| Term | Meaning |
|---|---|
| controller | The host Ansible runs from, and the only member of faramir_controller. The only host whose sudo prompts |
| fleet | Every other Ubuntu host, reached over SSH with NOPASSWD sudo |
faramir |
Every host running the secret broker, the controller included |
routers |
The pfSense routers. FreeBSD, so Ubuntu-only plays are all:!routers and router.yml reaches them |
Ubuntu >= 24.04 and Ansible >= 2.19 from the Ansible PPA:
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install ansiblemake lint also needs python3-venv and Node.js >= 24.21 with npm.
Every root .yml except requirements.yml is a playbook with a make target of the same name.
bin/playbook.py runs it.
make help # List the targets
make desktop # Run a playbook
make desktop -- --tags alacritty --limit example # Forward arguments to ansible-playbook
make faramir ARGS="--extra-vars k=v" # An argument containing "=" or whitespace goes in ARGS| Behaviour | Detail |
|---|---|
| First goal only | Every group is also a target, so make base -- --limit desktop applies base alone |
| ansible-core floor | An ansible-playbook older than 2.19 stops the run |
| Host listing | An inventory error, or a --limit matching no host, stops the run before any prompt |
| Reachability probe | Hosts that do not answer SSH within 1s are dropped and named. PREFLIGHT=none skips it |
| Exit status | The playbook's own, or 75 when it succeeded but the probe dropped a host. Through make every failure exits 2 |
--ask-become-pass |
Added when the run may reach the controller. ASK_PASS=1 forces it; a root run never gets it |
| Credentials | Every playbook runs under sops exec-env when the invoking account can read the store. Those including tasks/require_credentials.yml require it, refusing when it does not exist and re-entering as root when the operator cannot read it. The rest run without it, warning only when the store is readable but sops cannot decrypt it. SECRETS=none skips it |
| umask | 002, so files created in a setgid share stay group-writable |
A fresh checkout on the controller needs, in order:
hosts(Inventory): the controller infaramirandfaramir_controller, andprimary_userin[all:vars], the account the broker and the user-scoped roles install for.host_vars/<host>.ymlfor each Ubuntu host, settingmsmtp_domainandmsmtp_user.faramir.envin the repository root, listingmsmtp_passwordand, if used,github_token, one bare name per line (Secrets). Every target stops without it.make faramir, unprivileged: installs sops and the broker, and creates the secrets directory and age key but no store.- Log out and back in: the run adds you to the
devgroup, which the broker's socket requires. sudo faramir vault add ansible-ctrl: creates the store in an editor. Addmsmtp_password: <value>, andgithub_tokeniffaramir.envnames it.faramir doctorandfaramir refs, which should list every name infaramir.env.- The first-run targets below for the controller,
make faramirhaving already run.
A new host needs, before its first run:
- An inventory line naming its
ansible_host,ansible_portandansible_user, and its groups. - The operator's public key in that account's
~/.ssh/authorized_keys: ansible.cfg allows public-key authentication only. - sudo for that account.
Its first run is these targets, in order:
make faramir -- --limit <host>,faramir_controller: authorizes the broker's key on the host and writes its NOPASSWD sudoers entry. The controller must be in the run, and the one become password it asks for is used on both hosts, so they must match.make base,make docker,make msmtpandmake dev, each with-- --limit <host>, where the host is in the playbook's groups.devcomes beforedesktop, whose builds need its toolchains.- Every other playbook whose groups hold the host, with
-- --limit <host>.
After adding a torrent host, run make torrent -- --limit faramir_controller to regenerate the controller's transfer
scripts for it.
Tags that are not playbooks run through the playbook that owns them, e.g. make dev -- --tags ai_maintainer.
| Playbook | Hosts | Role | Purpose |
|---|---|---|---|
| base.yml | all:!routers |
base | Base packages and system configuration |
| desktop.yml | desktop |
desktop, then bspwm or niri | Desktop, browser, fonts, and the host's desktop_environment |
| dev.yml | dev |
dev | Development tools and languages |
| docker.yml | dev, homeautomation, webservers |
docker | Docker CE and Compose |
| faramir.yml | faramir, then all |
faramir | Secret broker, then its SSH key and NOPASSWD sudo on managed hosts |
| games.yml | games |
games | Gaming flatpaks and RetroArch |
| hobbies.yml | hobbies |
hobbies | 3D printing, electronics, FPV |
| homeautomation.yml | homeautomation |
homeautomation | Home Assistant and related containers |
| msmtp.yml | all:!routers |
msmtp | Mail forwarding |
| nas.yml | nas |
nas | Encrypted BTRFS RAID arrays |
| router.yml | routers |
router | pfSense connectivity and resolver health checks |
| rsnapshot.yml | rsnapshot |
rsnapshot | Incremental backups |
| torrent.yml | torrent, then faramir_controller |
torrent | rtorrent, plus its transfer scripts on the controller |
| upgrade.yml | all:!routers |
none | apt dist-upgrade and flatpak upgrade |
| webservers.yml | webservers |
letsencrypt_nginx | NGINX reverse proxy with Let's Encrypt |
hosts and host_vars/<host>.yml are gitignored. Override role defaults (roles/<role>/defaults/main.yml) in
host_vars/. Every host names its address, port and login: root's cron and the broker do not read ~/.ssh/config.
controller ansible_host=controller.example.com ansible_port=22 ansible_user=andornaut ansible_connection=local ansible_local_become_success_timeout=120
example ansible_host=example.com ansible_port=22 ansible_user=andornaut
[faramir]
controller
[faramir_controller]
controller
[desktop]
example
[all:vars]
primary_user=andornautDefine no localhost host: some tasks delegate to the implicit one, and a defined one joins every
hosts: all play.
Credential values live in ~/.config/faramir/secrets/ansible-ctrl.sops.yml (sops
and age). faramir.env (gitignored) lists their names, and each injected one
becomes a variable of that name. Map one to a differently named variable in host_vars/:
homeautomation_esphome_password: "{{ esphome_password_example_site }}"To add one: put the value in the store (sudo faramir vault edit ansible-ctrl, or sudo faramir vault add ansible-ctrl while there is none), its name in faramir.env, and a mapping in host_vars/ if needed.
| Gotcha | Detail |
|---|---|
Injected names outrank host_vars/ |
A host_vars/ entry under a name faramir.env declares is silently replaced. Give a per-host override its own name |
faramir.env must exist |
A checkout without it stops the run. Run ad-hoc ansible commands from the repository root |
vars_plugins_enabled |
ansible.cfg must keep host_group_vars in the list, or host_vars/ stops loading |
Credentials reach a play through make (under sops), the broker
(faramir run --env-file faramir.env -- ansible-playbook <playbook>.yml --limit '!faramir_controller'), or the
certificate renewal cron. That --limit does not stop a task delegated to localhost with become (games's
retroid tag, base's lockdown when it moves an SSH port) from taking the controller's sudo; run those through
faramir run -- sudo make <playbook>.
Optional. Without it, GitHub allows 60 API requests an hour per address, shared across the NAT; with it, 5000.
- Create a fine-grained token with repository access Public repositories and no permissions.
sudo faramir vault edit ansible-ctrland add it asgithub_token.- Add
github_tokentofaramir.env.
An expired token fails with HTTP 401. Don't reuse the gh CLI's token: it carries your full scopes and lives in the
keyring, where faramir link can't read it.
faramir runs credentialed commands without plaintext values reaching a coding agent. See the faramir role.
make faramir installs sops and the broker, and authorizes the broker's key and NOPASSWD sudo across the fleet.
Check with faramir doctor, faramir status and faramir refs, and end to end per
Verification.
make lint # Static checks on the host, no tests
make test # Every check CI runs, tests included, in a container
tests/lint.sh python # One static check
tests/container/test.sh identity # One check, in the container
ansible-galaxy collection install --upgrade -r requirements.yml # Upgrade collections
make clean # Remove collections, lint tooling and git hooksNever run the tests on the host: identity and dispatch execute unit tests, so they run only through make test
(or tests/container/test.sh), in a container with no network over a read-only copy of the checkout. make lint
runs every other check below.
| Check | Covers |
|---|---|
ansible-lint |
Ansible content |
config |
ansible.cfg keys, since ansible ignores ones it does not recognize |
shell |
shellcheck on every shell script, templates rendered first |
python |
ruff check and ruff format --check |
identity |
Every task that can change a host declares the account it runs as; read-only modules are exempt (tests/identity.py) |
dispatch |
Every tests/test_*.py but test_identity.py |
markdown |
markdownlint on every .md file git does not ignore |
CI (.github/workflows) also runs:
check: applies each Ubuntu playbook whose paths changed to the runner from role defaults (tests/check/inventory.yml), then runs it again under--check --diff.faramir,torrent,router,nas,rsnapshotandupgradeare not covered.ai-attributions: rejects commits carrying AI attribution or long dashes, and agent instruction files.