AnsibleXLVI · Python and Interpreter DiscoveryRemote Python and interpreter discovery
Bootstrapping a host with no Python
What you'll learn
- Write a bootstrap play that works against a host with no interpreter
- Explain why gather_facts must be false and what fails if it is not
- Make a raw-based task idempotent and honest about changed, given raw reports none of it
- Detect the distribution before you can gather facts, without guessing
- Hand off to normal modules in the same run, once an interpreter exists
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
A freshly imaged minimal host has no Python. Ansible needs Python to run a module. You would like to use Ansible to install Python.
That is a genuine circular dependency, and ansible.builtin.raw is the
supported way out of it. Part IX covered what raw is and what it gives
up — check mode, change reporting, environment: — and made the general
case for treating it as an escape hatch rather than a tool. This lesson
covers the one place where it is not an escape hatch but the correct and
only answer, and how to write that play so it does not become a liability.
Why raw is the only option
raw does not go through the module subsystem at all. It passes the
command directly into the connection string, which is why its
documentation describes it as “one of the few” actions requiring no
Python on the remote.
script shares that property — it transfers a local file and runs it
through the remote shell — and it is a reasonable choice when the
bootstrap is more than one command. Everything else on the list is a
Python module, including command and shell, which people reach for
first and which fail identically to apt on a host with no interpreter.
The shape of the play
- name: Bootstrap Python onto unmanaged hosts
hosts: unbootstrapped
gather_facts: false
become: true
tasks:
- name: Check whether a Python interpreter already exists
ansible.builtin.raw: command -v python3 || test -x /usr/bin/python3
register: python_present
changed_when: false
failed_when: false
- name: Install Python on Debian-family hosts
ansible.builtin.raw: apt-get update && apt-get install -y {{ bootstrap_python_package }}
when:
- python_present.rc != 0
- bootstrap_os_family == 'Debian'
changed_when: true
- name: Gather facts now that an interpreter exists
ansible.builtin.setup:
Read it in three parts.
The probe. A raw task that only asks a question. changed_when: false because asking changes nothing, and failed_when: false because
a non-zero exit code is a valid answer here rather than a failure. This
is the task that makes the play idempotent, and without it the play is a
package install that runs unconditionally on every execution.
The install. Guarded by the probe, and marked changed_when: true
because when it runs, it changed something and raw will not tell you
that itself. bootstrap_os_family and bootstrap_python_package come
from inventory rather than from facts — the section below explains why
they have to.
The handoff. An explicit setup task once an interpreter exists.
From this task onward the play is a normal play, and every subsequent
task can use normal modules.
Idempotency, which raw does not give you
raw has no idempotency and no change detection. It runs a command and
reports the exit code. If you write apt-get install -y python3 as a
bare task, it runs on every execution of the play, forever, and reports
changed every time because you told it to.
That is not merely untidy. A play that always reports changed is a
play whose output carries no information, and on a bootstrap group that
is re-run as new machines arrive, most of the hosts in the run are
already bootstrapped.
The probe-then-guard pattern above is the answer, and it has three properties worth stating explicitly.
The probe is honest about changed. changed_when: false on a task
that only looks at the system. Ansible cannot know this; you have to
assert it.
The probe is honest about failure. failed_when: false, because
“there is no python3 here” is the answer the play exists to handle, not
an error. Without it the play stops on precisely the hosts it was
written for.
The guard is a when, not a creates. raw has no creates
option — that belongs to command and shell, which cannot help here.
The conditional is the only mechanism available.
Detecting the distribution before you have facts
The install command differs per distribution family, and
ansible_facts.os_family is not available yet — that is a gathered
fact, and gathering needs the interpreter you are trying to install.
There are two honest answers, and one dishonest one.
Put it in inventory. The provisioning system knows what image it laid down. A group per family, or a variable per host, makes the play trivial:
# inventory/bootstrap/group_vars/debian_family.yml
bootstrap_os_family: Debian
bootstrap_python_package: python3
# inventory/bootstrap/group_vars/rhel_family.yml
bootstrap_os_family: RedHat
bootstrap_python_package: python3
This is the right answer whenever you control provisioning, which is most of the time.
Probe for it with raw. When you genuinely do not know, ask the
host — test -f /etc/debian_version and test -f /etc/redhat-release
are cheap and definitive enough for choosing a package manager.
The dishonest answer is to write a single command with || chains
that tries every package manager until one works. It appears to handle
everything, it reports success when the last one in the chain happened
to exit zero for an unrelated reason, and it makes the failure mode
unreadable. Part IX made this point about raw generally; it applies
with particular force here, because a bootstrap failure on a host you
cannot yet inspect with Ansible is expensive to debug.
Handing off
Once the interpreter exists, stop using raw. The handoff is a single
setup task, and from there the play is ordinary.
Two details make the handoff reliable.
Gather facts explicitly rather than starting a second play. A second play re-establishes the connection and works fine, but doing it in the same play keeps the bootstrap and the convergence in one file and one run, which matters when the bootstrap group is re-run regularly.
Pin the interpreter you just installed, or accept discovery for this run. You know exactly what you installed and where — this is the easiest pin you will ever write, and lesson 3 makes the case for it. On a host bootstrapped minutes ago, discovery will find the one interpreter present and be right; the value of pinning is for the run six months from now, after somebody else has installed another one.
Knowledge check
Knowledge check · 4 questions
Q1. A bootstrap play against a host with no Python fails before reaching any task. What is the most likely cause?
Q2. Which are required to make a raw-based bootstrap task idempotent and honest? Select all that apply.
Q3. You need different install commands per distribution family, but ansible_facts.os_family is not available yet. What is the best approach when you control provisioning?
Q4. ansible.builtin.command can be used instead of raw to install Python on a host that has none, because it runs a shell command rather than Python code.
Passing score: 75%. Answers are checked in this browser.