CertGrid CertGrid
Hands-on Lab·Ansible

Ansible command, shell and raw Modules

Three modules run things on a host and choosing between them is a recurring exam question. `command` runs a binary with no shell, `shell` runs a shell, and `raw` needs no Python at all. The interesting part is where the boundary really falls: `command` has no shell but does expand environment variables, which is not what it is usually described as doing.

Ad-hoc Commands and Modules Guide 7 of 45 Intermediate

Written against the versions above. `expand_argument_vars` defaults to `true` and has since ansible-core 2.16. It expands environment variables in arguments before running the command, without involving a shell - and, as the module documentation puts it, an unmatched variable is "left unchanged, unlike shell substitution which would remove it".

One managed node is enough - every difference here is about what happens on a single host.
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

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. command runs a binary, not a shell

    The simple case works:

    ans-a01 | CHANGED | rc=0 >>
    hello

    A pipe does not:

    ans-a01 | FAILED | rc=1 >>
    error: garbage option
    
    Usage:
     ps [options]

    Read who complained. That is ps rejecting its arguments, not Ansible rejecting yours. command split the string and ran ps with -ef, | and wc and -l as arguments. There is no shell to interpret the pipe, so it was just another word.

    Redirection goes the same way, and more quietly:

    ans-a01 | CHANGED | rc=0 >>
    redirected > /tmp/cmd-test.txt

    rc=0, and CHANGED. It succeeded. echo printed three words, one of which was >. No file was created, nothing reported a problem, and in a playbook this would pass. That is the dangerous one.

    bash Example session
    ansible web -m ansible.builtin.command -a "echo hello"ans-a01 | CHANGED | rc=0 >>helloansible web -m ansible.builtin.command -a "ps -ef | wc -l"[ERROR]: Task failed: Module failed: non-zero return codeOrigin: <adhoc 'ansible.builtin.command' task> {'action': 'ansible.builtin.command', 'args': {'_raw_params': 'ps -ef | wc -l'}, 'timeout': 0, 'async_val': 0, [...] ans-a01 | FAILED | rc=1 >>error: garbage option Usage: ps [options]  Try 'ps --help <simple|list|output|threads|misc|all>'  or 'ps --help <s|l|o|t|m|a>' for additional help text. For more details see ps(1).non-zero return code[exit 2]ansible web -m ansible.builtin.command -a "echo redirected > /tmp/cmd-test.txt"ans-a01 | CHANGED | rc=0 >>redirected > /tmp/cmd-test.txt

    Expected resultA success, an error from ps itself, and a silent no-op.

    Success conditionYou can recognise a shell construct that was passed through as text.

  3. But it does expand environment variables

    Here is where the usual explanation breaks down:

    ans-a01 | CHANGED | rc=0 >>
    /home/sysadmin

    $HOME was expanded, by a module that runs no shell. And yet:

    ans-a01 | CHANGED | rc=0 >>
    $RANDOM-$$

    $RANDOM and $$ were not.

    It is not something upstream doing it - -vvv shows the module received the string untouched:

    "_raw_params": "echo $HOME"

    The module's own documentation names the mechanism:

       expand_argument_vars  Expands the arguments that are variables, for
                             example `$HOME' will be expanded before being
                             passed to the command to run.
                             If a variable is not matched, it is left
                             unchanged, unlike shell substitution which
                             would remove it.

    And turning it off proves it:

    ans-a01 | CHANGED | rc=0 >>
    $HOME

    So the accurate rule is: command expands environment variables and nothing else. $RANDOM and $$ are shell features, not environment variables, so they survive as text - and unlike a shell, an unmatched variable is left in place rather than silently becoming an empty string.

    bash Example session
    ansible web -m ansible.builtin.command -a 'echo $HOME'ans-a01 | CHANGED | rc=0 >>/home/sysadminansible web -m ansible.builtin.command -a 'echo $RANDOM-$$'ans-a01 | CHANGED | rc=0 >>$RANDOM-$$ansible web -m ansible.builtin.command -a 'echo $HOME' -vvv 2>&1 | grep -oE '"_raw_params": "[^"]*"' | head -1"_raw_params": "echo $HOME"ansible-doc ansible.builtin.command 2>/dev/null | grep -A5 "   expand_argument_vars"   expand_argument_vars  Expands the arguments that are variables, for                         example `$HOME' will be expanded before being                         passed to the command to run.                         If a variable is not matched, it is left                         unchanged, unlike shell substitution which                         would remove it.ansible web -m ansible.builtin.command -a '{"argv": ["echo", "$HOME"], "expand_argument_vars": false}'ans-a01 | CHANGED | rc=0 >>$HOME

    Expected resultOne variable expanded, two left alone, and the option that controls it.

    Success conditionYou know precisely what reaches the binary.

  4. shell gives you all of it

    ans-a01 | CHANGED | rc=0 >>
    122
    ans-a01 | CHANGED | rc=0 >>
    22664-2402
    ans-a01 | CHANGED | rc=0 >>
    redirected

    Pipe, shell variables, redirection - all working, because there is now a /bin/sh between Ansible and the command.

    The cost is that you have taken responsibility for quoting and for anything the shell might do with your input. That is why the guidance is command unless you need a shell feature - not because shell is broken, but because a shell will happily interpret a filename containing a semicolon.

    In practice: reach for a real module first, command second, shell when you genuinely need a pipe or redirection, and treat a playbook full of shell tasks as a sign that a module was missed.

    bash Example session
    ansible web -m ansible.builtin.shell -a "ps -ef | wc -l"ans-a01 | CHANGED | rc=0 >>124ansible web -m ansible.builtin.shell -a 'echo $RANDOM-$$'ans-a01 | CHANGED | rc=0 >>-9345ansible web -m ansible.builtin.shell -a "echo redirected > /tmp/cmd-test.txt && cat /tmp/cmd-test.txt"ans-a01 | CHANGED | rc=0 >>redirected

    Expected resultAll three shell features working.

    Success conditionYou can choose between the two on the basis of what you need.

  5. raw needs no Python at all

    Point the interpreter at a path that does not exist and command dies:

    ans-a01 | FAILED! => {
        "module_stdout": "/bin/sh: 1: /usr/bin/python-does-not-exist: not found\r\n",
        "msg": "The module interpreter '/usr/bin/python-does-not-exist' was not found.",

    The same command through raw works:

    ans-a01 | CHANGED | rc=0 >>
    up 1 hour, 58 minutes
    Shared connection to ans-a01 closed.

    That is the entire reason raw exists. It sends the string straight down the SSH connection with no module, no JSON and no Python. Two jobs only:

    1. Bootstrapping - installing Python on a host that has none.
    2. Devices that cannot run Python - switches, appliances.

    Notice Shared connection to ans-a01 closed. in the output. raw gives you the raw SSH session, chatter and all, because nothing is parsing it. It also cannot be idempotent, cannot report changed honestly, and does not support --check.

    bash Example session
    ansible web -m ansible.builtin.command -a "uptime -p" -e ansible_python_interpreter=/usr/bin/python-does-not-exist 2>&1 | grep -E "FAILED|module_stdout|msg" | head -3ans-a01 | FAILED! => {    "module_stdout": "/bin/sh: 1: /usr/bin/python-does-not-exist: not found\r\n",    "msg": "The module interpreter '/usr/bin/python-does-not-exist' was not found.",ansible web -m ansible.builtin.raw -a "uptime -p" -e ansible_python_interpreter=/usr/bin/python-does-not-existans-a01 | CHANGED | rc=0 >>up 2 hours, 0 minutesShared connection to ans-a01 closed.

    Expected resultA module failure and a raw success against a broken interpreter.

    Success conditionYou know the one situation that requires raw.

  6. Making command idempotent

    command always reports CHANGED, because it cannot know what your binary did. Two parameters fix that for the common cases:

    ans-a01 | CHANGED | rc=0 >>
    ans-a01 | SUCCESS | rc=0 >>
    skipped, since /tmp/marker-file existsDid not run command since '/tmp/marker-file' exists

    Second run: SUCCESS, not CHANGED, and the command never ran. creates= tells Ansible 'if this path exists, there is nothing to do'. removes= is the mirror image - only run if the path is there:

    ans-a01 | SUCCESS | rc=0 >>
    skipped, since /tmp/marker-file does not exist

    These two turn a script invocation into something safe to run repeatedly, and they are the standard answer when an exam task says 'run this installer only if it has not already been run'.

    The other tool is changed_when: in a playbook, which lets you decide from the command's own output whether anything actually changed.

    bash Example session
    ansible web -m ansible.builtin.command -a "touch /tmp/marker-file creates=/tmp/marker-file"ans-a01 | CHANGED | rc=0 >>ansible web -m ansible.builtin.command -a "touch /tmp/marker-file creates=/tmp/marker-file"ans-a01 | SUCCESS | rc=0 >>skipped, since /tmp/marker-file existsDid not run command since '/tmp/marker-file' existsansible web -m ansible.builtin.command -a "rm /tmp/marker-file removes=/tmp/marker-file"ans-a01 | CHANGED | rc=0 >>ansible web -m ansible.builtin.command -a "rm /tmp/marker-file removes=/tmp/marker-file"ans-a01 | SUCCESS | rc=0 >>skipped, since /tmp/marker-file does not existDid not run command since '/tmp/marker-file' does not exist

    Expected resultEach command running once and skipping the second time.

    Success conditionYou can make a raw command safe to run twice.

Troubleshooting

Official sources