Write, run and collect a COSMOS experiment from scratch — then do the same thing again using a role somebody else already wrote and published to Ansible Galaxy.
COSMOS experiments are described in YAML and run by a framework that is already installed on every console. You do not download a framework, clone a repository, or install anything to get started. You write a file that says what you need and what to do, and one command runs it.
That is the whole inversion versus the older model, where every tutorial shipped its own copy of the machinery. Here the machinery is console-resident and your experiment is a dozen lines.
This tutorial builds two experiments:
hello-cosmos — one node, one command, one result collected back to your home directory. Nothing but the framework.hello-galaxy — the same shape, but the work is done by geerlingguy.ntp, a stock role from Ansible Galaxy, used unmodified.The second one matters more than it looks. A COSMOS experiment is ordinary Ansible with a resource-resolution layer in front, which means the very large body of Ansible content that already exists is available to you without porting anything.
After completing this tutorial you will be able to:
cosmos/v1 experiment description and understand each of its five sections.run: is not a shell, and step variables are not scoped to their step.| Difficulty | Beginner — this is the on-ramp |
| Estimated time | 20 min |
| Domain / sandbox | Any. Both experiments ask for "any node" |
| Topic group | Getting started |
| Transmits? | No |
| Last verified | 2026-08-12 on grid (node1-1) |
| Role | Qty | Notes |
|---|---|---|
| Any node | 1 | Both experiments declare requires: {}, so anything powered on will do |
None. Neither experiment requests an image, so whatever the node is running is used as-is. That is deliberate — imaging costs around 800 seconds against a 120-minute reservation, and a first experiment should not spend it.
| Component | Source |
|---|---|
cosmos-orchestration |
preinstalled on every console |
cosmos-experiments |
preinstalled; carries the two descriptions used here |
geerlingguy.ntp |
Ansible Galaxy — installed in step 5, only for the second experiment |
[ your console ] --ssh--> [ one node ]
cosmos-run runs the steps
^ |
+------ collect -----------+
~/cosmos-artifacts/
Log into any console and confirm the framework is there:
ssh <username>@console.grid.cosmos-lab.org
cosmos-run --version
cosmos-run (cosmos_lab.orchestration 0.1.0)
loaded from: /usr/share/ansible/collections/ansible_collections/cosmos_lab/orchestration
ansible-playbook [core 2.16.3]
--version prints a path as well as a version, because the collection can be installed twice — the package system-wide, and a user copy in ~/.ansible/collections that comes first and silently shadows it. When a change "did not take effect", this is the first thing to check.
Create ~/my-first.yml:
apiVersion: cosmos/v1
name: my-first
description: Can I get a node at all?
resources:
node:
requires: {}
Five lines of content, and already runnable. Every description has the same skeleton:
| Section | What it is |
|---|---|
apiVersion / name |
name seeds the run ID; lowercase, digits and dashes |
parameters |
typed, defaulted values you can override with -e |
resources |
what you need, described by capability — never by node name |
steps |
what to do, in order |
collect |
files or directories to fetch back afterwards |
requires: {} means "any node". Note it is an empty mapping — requires: on its own is null and the description is rejected.
cosmos-run my-first.yml --resolve-only
"msg": "my-first on grid: node=node1-1.grid.cosmos-lab.org "
--resolve-only reads your description and the read-only inventory and answers could this run here, right now, and on which nodes. It touches no hardware and needs no reservation, so ask it before booking time, and ask it once per sandbox you are considering.
When it cannot be satisfied it says what was missing and counts each requirement independently, so you learn which requirement is the real blocker rather than just the first one.
Now the full experiment. This is getting-started/hello-cosmos.description.yml, already packaged on the console:
apiVersion: cosmos/v1
name: hello-cosmos
description: One node, one command, one collected result
parameters:
message:
type: string
default: hello from COSMOS
resources:
node:
requires: {}
steps:
- name: Ask the node what it is
run: uname -sr
target: node
register: kernel
- name: Write a report where collect can find it
external:
tasks:
- name: Create the results directory
ansible.builtin.file:
path: /run/cosmos/hello
state: directory
mode: "0755"
- name: Save the report
ansible.builtin.copy:
dest: /run/cosmos/hello/report.txt
mode: "0644"
content: |
{{ message }}
host: {{ inventory_hostname }}
kernel: {{ kernel.stdout }}
target: node
collect: [/run/cosmos/hello]
Two step kinds appear here:
run: executes a command on the node. It is ansible.builtin.command, not a shell — no pipes, no > redirection, no globbing. This is the single most common first-time surprise. When you genuinely need a shell, use an external: step with ansible.builtin.shell and be explicit about it.external: takes raw Ansible tasks. Anything you could write in an ordinary playbook goes here unchanged — which is also the doorway to reusing content you did not write.target: node aims a step at one resource. Use target:, never on: — in YAML 1.1 an unquoted on is the boolean true, so on: node parses as {true: 'node'}, the target vanishes and the step runs everywhere. The validator rejects it rather than letting that happen silently.
cosmos-run getting-started/hello-cosmos.description.yml
TASK [Execute each step in order] **********************************************
PLAY RECAP *********************************************************************
localhost : ok=20 changed=3 unreachable=0 failed=0
node1-1.grid.cosmos-lab.org : ok=38 changed=8 unreachable=0 failed=0
Everything collect: named is fetched to ~/cosmos-artifacts/<run-id>/, under a directory named for the node it came from:
ls ~/cosmos-artifacts/hello-cosmos-20260812T020451Z/
description.yml
node1-1.grid.cosmos-lab.org/run/cosmos/hello/report.txt
run.json
cat ~/cosmos-artifacts/*/node1-1.grid.cosmos-lab.org/run/cosmos/hello/report.txt
hello from COSMOS
host: node1-1.grid.cosmos-lab.org
kernel: Linux 5.4.0-137-generic
Two files come free with every run. description.yml is a verbatim copy of what actually ran, and run.json records the framework version, the resolved nodes, and the parameters in force — so a result stays traceable to the thing that produced it.
Override a parameter without editing the file:
cosmos-run getting-started/hello-cosmos.description.yml -e message="my second run"
Everything so far was hand-written. Most of the time it should not be, because the work has usually been done already.
First install the content. The framework ships a requirements.yml with exact version pins:
ansible-galaxy install -r /usr/share/cosmos-experiments/requirements.yml
- downloading role 'ntp', owned by geerlingguy
- extracting geerlingguy.ntp to /home/<you>/.ansible/roles/geerlingguy.ntp
- geerlingguy.ntp (2.5.0) was installed successfully
Note install, not collection install — the latter ignores the roles: section entirely, which is the usual reason a role looks missing.
¶ ⚠️ The consoles share one home directory
console.grid,console.sb1,console.sb4and the rest mount the same NFS home, so~/.ansibleis a single fleet-wide tree, not a per-console one. Installing affects every sandbox at once.In particular,
ansible-galaxy ... --forcedeletes the destination tree before re-extracting it, which kills any run in progress on any sandbox. Install when things are quiet, and avoid--forceunless you know the fleet is idle.
Now getting-started/hello-galaxy.description.yml:
steps:
- name: Configure time sync with a stock Galaxy role
role: geerlingguy.ntp
target: node
vars:
ntp_timezone: "{{ ntp_timezone }}"
ntp_manage_config: true
- name: Check the result
run: timedatectl show --property=Timezone --value
target: node
register: tz
cosmos-run getting-started/hello-galaxy.description.yml
host: node1-1.grid.cosmos-lab.org
timezone: Etc/UTC
clocksource: tsc
There is no wrapper, no port and no COSMOS-specific fork. role: takes any role on the roles path, and the role runs exactly as published.
| You have | Use | Example |
|---|---|---|
| A role from Galaxy or your own repo | a role: step |
role: geerlingguy.ntp |
| A collection module | an external: step |
ansible.posix.sysctl in the TCP throughput experiment |
| Tasks copied from a playbook you already have | an external: step |
paste the task list unchanged |
What does not map directly is a complete downloaded playbook, because a playbook carries its own hosts: line and the framework has already resolved which nodes you have. Lift the tasks or the role out of it; that is where the reusable part lives anyway.
Two rules make reuse safe:
vars:, never by editing the role. Editing forks it, and the fork stops receiving upstream fixes. Anything you do not set keeps the role's own defaults.requirements.yml uses version: "2.5.0", not a range. A result is only reproducible if the code that produced it can be identified, and a range means tomorrow's run can legitimately pull different code.You have finished when all three are true:
cosmos-run getting-started/hello-cosmos.description.yml --resolve-only names a node.failed=0 for both localhost and the node.~/cosmos-artifacts/<run-id>/ contains report.txt, run.json and description.yml.Nothing to undo. Neither experiment images a node, transmits, or changes shared equipment, and the framework releases the nodes it held at the end of every run — including when a run fails.
Artifacts accumulate in ~/cosmos-artifacts/; delete old run directories when you no longer need them.
| Symptom | Likely cause | Fix |
|---|---|---|
requires rejected as invalid |
requires: with nothing after it is null |
Write requires: {} — an empty mapping |
| A step ran on every node | You wrote on: instead of target: |
Use target:. Unquoted on is the boolean true in YAML |
No such file or directory from a run: step using > or \| |
run: is command, not a shell |
Use an external: step with ansible.builtin.shell |
the role '<name>' was not found |
The role is not installed, or was installed with collection install |
ansible-galaxy install -r requirements.yml; requires cosmos-orchestration ≥ 0.1.1 for ~/.ansible/roles to be searched |
| A change to the framework "did nothing" | A user copy in ~/.ansible/collections is shadowing the package |
cosmos-run --version prints the path actually in use |
cosmos-run: description not found |
It is not in ~/cosmos-experiments or /usr/share/cosmos-experiments |
Run it by path, or install the cosmos-experiments package |
--detach for long runsOrchestration: cosmos_lab.orchestration (cosmos-run).
Last verified: 2026-08-12 on grid, node1-1, both experiments run end to end with artifacts collected.
Tags: getting-started, ansible, orchestration, cosmos-run, galaxy.