delegate_to accepts anything the connection plugin can dial. It does not
have to be a host in your inventory:
- name: Tell the balancer to drain this host ansible.builtin.uri: url: "http://192.0.2.250:8404/drain/{{ inventory_hostname }}" method: POST delegate_to: 192.0.2.250
That runs. Upstream is unusually direct about it:
“Although you can delegate_to a host that does not exist in inventory
(by adding an IP address, DNS name or whatever requirement the
connection plugin has), doing so does not add the host to your inventory
and might cause issues.”
“Might cause issues” is doing a lot of work in that sentence. This lesson
measures what actually happens, because the answer is not the simple one
people assume, and the part that surprises is the part that will bite you.
What the delegate does not get
Two hosts’ worth of evidence, from a play targeting web1 and delegating
to 192.0.2.250 — an address in the documentation range, so nothing real
was contacted:
Read-only / Safethe delegate is not in inventory in any sense— Verified on ansible-core 2.21.3, with a short connect timeout and ignore_unreachable so the play continues past the deliberately failed connection.
TASK [Delegate to a host that is not in inventory] *****************************
fatal: [web1 -> 192.0.2.250]: UNREACHABLE! => {"changed": false, "msg": "Task failed:
Failed to connect to the host via ssh: ssh: connect to host 192.0.2.250 port 2200:
Connection timed out", "unreachable": true}
TASK [Is there a hostvars entry for it] ****************************************
ok: [web1] => {
"msg": "in hostvars: False | groups.all = ['web1']"
}
'192.0.2.250' in hostvars is False, and groups['all'] contains only
web1. The delegate exists for the duration of the connection and nowhere
else. Concretely, that costs you:
No host_vars. There is no inventory entry to attach them to.
No group_vars from any real group. It is in no groups. Not all
in the groups sense, not loadbalancers, not anything.
No facts, ever.hostvars['192.0.2.250']['ansible_distribution']
raises undefined. Nothing gathered facts for it, and delegate_facts: true would need somewhere to put them.
No membership in groups, so it is invisible to
groups['loadbalancers'] | first and to every pattern-based check you
might use to reason about the fleet.
No appearance in ansible-inventory. Your inventory graph — the
blast-radius map this course keeps insisting on — does not know this
machine is touched by your automation.
That last one is the important one, and it is the reason this pattern
is discouraged rather than merely inconvenient.
What it does get, and this is the surprise
Look at the port in that error message: 2200. The SSH default is 22.
Something supplied 2200, and it was not the delegate’s own inventory
entry, because it has none.
Two runs isolate it. The same delegation, the same address, with
ansible_port placed in two different scopes:
Read-only / Safehost_vars do not follow; group_vars/all does— Two inventories differing only in where ansible_port is set. Both delegate to the same documentation-range address, which never answers. Verified on 2.21.3.
$ ansible-playbook -i outside3.ini outside3.yml -T 4 # then outside4.ini
#### A: ansible_port as a HOST var on web1 only ####
fatal: [web1 -> 192.0.2.250]: UNREACHABLE! => {"msg": "... ssh: connect to host
192.0.2.250 port 22: Connection timed out", "unreachable": true}
#### B: ansible_port in [all:vars] ####
fatal: [web1 -> 192.0.2.250]: UNREACHABLE! => {"msg": "... ssh: connect to host
192.0.2.250 port 2200: Connection timed out", "unreachable": true}
With ansible_port=2999 set on web1 as a host variable, the delegate
connected on 22 — the plugin default. The current host’s connection
settings do not follow the delegate, which is consistent with everything in
lesson 2.
With ansible_port=2200 in [all:vars], the delegate connected on
2200.
So the precise rule for a non-inventory delegate:
It has no hostvars entry and no group membership, but variables defined
for the all group are still in scope when its connection is built.
add_host: the deliberate version
When a host genuinely is not known until the run — a VM you just
provisioned, a container whose address came from an API — the honest tool
is ansible.builtin.add_host, which creates a real in-memory inventory
entry for the rest of the run.
Read-only / Safeadd_host creates something that actually exists— add_host modifies only the in-memory inventory for the current run. Nothing is written to disk and no host is contacted. Verified on 2.21.3.
TASK [add_host creates a real in-memory entry] *********************************
ok: [web1]
TASK [Now it exists] ***********************************************************
ok: [web1] => {
"msg": "in hostvars: True | user=proxyops | groups=['all', 'ungrouped', 'web', 'loadbalancers']"
}
The entry is complete in the ways that matter: it is in hostvars, it is
in a group, and the group appears in groups.
Configuration changeregistering a just-provisioned host— add_host changes the run inventory, not any machine. It is a configuration change to the run, which is why it is not READ-ONLY - a later play will act on what it created.
- name: Register the new proxy for the rest of this run
ansible.builtin.add_host:
name: "lb-{{ provision_result.instance.id }}"
ansible_host: "{{ provision_result.instance.private_ip }}"
ansible_user: proxyops
ansible_port: 2222
groups: loadbalancers
run_once: true
changed_when: false
- name: Now it can be delegated to by name, like anything else
ansible.builtin.uri:
url: "http://{{ hostvars[groups['loadbalancers'][0]]['ansible_host'] }}:8404/drain/{{ inventory_hostname }}"
method: POST
delegate_to: "{{ groups['loadbalancers'][0] }}"
Three things about add_host worth holding on to:
It is in-memory and per-run. Nothing is written to an inventory
file. The next ansible-playbook invocation knows nothing about it,
which is correct for an ephemeral host and wrong for a permanent one.
Variables you pass become host variables on the new entry, so you
can set connection settings that are true for that machine instead of
inheriting group_vars/all.
It bypasses the host loop implicitly — the module is documented as
acting on the in-memory inventory, not on a target — but people still
pair it with run_once to avoid the noise of running it once per host.
Given the previous two lessons, note that under serial the run_once
would fire once per batch; adding the same host repeatedly is
idempotent, so this is one of the cases where per-batch execution is
harmless.
Knowledge check
Knowledge check · 4 questions
Q1. A task delegates to the bare address 192.0.2.250, which is not in inventory. The repository has group_vars/all.yml setting ansible_user, ansible_port and ansible_ssh_private_key_file for the fleet. What settings does the delegated connection use?
Q2. What does ansible.builtin.add_host create?
Q3. What does a non-inventory delegate lack, compared with a host that has a real inventory entry? Select all that apply.
Q4. Adding delegate_facts: true to a task delegated to a bare address gives you a usable fact set for that machine.
Passing score: 75%. Answers are checked in this browser.