AnsibleIX · command, shell and rawcommand, shell and raw
script: transferring a script and running it
What you'll learn
- Describe what script does that copy plus command does not, and vice versa
- Use creates, removes, chdir and executable with script correctly
- Explain why script needs no Python on the managed node
- State what check mode and ansible-lint can and cannot see inside a script
- Decide when a script is genuinely the right answer
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
ansible.builtin.script takes a script that lives on the controller,
transfers it to the managed node, runs it, and cleans up after itself.
It sits between shell and a proper module in almost every dimension:
more capable than a one-line command, less inspectable than a module,
and — importantly for a course organised around blast radius — the point
at which the tooling stops being able to tell you what a task will do.
What it does that copy plus command does not
The obvious alternative is two tasks: copy the script, then run it. The differences are real and cut both ways.
script | copy + command | |
|---|---|---|
| Script left on the managed node afterwards | no | yes, until you remove it |
| Requires Python on the target | no | yes, for copy |
creates / removes guards | yes | on the command half |
| File mode, owner, SELinux context managed | no | yes, by copy |
| Idempotent on its own | no | copy is; command is not |
| stdout and stderr separated in the result | no — see below | yes |
| Vaulted script decrypted automatically | yes | yes |
The “no Python required” row is the one people forget. script shares
that property with raw — ansible-doc records the same platform note
for both — which makes it the natural second step in a bootstrap
sequence, when raw has got you a shell but not yet an interpreter.
Writing the task
- name: Run the vendor preflight checker
ansible.builtin.script: files/vendor-preflight.sh --strict
- name: Same task, options spelled out
ansible.builtin.script:
cmd: files/vendor-preflight.sh --strict
chdir: /opt/vendor
creates: /opt/vendor/.preflight-passed
executable: /bin/bashThe documented options are cmd, chdir, creates, removes,
executable and the free-form form, and that is the complete list.
There is no mode, no owner, no argv.
Two behaviours worth naming:
executablenames the interpreter to invoke the script with. If the script has a shebang and is being run through the remote shell, you rarely need it; set it when you want to be certain which interpreter runs, or when the file has no shebang.- Arguments after the path are shell-interpreted on the target, the
same as
shell. Everything the injection lesson said applies here: a{{ var }}in the argument list needs| quote, and there is noargvescape hatch.
Guards work; idempotency does not come free
script supports creates and removes exactly as command does, and
gets the same partial check-mode support from them. Everything else about
change reporting is the same too: the task reports changed whenever the
script ran.
- name: Migrate the on-disk data format to v3
ansible.builtin.script:
cmd: files/migrate-to-v3.sh
creates: /var/lib/app/.format-v3
- name: Reconcile licences, reporting honestly
ansible.builtin.script: files/licence-reconcile.sh
register: licence
changed_when: licence.stdout is not search('already up to date')The marker discipline matters more here than with command, because a
script is usually doing several things. Write the marker as the script’s
final action, after everything that could fail has succeeded. A
marker written first turns a partial failure into a task that will never
run again.
What you give up
This is the part that decides whether a script is the right answer.
Check mode sees nothing. The check-mode attribute is partial, and
the detail is identical to command: the support consists of creates
and removes. Under --check an unguarded script task is skipped, so
a dry run tells you the script would have run and nothing about what it
would have done.
ansible-lint sees nothing. It reads your YAML. The script is a
file it does not parse, so every rule about deprecated modules, unquoted
octal modes, become usage and shell safety stops applying at the task
boundary. Whatever is in that file is unreviewed by the automation.
--diff sees nothing. The diff attribute is support: none. If the
script rewrites /etc/app/main.conf, no diff appears in the output,
because nothing in the pipeline knows a file was touched.
The result is unstructured. You get a return code and a combined output stream. Everything downstream is string matching.
When a script is genuinely right
Three cases hold up under review:
- A vendor installer or a proprietary CLI sequence. The logic is not yours, it is not idempotent, and rewriting it as modules would mean reimplementing a vendor’s product.
- A one-shot migration. It runs once per host in its life, it is
guarded by
creates, and its correctness is established by testing rather than by reasoning about convergence. - A bootstrap step that must run before Python exists.
rawgets you a shell;scriptgets you an arbitrarily long program without needing an interpreter on the target.
Blast radius
A script task ships an arbitrary program to every targeted host and
runs it, usually with become: true, with no dry run and no diff. The
blast radius is “whatever the script does”, and the only way to know
what that is, is to read it.
Two controls are worth making habitual:
# What is the actual host list?
ansible-playbook -i inventories/prod migrate.yml --limit dbservers --list-hosts
# Prove it on one host first. --check will not exercise the script at all.
ansible-playbook -i inventories/prod migrate.yml --limit db01.example.com --diffThe second line is not a formality. For a play whose real work is in a
script task, a single-host run is the only dry run that exists.
Knowledge check
Knowledge check · 4 questions
Q1. A team replaces an ansible.builtin.script task with ansible.builtin.copy plus ansible.builtin.command. A failed_when matching on result.stderr suddenly starts working. Why did it not work before?
Q2. Which of these does ansible.builtin.script give you? Select all that apply.
Q3. Under --check, an ansible.builtin.script task with no creates or removes guard is skipped without running.
Q4. A one-shot migration script writes its marker file as its first action, before doing any work. What is the failure this creates?
Passing score: 75%. Answers are checked in this browser.