steps: is an ordered list. Each entry carries exactly one kind — the key that says what it does — plus optional modifiers that say where and how.
steps:
- name: Start the receiver # modifier: a label for the log
run: /root/rx.py --freq 2.4e9 # kind: what to do
target: rx # modifier: which resource
| Modifier | Meaning |
|---|---|
name |
Label shown while running. Write one; the default is the index. |
target |
Which declared resource to run on. Omitted means every node. |
on |
Alias for target. |
vars |
Variables for this step |
when |
Condition; skip the step when false |
register |
Save the result under this name for later steps |
timeout |
Seconds before giving up |
ignore_errors |
Continue even if this step fails |
Eight are implemented. Two more are accepted by the schema but not yet built — covered at the end, because the engine tells you so explicitly rather than failing obscurely.
role — run a roleThe main one. Composes the reusable role library, or any role you can reach.
- name: Measure BER
role: cosmos_lab.orchestration.fr3_ofdm_ber
target: rx
vars:
cosmos_fr3_sub: "{{ sub }}"
cosmos_fr3_amplitude: "{{ amplitude }}"
Most descriptions are one role step and little else — the role holds the complexity, the description holds the choices.
run — run a commandShort form, when there is nothing to configure:
- run: uhd_find_devices
target: tx
Long form:
- name: Start the transmitter
run:
cmd: /root/tx.py --freq 2400000000 --gain 70
background: true # start and keep going
unit: cosmos-tx # name the transient systemd unit
chdir: /root/experiment
environment: {UHD_IMAGES_DIR: /usr/share/uhd/images}
rc_ok: [0, 2] # exit codes that count as success
target: tx
register: tx_start
background: true starts the command as a transient systemd unit rather than a detached process. That matters: it survives the Ansible connection closing, it can be stopped by name in teardown, and its output goes to the journal where you can read it after the fact. Give it a unit: name — the default is derived from the step index, which is not memorable when you come to stop it.
service — configure shared equipmentAttenuator matrices, positioners, RF switches — equipment shared between experiments, reached over HTTP rather than by logging into a node.
- name: Set the path attenuation
service:
name: rfmatrix
atten_db: 40
Anything put into a non-default state must be restored. Roles that configure equipment already do this; if your description configures something directly, put the restore in
teardownso it runs even when the experiment fails.
collect — bring files back mid-runUsually you use the top-level collect: key, which runs at the end. A collect step is for grabbing something at a specific moment — before a reconfiguration destroys it, say.
- name: Save the calibration before retuning
collect: [/run/cosmos/cal.json]
target: rx
barrier — wait for every node to arriveMulti-node experiments need this whenever one side must be ready before the other proceeds. Without it, a receiver can start capturing before the transmitter exists, and the result is a confident measurement of nothing.
- name: Wait for both radios to settle
barrier:
retries: 30
delay: 2
wait — pause- name: Let the transmitter settle
wait: 12
Twelve seconds is not arbitrary — a B210 needs roughly that long after starting before its output is stable, and measuring earlier reads a link that is tens of dB weaker than the real one.
assert — check something is true- name: The receiver actually saw signal
assert:
that:
- rx_power.stdout | float > -60
fail_msg: >-
Received power {{ rx_power.stdout }} dBm is below the noise floor.
Check the attenuator setting and that the transmitter is running.
Assert early and assert loudly. A run that fails in the first minute with a clear reason costs less than one that produces a plausible curve nobody can explain.
external — drop to raw AnsibleThe escape hatch, for anything the framework does not model. Inline tasks:
- name: Tune the TCP buffers
external:
tasks:
- name: Raise the receive buffer maximum
ansible.posix.sysctl:
name: net.core.rmem_max
value: "{{ rmem }}"
sysctl_set: true
reload: true
Or a role from anywhere, including Galaxy:
- name: Install Docker with the stock community role
external:
role: geerlingguy.docker
This is deliberately unrestricted. The framework is a convenience layer, not a cage — when it does not cover your case, the full tool is still underneath.
sweep and verify are accepted by the schema but the engine does not implement them. It says so, by name, and tells you what to use instead:
Step 3 uses 'sweep', which the schema accepts but this engine does not
implement yet. Express it as a 'role' step, or drop to raw Ansible with an
'external' step. Implemented kinds: role, run, wait, barrier, assert,
service, collect, external.
The schema is deliberately ahead of the engine so descriptions written against the full grammar stay valid, but an unimplemented kind fails immediately and by name rather than being silently skipped — a skipped step in an experiment is far worse than a stopped run, because the results look real.
Sweeps today live inside roles, which is why fr3_ofdm_ber takes an snr_db range rather than the description looping over it.
Steps run in order. A step without target: runs on every node in the experiment, which is right for staging software and wrong for starting a transmitter — so aim anything asymmetric.
Teardown is not a step kind: it is a separate top-level teardown: list that runs whether or not the experiment succeeded. Roles that configure equipment restore it themselves through the same mechanism.
Last verified: 2026-08-03 on console.grid.cosmos-lab.org.