Skip to main content
RunBook Academy

AnsibleVII · Ad-Hoc ExecutionAd-hoc execution

Ad-hoc as a fleet inspection instrument

Foundation⏱ ~18 minansiblejq

What you'll learn

  • Answer common estate-wide questions using only read-only modules
  • Use setup filters and gather_subset to keep a fleet-wide fact query cheap
  • Read a file across many hosts without shelling out to cat
  • Capture inspection results as evidence rather than as terminal scrollback

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

Not yet marked complete on this device.

This is the lesson where ad-hoc commands are unambiguously the right tool, and it is worth being as enthusiastic about it as the rest of this part is cautious.

An estate-wide question that would take an hour of SSH and note-taking takes four seconds. “Which of these 200 hosts are still on the old kernel?” “Which ones have the wrong resolver?” “Is that package the version we think it is anywhere?” These are read-only, they are safe to run in the middle of an incident, and the output is evidence you can put in a ticket.

The whole argument of this part is that ad-hoc inspection is fine forever and ad-hoc change is a debt. This lesson is the first half.

Start with reachability

Before any inspection tells you something about the estate, establish which parts of the estate answered.

Read-only / Safewho is actually there
$ ansible production -i inventory.ini -m ansible.builtin.ping
web01.example.com | SUCCESS => {
  "changed": false,
  "ping": "pong"
}
db01.example.com | UNREACHABLE! => {
  "changed": false,
  "msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 198.51.100.21 port 22: Connection timed out",
  "unreachable": true
}

Illustrative output

ping here is not ICMP. It is a Python module that runs on the target and returns the string pong, so a success proves three things at once: the network path works, SSH authenticated, and there is a usable Python interpreter. A failure at any of those layers looks different, which makes it a better first probe than ssh host true.

Facts: the broadest question you can ask

ansible.builtin.setup is the module that fact gathering uses, and you can call it directly. Unfiltered it returns several hundred keys per host, which is unreadable and slow. Filter it.

Read-only / Safewhich distribution, everywhere
$ ansible production -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_distribution*"
web01.example.com | SUCCESS => {
  "ansible_facts": {
      "ansible_distribution": "Ubuntu",
      "ansible_distribution_file_parsed": true,
      "ansible_distribution_file_path": "/etc/os-release",
      "ansible_distribution_file_variety": "Debian",
      "ansible_distribution_major_version": "24",
      "ansible_distribution_release": "noble",
      "ansible_distribution_version": "24.04"
  },
  "changed": false
}

Illustrative output

filter is a list of fnmatch patterns — shell-style globs, not regular expressions. It has accepted a list since Ansible 2.11 and still accepts a bare string as a single pattern. Two named facts, no globbing:

Read-only / Safetwo exact facts
$ ansible production -i inventory.ini -m ansible.builtin.setup -a '{"filter": ["ansible_kernel", "ansible_memtotal_mb"]}'
web01.example.com | SUCCESS => {
  "ansible_facts": {
      "ansible_kernel": "6.8.0-51-generic",
      "ansible_memtotal_mb": 15990
  },
  "changed": false
}

Illustrative output

filter is not the cheap knob — gather_subset is

This is the distinction that matters on a large fleet, and it is easy to get backwards.

filter runs after collection. Ansible gathers everything and then discards the keys you did not ask for. The transfer is smaller; the work on the target is identical.

gather_subset changes what is collected. Excluding the hardware subset means the module never enumerates block devices, never reads /proc/partitions, and never waits on a hung disk.

Read-only / Safecollect almost nothing
$ ansible production -i inventory.ini -m ansible.builtin.setup -a "gather_subset=!all,!min,virtual"
web01.example.com | SUCCESS => {
  "ansible_facts": {
      "ansible_virtualization_role": "guest",
      "ansible_virtualization_tech_guest": [
          "kvm"
      ],
      "ansible_virtualization_type": "kvm",
      "gather_subset": [
          "!all",
          "!min",
          "virtual"
      ],
      "module_setup": true
  },
  "changed": false
}

The subsets are named in ansible-doc ansible.builtin.setup and include hardware, network, virtual, mounts, devices, distribution, kernel, python, service_mgr, pkg_mgr and about forty more. A leading ! excludes.

On five hosts this is a micro-optimisation. On two thousand, with a host whose SAN mount is wedged, the difference is between an inspection that returns in six seconds and one that hangs on the mounts collection until the connection times out.

Reading a file across the estate

The obvious approach is -m command -a "cat /etc/resolv.conf". Do not. It reports changed on every host, it returns an unstructured blob, and it fails differently depending on the target’s shell.

slurp is the purpose-built module:

Read-only / Safeone file, every host
$ ansible production -i inventory.ini -m ansible.builtin.slurp -a "src=/etc/resolv.conf"
web01.example.com | SUCCESS => {
  "changed": false,
  "content": "bmFtZXNlcnZlciAxOTIuMC4yLjUzCg==",
  "encoding": "base64",
  "source": "/etc/resolv.conf"
}

Illustrative output

The base64 is not an obstacle; it is what makes the return value safe to transport regardless of what the file contains. Decode it where you need it:

Read-only / Safedecode one host's result
$ jq -r '.content' ./evidence/web01.example.com | base64 -d
nameserver 192.0.2.53

Illustrative output

For a question about a file’s metadata rather than its contents — “does this exist”, “who owns it”, “has it changed” — stat is cheaper and returns structured fields including a checksum:

Read-only / Safemetadata, not contents
$ ansible production -i inventory.ini -m ansible.builtin.stat -a "path=/etc/nginx/nginx.conf"
web01.example.com | SUCCESS => {
  "changed": false,
  "stat": {
      "checksum": "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c",
      "exists": true,
      "gr_name": "root",
      "mode": "0644",
      "mtime": 1783030738.4909694,
      "path": "/etc/nginx/nginx.conf",
      "pw_name": "root",
      "size": 1447
  }
}

Illustrative output

The checksum answers a question nothing else answers cheaply: are these 200 files identical? Collect them, sort, count the distinct values. One value means the fleet is consistent. Three means you have a drift problem and now know its shape.

The rest of the read-only toolkit

QuestionModule
Which services exist and what state are they in?ansible.builtin.service_facts
Which packages are installed, at what versions?ansible.builtin.package_facts
Does this user or group exist on the target?ansible.builtin.getent
Which files under this path match these criteria?ansible.builtin.find
Is this port answering yet?ansible.builtin.wait_for
What does this HTTP endpoint return?ansible.builtin.uri

Two cautions on that table. package_facts on a large RPM estate is genuinely expensive — it enumerates the whole package database — so scope it with --limit before you run it on two thousand hosts. And uri is read-only only if you leave method at its default of GET; the same module will happily POST or DELETE.

Capture it as evidence

Terminal scrollback is not evidence. It is not shareable, it is not timestamped, and it disappears.

Read-only / Safeinspection with an artefact
$ ansible production -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_kernel" --tree ./evidence
Read-only / Safeturn the artefacts into an answer
$ jq -r '.ansible_facts.ansible_kernel' ./evidence/* | sort | uniq -c | sort -rn
     37 6.8.0-51-generic
    2 6.8.0-45-generic
    1 5.15.0-119-generic

Illustrative output

Three lines, and the third one is the finding. That host is two LTS kernels behind and nobody knew.

This is the pattern worth internalising: run the inspection with --tree, process the JSON locally, put the summary in the ticket. It takes ten seconds longer than reading the screen and produces something a colleague can check.

Knowledge check

Knowledge check · 4 questions

  1. Q1. You need the kernel version from 2000 hosts and one of them has a hung SAN mount. Which invocation is least likely to stall?

  2. Q2. An inspection that returns results from 40 hosts and reports 3 unreachable has told you something about all 43.

  3. Q3. Which of these are safe to run against production during an active incident, with no risk of changing a managed node? Select all that apply.

  4. Q4. What makes stat a better drift check across 200 hosts than slurping the file and comparing contents?

Passing score: 75%. Answers are checked in this browser.