How it works
One package on many systems
A machine runs Debian, Ubuntu, Arch Linux or macOS. A package is written
once for all of them: never gh-debian and gh-arch. It carries a file
per system only where that system really differs, and says in
platforms which systems it
runs on. This page is for whoever writes a package: how the built-in
packages do it, and the traps they already hit.
Load the names for this system
A package that needs different names or paths per system keeps them in
vars/, and loads the first file that matches, most specific first:
# A loop over first_found, not a plain include_vars: when no file matches, the
# loop is empty and the task skips, on ansible-core 2.14 as on the latest.
- name: Load the names for this system
ansible.builtin.include_vars: "{{ devmachine_vars_file }}"
loop: "{{ query('ansible.builtin.first_found', devmachine_vars_chain) }}"
loop_control:
loop_var: devmachine_vars_file
vars:
devmachine_vars_chain:
files:
- "{{ ansible_facts['distribution'] }}.yml"
- "{{ ansible_facts['pkg_mgr'] }}.yml"
- "{{ ansible_facts['os_family'] }}.yml"
- default.yml
paths:
- "{{ role_path }}/vars"
skip: true
What each name matches:
| File | Matches |
|---|---|
Ubuntu.yml, Debian.yml, Archlinux.yml, MacOSX.yml | one distribution (distribution) |
apt.yml, pacman.yml, homebrew.yml, macports.yml | one package manager (pkg_mgr) |
Debian.yml, Archlinux.yml, Darwin.yml | a family (os_family): Debian.yml serves Ubuntu too |
default.yml | everything else |
A package with no difference between systems has no vars/ file and no
such task. Defaults that hold everywhere stay in defaults/main.yml, and
the files in vars/ only change what differs.
Read facts as ansible_facts['...'], never as the ansible_* variables.
How to install a tool
Take the first of these that works for the tool:
- The project’s official installer, such as
curl -fsSL https://mise.run | sh. - A release binary for the system and the architecture, from the project’s releases.
mise, for a language or a tool mise knows.- The
packagemodule, with the names invars/. Neverapt: the CLI refuses a package that uses it. tasks/<System>.yml, only when the steps themselves differ, not only the names.
A release binary is named the way Go names systems and architectures, not
the way the facts do. ansible_facts['system'] | lower gives linux or
darwin. The architecture needs a map: Linux reports x86_64 or
aarch64, an Apple Silicon Mac reports arm64, and a release asset says
amd64 or arm64. The glab package carries the map:
devmachine_go_architectures:
x86_64: amd64
aarch64: arm64
armv7l: armv6
devmachine_go_architecture: >-
{{ devmachine_go_architectures.get(ansible_facts['architecture'], ansible_facts['architecture']) }}
Names across systems
What the built-in packages install, by system. Check a name before you use it (see below); this table is a start, not the source.
| What | Debian, Ubuntu | Arch Linux | Homebrew | MacPorts |
|---|---|---|---|---|
| build toolchain | build-essential | base-devel | the Command Line Tools | the Command Line Tools |
| curl, git, tar, unzip, zsh | same name | same name | come with macOS | come with macOS |
| SSH client | openssh-client | openssh | comes with macOS | comes with macOS |
ACLs for Ansible’s become | acl | acl | not needed | not needed |
| tmux, mosh, ffmpeg | same name | same name | same name | same name |
| GnuPG | gnupg | gnupg | gnupg | gnupg2 |
| OpenSSL headers | libssl-dev | openssl | openssl@3 | openssl3 |
| zlib headers | zlib1g-dev | zlib | zlib | zlib |
| YAML headers | libyaml-dev | libyaml | libyaml | libyaml |
| readline headers | libreadline-dev | readline | readline | readline |
| libffi headers | libffi-dev | libffi | libffi | libffi |
| GMP headers | libgmp-dev | gmp | gmp | gmp |
| Docker | Docker’s repository | docker, docker-buildx, docker-compose | — | — |
Arch ships headers inside the library package, so there is no -dev to
add. Homebrew keeps openssl@3, readline, zlib and libffi out of its
prefix (keg-only): a build finds them through brew --prefix <name>.
Traps already hit
Each of these broke a real run before the packages handled it.
Swap on btrfs. fallocate and swapon fail with swapon: /swapfile: swapon failed: Invalid argument when / is btrfs, as on Arch. Make the
file with btrfs filesystem mkswapfile there, and fallocate elsewhere.
macOS manages its own swap: base makes none on a Mac.
No LANG over SSH on Arch. Nothing reads /etc/locale.conf for an SSH
login, and mosh-server stops with mosh-server needs a UTF-8 native locale to run. base writes LANG to /etc/environment, which pam_env
reads for every login.
distribution_release is NA on Arch. Arch is a rolling release. A
repository line built from the release name (Docker’s, Tailscale’s) belongs
in vars/Debian.yml, never in a task every system runs.
No cache refresh alone on Arch. pacman -Sy without -u is a partial
upgrade, which Arch does not support. Refresh the lists only together with
an upgrade, and only when the operator asked for one.
No group named after the account on macOS. Every account is in staff
(gid 20); group: alice fails with chgrp failed: failed to look up group alice. The root group does not exist either: a file root owns takes
wheel. Read the account’s home and group from the account itself, with
the user module in check mode, which reads and never creates:
# check_mode makes the user module only read: it never creates the account,
# and a missing one just leaves no home in the result.
- name: Read the account's home and group
ansible.builtin.user:
name: "{{ devmachine_workspace.user }}"
check_mode: true
changed_when: false
register: devmachine_account
Then use devmachine_account.home | default('/home/' ~ devmachine_workspace.user)
for the home, and devmachine_account.group | default(omit) for the group.
Every package that reads devmachine_account registers it itself. A
registered variable outlives the role, so a package that skips this task
reads whatever account the previous package left, which can belong to
another workspace. packages validate warns about such a package.
macOS has no getent, and homes are in /Users.
Homebrew refuses root. On a Mac reached over SSH the play runs as root,
and brew refuses to. A task that installs through Homebrew becomes the
account that owns it:
- name: Find who owns Homebrew
ansible.builtin.stat:
path: "{{ '/opt/homebrew' if ansible_facts['architecture'] == 'arm64' else '/usr/local' }}/bin/brew"
register: devmachine_base_brew
when: ansible_facts['pkg_mgr'] == 'homebrew'
- name: Install the base packages from Homebrew
ansible.builtin.package:
name: "{{ devmachine_base_packages }}"
state: present
become: "{{ devmachine_base_brew.stat.uid != ansible_facts['effective_user_id'] }}"
become_user: "{{ devmachine_base_brew.stat.pw_name }}"
when: ansible_facts['pkg_mgr'] == 'homebrew'
MacPorts is the other way round: it installs as root, so the play stays root.
MacPorts has no check mode. community.general.macports does not
support check mode, so sync --check skips its tasks and shows nothing
they would install. Do not read an empty dry run as “nothing to do”.
A plain SSH command on a Mac has a short PATH. It gets
/usr/bin:/bin:/usr/sbin:/sbin; Homebrew (/opt/homebrew/bin or
/usr/local/bin) and MacPorts (/opt/local/bin) are added only by a login
shell. Call a tool by its absolute path, or set PATH in the task’s
environment.
platforms. A package that cannot work on a system says so:
firewall, fail2ban, caddy and docker declare platforms: [linux],
mac-brew and mac-ports declare [macos]. sync refuses a package for
another system before it changes the machine, naming both. Left out, the
package claims to run everywhere, so leave it out only when that is true.
Check, never guess
A module’s options, a package name and a fact’s value differ by system and by version. Look them up:
| What | How |
|---|---|
| a module’s options and its check-mode support | ansible-doc <module>, or docs.ansible.com |
| a Debian or Ubuntu package | apt-cache policy <name> |
| an Arch package | pacman -Si <name> |
| a Homebrew formula | brew info <name> |
| a MacPorts port | port info <name> |
| what the machine reports | devmachine --format json machines show <machine>, under observed |
Prove it on every system it declares
A package that says platforms: [linux, macos] is proved on Debian or
Ubuntu, on Arch and on a Mac, not on one of them. Each proof is the same:
devmachine sync ends with failed=0, and a second sync reports
changed=0. A machine for this is cheap: devmachine machines create-local <name> makes an Ubuntu VM, and --distro arch an Arch one.