CertGrid CertGrid
Hands-on Lab·Ansible

Ansible Ad-Hoc Commands and Core Modules

An ad-hoc command is one module against a pattern of hosts, right now, with no playbook. It is how you investigate, how you fix one thing quickly, and how you learn what a module does before committing it to a file. This covers the shape of the command line, the six modules that cover most ad-hoc work, and the parallelism that is running underneath.

Ad-hoc Commands and Modules Guide 6 of 45 Beginner

Written against the versions above. Module names are written here in full - `ansible.builtin.command` rather than `command`. The short name still works and always will for `ansible.builtin`, but the fully qualified name is unambiguous when several collections define something similar, and it is what current documentation uses.

Two managed nodes in two groups, so a pattern selects something meaningful.
Server NameIP AddressOSRolesCPURAMHDD
ANS-CTL01192.168.0.36Ubuntu 26.04 LTSAnsible Control Node2 Core3 GB50 GB
ANS-A01192.168.0.37Ubuntu 26.04 LTSManaged Node (group: web)2 Core3 GB50 GB
ANS-B01192.168.0.38Ubuntu 26.04 LTSManaged Node (group: db)2 Core3 GB50 GB

Before you start

  1. The configuration this guide assumes

    Everything below runs from the home directory on ANS-CTL01, where two files sit side by side.

    ~/ansible.cfg - found because Ansible looks for ./ansible.cfg in the current directory:

    [defaults]
    inventory = ./inventory.ini
    host_key_checking = False
    remote_user = sysadmin
    result_format = yaml

    result_format = yaml rather than the stdout_callback = yaml that older material recommends: that callback lived in community.general and was removed in version 12.0.0, so a playbook run against this config errors out with "The 'community.general.yaml' callback plugin has been removed." The built-in option replaces it and produces the same readable output.

    ~/inventory.ini - two groups and a parent group, so host patterns have something to select:

    [web]
    ans-a01
    
    [db]
    ans-b01
    
    [production:children]
    web
    db
    
    [web:vars]
    app_port=8080
    
    [all:vars]
    ansible_python_interpreter=/usr/bin/python3

    The short names resolve because /etc/hosts on all three machines carries them, and the control node reaches both managed nodes with an ed25519 key and passwordless sudo.

    If any of this is unfamiliar, build it first: guide 3, guide 4 and guide 5 cover the three pieces in order.

    The last two commands are the check worth running before anything else in this path: pong from both hosts, and root when escalation is on.

    bash Example session
    cat ~/ansible.cfg[defaults]inventory = ./inventory.inihost_key_checking = Falseremote_user = sysadminresult_format = yamlcat ~/inventory.ini[web]ans-a01 [db]ans-b01 [production:children]webdb [web:vars]app_port=8080 [all:vars]ansible_python_interpreter=/usr/bin/python3grep -E "ans-(ctl|a|b)01" /etc/hosts192.168.0.36 ans-ctl01192.168.0.37 ans-a01192.168.0.38 ans-b01ls -l ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub-rw------- 1 sysadmin sysadmin 411 Aug 23 07:06 /home/sysadmin/.ssh/id_ed25519-rw-r--r-- 1 sysadmin sysadmin  99 Aug 23 07:06 /home/sysadmin/.ssh/id_ed25519.pubansible-inventory --graph@all:  |--@ungrouped:  |--@production:  |  |--@web:  |  |  |--ans-a01  |  |--@db:  |  |  |--ans-b01ansible all -m ansible.builtin.pingans-a01 | SUCCESS => {    "changed": false,    "ping": "pong"}ans-b01 | SUCCESS => {    "changed": false,    "ping": "pong"}ansible all -m ansible.builtin.command -a "id -un" --becomeans-a01 | CHANGED | rc=0 >>rootans-b01 | CHANGED | rc=0 >>root

    Expected resultBoth config files as shown, and pong plus root from both managed nodes.

    Success conditionYour control node matches the one every command in this guide was run on.

  2. The shape of an ad-hoc command

    Three parts, always:

    ansible <pattern> -m <module> -a "<arguments>"

    who, what, with what. That is the whole grammar.

    ans-a01 | SUCCESS => {
        "changed": false,
        "ping": "pong"
    }

    Read the first word of each result. SUCCESS, CHANGED, FAILED, UNREACHABLE, SKIPPED - five states, and the difference between the first two is the entire idea of idempotence.

    setup is the module that returns facts, and filter= is essential - unfiltered it returns several hundred values per host:

    "ansible_hostname": "ans-a01",
    "ansible_distribution": "Ubuntu",
    "ansible_memtotal_mb": 2932

    Those names are exactly what you use later in a template or a when:.

    bash Example session
    ansible all -m ansible.builtin.pingans-a01 | SUCCESS => {    "changed": false,    "ping": "pong"}ans-b01 | SUCCESS => {    "changed": false,    "ping": "pong"}ansible web -m ansible.builtin.command -a "uptime -p"ans-a01 | CHANGED | rc=0 >>up 1 hour, 57 minutesansible all -m ansible.builtin.setup -a "filter=ansible_hostname,ansible_distribution,ansible_memtotal_mb"ans-a01 | SUCCESS => {    "ansible_facts": {        "ansible_distribution": "Ubuntu",        "ansible_hostname": "ans-a01",        "ansible_memtotal_mb": 3398    },    "changed": false}ans-b01 | SUCCESS => {    "ansible_facts": {        "ansible_distribution": "Ubuntu",        "ansible_hostname": "ans-b01",        "ansible_memtotal_mb": 3398    },    "changed": false}

    Expected resultA pong, an uptime, and three named facts.

    Success conditionYou can run any module against any pattern from memory.

  3. The handful you will actually type

    -m ansible.builtin.file    -a "path=... state=directory mode=0755"
    -m ansible.builtin.copy    -a "dest=... content='...' mode=0644"
    -m ansible.builtin.stat    -a "path=..."

    file manages existence, type and permissions; copy puts content there; stat tells you what is already there without changing anything.

    Note the argument style: key=value pairs separated by spaces, all inside one -a string. It looks odd next to YAML, and it is the same set of parameters the module takes in a playbook.

    stat is the one people forget, and it is the right tool for 'is this already done' - it never changes anything, so it is safe to run anywhere.

    bash Example session
    ansible all -m ansible.builtin.file -a "path=/tmp/adhoc-demo state=directory mode=0755"ans-a01 | CHANGED => {    "changed": true,    "gid": 1000,    "group": "sysadmin",    "mode": "0755",    "owner": "sysadmin",    "path": "/tmp/adhoc-demo",    "size": 40,    "state": "directory",    "uid": 1000}ans-b01 | CHANGED => {    "changed": true,    "gid": 1000,    "group": "sysadmin",    "mode": "0755",    "owner": "sysadmin",    "path": "/tmp/adhoc-demo",    "size": 40,    "state": "directory",    "uid": 1000}ansible all -m ansible.builtin.copy -a "dest=/tmp/adhoc-demo/note.txt content='written by an ad-hoc command\n' mode=0644"ans-a01 | CHANGED => {    "changed": true,    "checksum": "b52424da9dd1eac54020c9c91037734abf5f4fa9",    "dest": "/tmp/adhoc-demo/note.txt",    "gid": 1000,    "group": "sysadmin",    "md5sum": "30e60d9f4367b6264011e50470ab9b08",    "mode": "0644",    "owner": "sysadmin",    "size": 29,    "src": "/home/sysadmin/.ansible/tmp/ansible-tmp-1787471782.402282-11978-35299567800357/.source.txt",    "state": "file",    "uid": 1000}ans-b01 | CHANGED => {    "changed": true,    "checksum": "b52424da9dd1eac54020c9c91037734abf5f4fa9",    "dest": "/tmp/adhoc-demo/note.txt",    "gid": 1000,    "group": "sysadmin",    "md5sum": "30e60d9f4367b6264011e50470ab9b08",    "mode": "0644",    "owner": "sysadmin",    "size": 29,    "src": "/home/sysadmin/.ansible/tmp/ansible-tmp-1787471782.4044476-11979-44541526997894/.source.txt",    "state": "file",    "uid": 1000}ansible all -m ansible.builtin.command -a "cat /tmp/adhoc-demo/note.txt"ans-a01 | CHANGED | rc=0 >>written by an ad-hoc commandans-b01 | CHANGED | rc=0 >>written by an ad-hoc commandansible all -m ansible.builtin.stat -a "path=/tmp/adhoc-demo/note.txt" 2>&1 | grep -E "SUCCESS|\"mode\"|\"size\"|\"exists\""ans-a01 | SUCCESS => {        "exists": true,        "mode": "0644",        "size": 29,ans-b01 | SUCCESS => {        "exists": true,        "mode": "0644",        "size": 29,

    Expected resultA directory, a file with known content and mode, and a read-only check.

    Success conditionYou can create and inspect files across a fleet without a playbook.

  4. The ones that need root

    service_facts gathers every unit on the host - useful, and large:

    ansible web -m ansible.builtin.service_facts

    and systemd_service manages one. Note --become, because managing services needs root:

    ansible web -m ansible.builtin.systemd_service \
      -a "name=cron state=started enabled=true" --become

    The two parameters are independent and both matter: state=started is now, enabled=true is after a reboot. Setting one and assuming the other is a classic way to lose a service on the next restart.

    The result reports ok rather than changed here, because cron was already running and already enabled - the module checked and did nothing, which is what you want.

    bash Example session
    ansible web -m ansible.builtin.service_facts 2>&1 | grep -c "\.service"634ansible web -m ansible.builtin.systemd_service -a "name=cron state=started enabled=true" --become 2>&1 | grep -E "SUCCESS|CHANGED|\"state\"|\"enabled\""ans-a01 | SUCCESS => {    "enabled": true,    "state": "started",        "UnitFilePreset": "enabled",        "UnitFileState": "enabled",ansible web -m ansible.builtin.command -a "systemctl is-active cron"ans-a01 | CHANGED | rc=0 >>active

    Expected resultA unit count, an idempotent service task, and confirmation.

    Success conditionYou can manage services across hosts in one line.

  5. How many at once

    DEFAULT_FORKS(default) = 5

    Five hosts in parallel by default. And it is real - two hosts each sleeping two seconds:

    ansible all -m ansible.builtin.command -a "sleep 2"  0.33s user 0.08s system 16% cpu 2.479 total

    2.479 seconds, not 4. They ran at the same time.

    This is the number to raise on a large inventory - 5 forks against 200 hosts means 40 sequential batches. It is also the number to *lower* when a task is heavy on the control node, since every fork is a process there.

    When to stop using ad-hoc commands: as soon as you need a second step, a condition, or to do the same thing again next week. Ad-hoc is for investigation and one-offs; anything you would repeat belongs in a playbook.

    bash Example session
    ansible-config dump | grep -E "^DEFAULT_FORKS"DEFAULT_FORKS(default) = 5time ansible all -m ansible.builtin.command -a "sleep 2" 2>&1 | tail -4ans-a01 | CHANGED | rc=0 >> ans-b01 | CHANGED | rc=0 >> ansible all -m ansible.builtin.command -a "sleep 2" 2>&1  0.33s user 0.08s system 16% cpu 2.479 totaltail -4  0.00s user 0.00s system 0% cpu 2.481 totalansible all -m ansible.builtin.file -a "path=/tmp/adhoc-demo state=absent"ans-a01 | CHANGED => {    "changed": true,    "path": "/tmp/adhoc-demo",    "state": "absent"}ans-b01 | CHANGED => {    "changed": true,    "path": "/tmp/adhoc-demo",    "state": "absent"}

    Expected resultFive forks by default, and two hosts finishing in the time of one.

    Success conditionYou know how wide Ansible runs, and how to change it.

Troubleshooting

Official sources