Documentation
Tasks
Tasks are Rash’s main execution unit. Every task selects exactly one module and may add execution keywords such as conditions, registration, retries, privilege escalation, error handling, or output control.
Upgrading a script written for an older Rash? See Breaking changes.
- name: this must be ignored
assert:
that:
- "rash.path == ''"
ignore_errors: true
- name: save password to multiple files
when: "'MY_PASSWORD' in env"
vars:
file_name: "{{ item | split('/') | last }}"
find_query:
paths: "{{ rash.dir }}"
file_type: file
loop: "{{ find(find_query) }}"
copy:
content: "{{ env.MY_PASSWORD }}"
dest: "/tmp/MY_PASSWORD_FILE_{{ file_name }}"
mode: "400"
Keywords
| Keyword | Type | Description |
|---|---|---|
name |
string | Human-readable task name. |
when |
string or list | MiniJinja expression(s), written without {{ }}. The task runs only when all expressions are true. |
vars |
map | Variables scoped to the task. |
environment |
map | Environment variables made available while the task executes. |
register |
string | Store the structured task result under this variable name. |
changed_when |
string or list | Override the task’s changed status with a MiniJinja expression. |
failed_when |
string or list | Override the failure status of a module result with a MiniJinja expression. Errors raised by a module are failures regardless. |
ignore_errors |
boolean | Continue execution after a failed task while preserving the failed result. |
loop |
list or template | Execute the task for every rendered item. The current item is available as item; register keeps the last item’s result plus a results list. |
until |
string or list | Repeat the task until the expression becomes true. Evaluated like when, with the task vars; ignored for async tasks. |
retries |
integer | Number of retries for until; defaults to 3. |
delay |
integer | Delay between retries, in seconds; defaults to 0. |
async |
integer | Maximum runtime, in seconds, for asynchronous command/shell/script execution. |
poll |
integer | Async polling interval in seconds. 0 means fire-and-forget. |
rescue |
list | Tasks executed when the main task fails. |
always |
list | Tasks executed after the main task regardless of success or failure. |
notify |
string or list | Handler name(s) queued when the task reports changed: true. |
check_mode |
boolean | Execute the task in dry-run/check mode when supported by the module. |
quiet |
boolean | Suppress the task’s normal module-result output while leaving the task itself visible. |
no_log |
boolean | Suppress the task’s logging (name, params, result, errors and verbose output), also in become children. Use for credentials and other sensitive values. |
become |
boolean | Run the task with privilege escalation. |
become_user |
string | Target user when become is enabled. |
become_method |
string | Privilege escalation method: syscall (default) or sudo. |
become_exe |
string | Sudo executable used with become_method: sudo. |
become_password |
string | Password used with sudo become. Prefer a protected value and no_log: true. |
Boolean values can be used directly for when, changed_when and failed_when. A list of
expressions is evaluated as a logical AND.
become, check_mode, ignore_errors, quiet and no_log must be YAML booleans, and
retries, delay, async and poll YAML integers. A string, including a template such as
retries: "{{ n }}", is a parse error.
Registered results
register stores the finalized task result, after changed_when and failed_when have been
evaluated. A registered result always provides these generic fields:
| Field | Meaning |
|---|---|
changed |
Final changed status. |
failed |
Final failure status. |
output |
Generic module output, when present. |
stdout |
Compatibility alias for output. |
extra |
Module-specific structured payload. |
error |
Failure description when the task failed. |
Non-conflicting keys from extra are also exposed at the result’s top level. For example,
command, shell and script provide rc and stderr, while modules such as stat can expose
module-specific objects such as stat:
- command:
argv: [sh, -c, "printf hello; exit 3"]
register: command_result
ignore_errors: true
- debug:
msg: "rc={{ command_result.rc }}, stdout={{ command_result.stdout }}"
- stat:
path: /etc/hostname
register: hostname
- debug:
msg: "exists={{ hostname.stat.exists }}"
The generic extra field remains available even when its keys are flattened:
- debug:
msg: "{{ command_result.extra.rc }} == {{ command_result.rc }}"
Rash also provides result tests for conditions:
- command: "false"
register: probe
ignore_errors: true
- debug:
msg: "The probe failed"
when: probe is failed
- debug:
msg: "The probe succeeded"
when: probe is succeeded
Supported result tests are failed, succeeded (alias success) and changed.
Loop results
With loop, the registered variable is the last item’s result, except that changed is true when
any item changed, failed is true when any item failed and error is the first item’s error. It
also has a results list with the result of every executed item (the fields above plus item):
- command: "test -e {{ item }}"
loop: [/etc/hostname, /nonexistent]
register: checks
ignore_errors: true
- debug:
msg: "any failed: {{ checks is failed }}, rc: {{ checks.results | map(attribute='rc') | list }}"
Merging into the script variables
At the top level, and inside block and included files, the variables a task produces
(register, set_vars, include with export) are deep-merged into the script variables:
mappings merge key by key and lists are concatenated. This is long-standing behaviour:
- set_vars: { ports: [80] }
- set_vars: { ports: [443] } # ports is now [80, 443]
A register is the exception: registering again under the same name replaces the previous
result, so its results list does not accumulate. Inside rescue and always sections and
between loop items, a newer value replaces the older one.
Process failures are results
For command, shell and script, a process that starts correctly but exits non-zero still
produces a structured result. By default that result has failed: true; failed_when can redefine
which exit statuses are considered failures:
- command:
argv: [grep, -q, needle, file.txt]
register: grep_result
changed_when: false
failed_when: result.rc not in [0, 1]
- debug:
msg: "needle was not present"
when: grep_result.rc == 1
Both result and the task’s register name are available while Rash evaluates changed_when and
failed_when.
Both conditions only apply to a result the module returned. An error raised by a module (fail,
assert, file on a missing path, a template error) is a failure whatever failed_when says:
handle it with ignore_errors or rescue.
ignore_errors: true changes control flow, not the result: execution continues, but the registered
value remains failed: true. This makes it possible to inspect the exact failure later. It also
ignores errors rendering the task (undefined variables in params or conditions).
Error handling
rescue
rescue executes when the main task has a semantic or execution failure:
- name: Update application
command: ./update
rescue:
- debug:
msg: "Update failed; running recovery"
- command: ./recover
If rescue completes successfully, the task is considered recovered and execution continues.
always
always is a finally-style section and runs after the main task and any rescue section:
- name: Work with temporary state
command: ./work
always:
- file:
path: /tmp/work.lock
state: absent
rescue and always wrap the whole task execution. When the task contains a loop, the loop is
executed as one aggregate operation: rescue runs once if that operation fails, and always runs once
after it completes. They are not independently invoked for every loop item.
Section tasks see the task vars and inherit its become and check_mode settings. With
ignore_errors: true, a failure is not rescued: always still runs and the task is reported as an
ignored failure (it does not notify handlers).
Explicit script exit
meta: exit terminates a Rash script with an application-defined status code:
- name: Stop with a CLI-style usage status
meta:
action: exit
code: 2
always:
- debug:
msg: "cleanup still runs"
An explicit exit is control flow rather than a failure: it is not swallowed by ignore_errors and
does not trigger rescue, but an always section still executes before Rash exits. The code must be
between 0 and 255 and defaults to 0.
Retries
until repeats a task until its expression is true. It is evaluated like when, with the task
vars and the registered result; the current zero-based retry count is available as retries:
- command: test -S /run/app.sock
register: socket_check
changed_when: false
failed_when: false
until: socket_check.rc == 0
retries: 10
delay: 1
Failed attempts are retried too. When the retries run out the task fails with until condition not
satisfied after N retries and the registered result reports failed: true. A task skipped by
when is not retried. until, retries and delay are ignored for async tasks.
Asynchronous commands
async applies to command, shell and script tasks. Rash starts the process in a managed
process group so timeouts can terminate the process tree rather than only the immediate child.
Without stdin data the job gets an empty stdin: a background job never reads Rash’s input or the
terminal.
- command:
argv: [./long-build]
stdout: tee
stderr: tee
async: 600
poll: 2
register: build
With poll: 0, Rash returns immediately and the registered result contains rash_job_id (or
rash_job_ids for an asynchronous loop). With a positive polling interval, Rash waits and returns
the final structured process result, with the same shape as a synchronous task (and the same
loop results for a loop). transfer_pid and async execution are intentionally
incompatible.
Async tasks accept exactly the same module parameters as synchronous ones (unknown fields are
rejected, shell honors creates/removes). In check mode no job is started and the task reports
the change it would make. With become, the job runs directly as the become user, with that user’s
supplementary groups (become_method: syscall); become_method: sudo is rejected for async tasks.
until, retries and delay do not apply to an async task: the job runs once. Poll a poll: 0
job with async_status and until instead.
Signals and interactive commands
Synchronous command, shell and script processes run in Rash’s own process group, like the
foreground job of a shell: they can prompt on the terminal (read, ssh, sudo, $EDITOR) and
receive Ctrl-C directly.
When Rash receives SIGINT, SIGTERM or SIGHUP while such a process runs, it forwards signals sent
with kill (for example by docker stop) to the process, waits for it, and then stops the script:
the interruption is not swallowed by ignore_errors, failed_when, rescue or loops, but always
sections still run. A terminal Ctrl-C that the process handles itself (it exits normally, like an
editor or a REPL) does not stop Rash.
A signal sent to Rash’s whole process group (timeout(1), kill -- -PGID, pkill -g, systemd’s
KillMode=control-group) reaches the process directly and once more through Rash’s forwarding,
since si_code does not tell it from a kill to Rash alone: the process may receive it twice. On
macOS a terminal Ctrl-C is forwarded the same way.
Between tasks, or while an in-process module such as pause, copy or async polling runs, Rash
stops immediately and always sections do not run. In every case running async jobs are killed and
Rash exits with 128 + signal (130 for SIGINT, 143 for SIGTERM, 129 for SIGHUP). The exception is
pause reading hidden input (input: true, echo: false): Ctrl-C restores the terminal and stops
the script with 130, running always sections; Ctrl-D ends the input with an empty string, as with
echo: true.
Script and block defaults
For larger local scripts, the top-level mapping form can define defaults shared by tasks and handlers:
defaults:
environment:
APP_ENV: production
changed_when: false
tasks:
- command: ./inspect
- command: ./inspect-other
environment:
APP_DEBUG: "1"
handlers:
- name: reload application
command: ./reload
A task overrides a scalar default. vars and environment are merged key-by-key so a task can add
or replace individual entries. The traditional top-level sequence of tasks remains supported.
A block has the same optional defaults mechanism while preserving its original sequence form:
- block:
tasks:
- command: ./migrate
- command: ./verify
defaults:
environment:
APP_ENV: production
become: true
Output and secrets
Use quiet: true when an internal task should not contribute its normal result to script output.
This is useful with --output raw when the script should behave like a Unix command and emit only a
final selected value.
Use no_log: true for sensitive tasks. It suppresses the task’s logging rather than merely hiding
the final result: the task name is shown as <redacted>, its rendered params, result and verbose
(-v/-vv) output are not logged and a failure is reported without its details, in Rash and in a
become child alike. The registered result still holds the real values, and output a process writes
itself (stdout: tee or inherit) still reaches the terminal. On an async task, no_log covers
the task that starts the job; set it on the async_status/async_poll task too.
- name: handle a secret without printing it
set_vars:
api_token: "{{ env.API_TOKEN | default('s3cr3t-token') }}"
no_log: true
- name: derive a value from the secret
command:
argv: [sh, -c, 'printf %s "$TOKEN" | wc -c']
environment:
TOKEN: "{{ api_token }}"
register: token_length
changed_when: false
no_log: true
- name: internal step that adds nothing to the output
command: "true"
quiet: true
- assert:
that:
- token_length.stdout | trim | int == api_token | length
command, shell and script choose per stream what happens with process output: capture
(default, registered), tee (streamed live and registered), inherit (streamed live, not
registered) or null (discarded; quote it in YAML). stdin feeds data to the process; without it,
the process inherits Rash’s stdin (async jobs get an empty one).
- name: capture is the default
command: echo captured
register: captured
- name: stream output live and still register it
command:
argv: [echo, teed]
stdout: tee
register: teed
- name: let the process write to Rash's own stdout
command:
argv: [echo, inherited]
stdout: inherit
register: inherited
- name: discard noisy stderr
shell:
cmd: echo kept; echo noise >&2
stderr: "null"
register: quiet_stderr
- name: feed stdin
command:
argv: [tr, a-z, A-Z]
stdin: "hello stdin"
register: upper
- assert:
that:
- captured.stdout == "captured\n"
- teed.stdout == "teed\n"
- not inherited.stdout
- quiet_stderr.stdout == "kept\n"
- not quiet_stderr.stderr
- upper.stdout == "HELLO STDIN"
Using become
Syscall method (default)
The syscall method starts a child Rash process that switches to the become user (its supplementary
groups from initgroups(3), on Linux and macOS alike, its GID and UID) before running the task, and
requires CAP_SETUID and CAP_SETGID (or root):
sudo setcap cap_setgid,cap_setuid+ep $(which rash)
- name: Configure a root-owned file
become: true
copy:
dest: /etc/example.conf
content: enabled=true
Sudo method
The sudo method delegates privilege escalation to a sudo-compatible executable:
- name: Install package
become: true
become_method: sudo
become_user: root
command:
argv: [apt, install, -y, nginx]
A password can be supplied through become_password, or requested with --ask-become-pass / -K:
rash --become --become-method sudo -K script.rh
| Aspect | syscall |
sudo |
|---|---|---|
| Requires UID/GID capabilities | Yes | No |
| Requires sudo executable | No | Yes |
| Password support | No | Yes |
| Extra child Rash process | Yes (not for the current user or with transfer_pid) |
Yes (started by sudo) |
Control-flow modules
Modules that only drive Rash itself (block, include, meta, set_vars, debug, assert,
fail, pause, async_status, async_poll and custom modules) always run in the Rash process,
never as the become user, so meta: exit keeps its exit code and vars stay in scope. When
become (or check_mode) is set on a block or include, every child task inherits it and
escalates on its own; a child cannot turn it off.
With both methods, task data is exchanged with the child through private (0600) temporary files
that are removed afterwards, also when the script is interrupted. The syscall child opens them
before switching user; with the sudo method, becoming a non-root user other than the current one
requires running Rash as root. Signals sent to Rash while a become task runs are forwarded to the
child, like for any other process (see Signals).
The temporary directory may be writable by other users (e.g. a TMPDIR kept by sudo -E), so the
child only gets the user to switch to from Rash’s command line, never from those files, and refuses
a task or result file that is not a regular, single-link, owner-only file of the expected owner;
Rash reads the result through the file it created instead of reopening its path.
With become_method: sudo, Rash running as root and a non-root become_user, the task file is
handed to that user: it holds the task’s rendered params and the whole variable context (registered
results, env, script arguments, pause input), which that user can read. Keep secrets out of the
variables of a script that escalates to a less trusted user.
A command with transfer_pid: true replaces Rash with the program, so a failed exec (program
missing or not executable) ends Rash at once with exit status 1 and an error naming the program,
like exec in sh: ignore_errors and rescue do not apply, with or without become. With the
syscall method Rash switches user in place before the exec, so it never goes on running further
tasks as the become user.
Known differences from Ansible
failed_whenandchanged_whenonly apply to module results; an error raised by a module fails the task regardless (see Process failures are results).until,retriesanddelayare ignored forasynctasks.- A
blocknever reportschanged:registerornotifyon a block sees no change, use them on its child tasks. Anotifyinside ablockor an included file does not reach the script’s handlers (an included file runs its ownhandlers). - New variables are deep-merged at the top level and lists are concatenated (see Merging into the script variables).
script.args,script.executable,cmdwithtransfer_pidand shebang lines are split with shell-like quoting where a word starting with#begins a comment; quote it ("'#channel'").