Written 2026-08-30 after using Ansible to provision this machine, framed toward the real target: provisioning customer machines with company dependencies, ROS 2, middleware and SDK builds. The personal setup that produced it: dotfiles-architecture.
The big picture
- A shell script is a recipe; a playbook is an order. Recipe: “crack two eggs, whisk, heat the pan”, run twice → four eggs. Order: “an omelette on the table”, already there → nothing happens.
- Declarative: describe the state you want; Ansible inspects reality and acts only on the difference.
- Why it matters for customer machines: re-running against a half-configured robot must be safe. With an order you just run it again.
1. Declarative in one command
ansible localhost -m file -a "path=/tmp/demo state=directory mode=0755"
# first run -> CHANGED "changed": true
# second run -> SUCCESS "changed": false ← same command, did nothing- You never said “create a directory”, only “this directory should exist with mode 0755”.
- vs a shell script: you write the state, not steps · idempotent by construction (no hand-coded guards) · always reports
changedvsok(not only if youecho) · dry run with--check(scripts have none).
2. Vocabulary
- Inventory → machines to manage, in groups (
inventory.ini) · Facts → what Ansible discovers about the target before starting - Module → a unit of work:
apt,file,git,copy,command, … · Task → one named module call - Play → tasks mapped to hosts · Playbook → YAML file of one or more plays
- Role → reusable bundle of tasks/files/vars, how you split a big playbook · Handler → task that runs only when notified (e.g. restart a service)
[local]
localhost ansible_connection=local # don't SSH to ourselves
[robots] # the real use case
robot-01 ansible_host=192.168.1.50 ansible_user=unitree
robot-02 ansible_host=192.168.1.51 ansible_user=unitree- Same playbook, different inventory → one playbook serves many machines.
- SSH is the default connection;
connection: localis the special case.
3. Commands
# ad-hoc: one module, no file. Good for exploring
ansible localhost -m ping
ansible localhost -m setup -a 'filter=ansible_distribution*' # show facts
# playbooks
ansible-playbook provision.yml --syntax-check # parse only
ansible-playbook provision.yml --list-tasks # what would run, in order (runs nothing)
ansible-playbook provision.yml --check --diff -K # DRY RUN
ansible-playbook provision.yml -K # for real
ansible-playbook provision.yml -K --tags ros2 # just one part-K→--ask-become-pass, prompt once for the sudo password ·become: trueon a task → run it as root--check→ report what would change, change nothing ·--diff→ show actual before/after content-i <file>→ inventory (or set it inansible.cfgand stop typing it)
4. Reading the output
PLAY RECAP
localhost : ok=15 changed=1 unreachable=0 failed=0 skipped=5ok→ ran successfully (includes ones that changed nothing) ·changed→ actually altered the machineskipped→ awhen:guard said not needed ·failed→ erroredchanged=0on an already-configured machine is a test result, not cosmetics: it proves the playbook matches reality well enough to be safe to re-run.
Not every
changedis a bugSome tasks are time-based:
apt: update_cachewithcache_valid_time: 3600reportschangedonce an hour regardless of state. Know which before chasing a ghost.
5. Idempotency: free vs hand-rolled
- Proper modules are idempotent for free:
apt,file,copy,git,deb822_repositoryinspect before acting. commandandshellare not: Ansible can’t know what they do, so they reportchangedevery run unless told otherwise.
# 1. `when:` - don't run at all unless needed (check state FIRST)
- ansible.builtin.stat:
path: "{{ ansible_env.HOME }}/.local/pipx/venvs/ansible"
register: pipx_ansible
- ansible.builtin.command: pipx install --include-deps ansible
when: not pipx_ansible.stat.exists
# 2. `changed_when:` - run it, but decide for yourself whether it changed anything
- ansible.builtin.command: ./bootstrap.sh
register: result
changed_when: "'linked' in result.stdout or 'copied' in result.stdout"
# 3. `creates:` - skip if a file already exists. THE tool for source builds.
- ansible.builtin.command: make install
args:
chdir: /opt/unitree_sdk2/build
creates: /usr/local/lib/libunitree_sdk2.socreates:matters most for robotics work: it stops a playbook recompiling an SDK on every run.- Rule of thumb: if you reach for
commandorshell, you owe it a guard.
6. Third-party apt repositories (= the ROS 2 procedure)
ROS 2, Chrome, VS Code and Docker all follow one shape: add the vendor’s GPG key, add their repo pinned to that key, install.
- name: Add third-party apt repositories
become: true
ansible.builtin.deb822_repository:
name: "{{ item.name }}" # -> /etc/apt/sources.list.d/<name>.sources
types: [deb]
uris: "{{ item.uris }}"
suites: "{{ item.suites }}"
components: "{{ item.components }}"
architectures: [amd64]
signed_by: "{{ item.key_url }}" # a URL - Ansible fetches AND dearmors it
state: present
loop: "{{ apt_repos }}"
register: repos
- name: Refresh the cache only if a repo changed
become: true
ansible.builtin.apt: {update_cache: true}
when: repos.changedWhy
signed_bymatters: a real security pointIt pins a key to one repository. Without it (old
apt-key add, or a.listline with nosigned-by), a key in apt’s global trust store vouches for packages claiming to come from anywhere: compromise one vendor’s key and it validates packages pretending to be any other.
- With it, Microsoft’s key only validates Microsoft’s repo. That’s why
apt-keyis deprecated and ROS 2’s own instructions usesigned-by=/usr/share/keyrings/ros-archive-keyring.gpg. The pattern above is that procedure, learned somewhere a mistake is free. - Slack’s repo on this machine has no
Signed-Byat all: worth fixing or replacing.
7. What Ansible reproduces, and what it does NOT
- Which applications → ✓ yes · configuration files → ✓ byte-identical (they’re files in git) · software versions → no.
state: present= “make sure this is installed”, not “this exact version”. A new machine gets whatever the repo serves that day.- Personal desktop → correct (you want a current browser, not one months behind on security patches). Customer robot → not: you want the versions you tested.
- ansible.builtin.apt:
name: "ros-humble-desktop=0.10.0-1jammy" # exact version
state: presentPinning needs the repo to keep old versions
Ubuntu archives, ROS (
packages.ros.org) and VS Code (Microsoft) keep them. Google Chrome only ever serves the current release, so it literally cannot be pinned.
- Where containers come in: a Docker image freezes everything (OS, ROS distro, dependency versions, your SDK build) into an artifact that doesn’t shift when an upstream repo does. Hence robotics teams often use it.
- Ansible reproduces a recipe. A container reproduces a result. They combine: Ansible installs Docker and deploys the image, so the host is still configured reproducibly. Which fits depends on whether things must run natively on the robot’s hardware.
8. Traps hit in practice
failed_when: false # suppresses EVERYTHING, including real errors
failed_when: result.rc > 1 # grep: 0=found, 1=not found, 2=real error
failed_when: falsehides bugsA guard ran
pipx list --short, a flag that doesn’t exist in pipx 1.0.0 (Ubuntu 22.04’s version). It exited 2 with a usage error,failed_when: falseswallowed it, empty stdout made the guard always true, and the playbook reinstalled Ansible on every single run.
- Better still, avoid the command: check the filesystem with
stat. No flags to get wrong, and it works in check mode. - Three lessons: check a CLI flag against the installed version · suppress only the specific expected non-failure · it was only visible on an already-configured machine (on a fresh one every task legitimately changes something, so a spurious action is invisible).
msg: "{{ result.stdout_lines | default(['(skipped in --check)']) }}"--checksilently skipscommandandshell: Ansible won’t run arbitrary commands to see what they’d do. It validates package tasks, says nothing about script tasks: a smoke test, not a proof.- Anything registered by a skipped task is undefined in check mode → guard it with
default(...)as above. vars_files:paths are relative to the playbook, not your working directory. Move the playbook and the path breaks.
9. What to learn next, for the work case
- Roles → at four or five components (
ros2,cyclonedds,unitree-sdk, company deps) one playbook stops being readable - Tags →
--tags ros2re-runs one part instead of a 20-minute deployment creates:→ idempotent source builds (the SDK compile)- host_vars / group_vars → different robot models, one playbook
- Handlers → restart a service only when its config actually changed
- Vault →
ansible-vaultencrypts secrets so they can live in git - Version pinning → ship what you tested (§7)
Update 2026-09-01: this happened
All seven were used building the G1 training playbook (kept as a separate, private note).
creates:, handlers and version pinning were exactly as load-bearing as predicted. One correction: tags are the wrong tool for per-customer variation: they carry no values and live only in shell history. Variable files do that job (§4 of that note).
10. The pattern worth keeping
A manifest that both installs and audits:
vars/software.yml ← ONE list of what belongs on the machine
provision.yml ← reads it, installs
audit.py ← reads the SAME list, reports drift- One source, two consumers. A second copy of the list would drift from the first, and you’d maintain two things.
- The audit answers “is this still complete?”: a manifest written once is accurate for exactly one afternoon. Install something in November, forget to declare it, and you find out on a fresh machine that’s missing a tool.
Related: dotfiles-architecture · git-daily-workflow · ssh-keys-and-github