The warm Python path
Most Ansible modules are Python programs. Volant runs them unchanged, but it does not start a new interpreter for each task.
How it works
Section titled “How it works”flowchart TB H[Python helper with ansible-core] --> B[union blob: one zip per run, named by its hash] B -- sent once, cached, hash verified --> AG[agent on the host] AG --> S[warm Python server, module_utils loaded once] S -- fork --> T1[module A] S -- fork --> T2[module B] class B,S key
- The controller asks a helper process to build ansible-core’s payload for every module the run needs.
- Volant merges them into one zip, the union blob, with every
module_utilsfile they import. The blob is named by the BLAKE3 hash of its contents. - The blob is sent to each host once and cached there. The agent checks the hash before using it, and a host that already has it receives nothing.
- The agent starts one Python server per target user. The server loads the shared code from the blob once, then forks a child for each task. The child runs the module and writes its JSON result back over a pipe.
The union is built before the first connection, so it has to hold every module a host might reach. It carries the modules the plays name, those of every task and handler file of the roles in play (their meta/main.yml dependencies included), and those of any file an include names literally. A module named only in a file for another platform, such as setup-RedHat.yml next to setup-Debian.yml, is in it too, so an include_tasks: setup-{{ ansible_os_family }}.yml finds its module whichever file a host picks. A name there that no module answers to is left out, and fails by name only if a host reaches it.
Module results are untrusted and are never rendered as controller templates. See Trusted and untrusted values. As ansible-core does on the controller, Volant adds stdout_lines and stderr_lines to a result that has stdout or stderr without them, split with Python’s splitlines() rules.
What each side needs
Section titled “What each side needs”The controller needs Python 3 with ansible-core. Volant picks VOLANT_PYTHON first, then the active virtual environment, then python3 on your PATH.
The host needs a Python 3 interpreter, but not ansible-core: the blob carries the module and every file it imports. The agent reports the interpreters it finds when it starts.
Measured cost
Section titled “Measured cost”These numbers come from an Ubuntu 24.04 host with Python 3.12, over SSH. Each figure is a median.
- Ansible, default settings
- Ansible, pipelining
- Payload as a plain process
- Fork, nothing preloaded
- Fork, module_utils loaded
Show as a table
| Cost of running one module | |
|---|---|
| Ansible, default settings | 765 ms |
| Ansible, pipelining | 469 ms |
| Payload as a plain process | 340 ms |
| Fork, nothing preloaded | 266 ms |
| Fork, module_utils loaded | 12.8 ms |
Per module under Volant, the cost follows the work the module does. apt really does talk to dpkg: the warm path removes overhead, not work.
- ping
- lineinfile
- stat
- file
- apt
Show as a table
| Per-task cost under Volant, by module | |
|---|---|
| ping | 12.8 ms |
| lineinfile | 22.0 ms |
| stat | 31.3 ms |
| file | 31.6 ms |
| apt | 755 ms |
Sending the modules as one archive saves 72.7 percent of the bytes, because the files they share travel once. Loading it costs 264.5 ms, once per server.
- One union blob
- One zip per module
Show as a table
| Bytes sent for five modules | |
|---|---|
| One union blob | 0.63 MB |
| One zip per module | 2.31 MB |
Decision record 0006 has the reasoning and a measurement on a full playbook.
Modules from collections
Section titled “Modules from collections”A module from an installed collection, such as ansible.posix.sysctl or community.general.ufw, runs on the warm Python path like a builtin one. Volant never installs a collection. Before the first connection it asks the controller’s ansible-core what each dotted name is, and ansible-core looks only in the collection paths it is configured with, such as ANSIBLE_COLLECTIONS_PATH. Nothing is fetched to answer.
A name outside ansible.builtin and ansible.legacy gets one of four answers:
| What ansible-core finds | What Volant does |
|---|---|
| a module it can build | adds it to the union and runs it |
a module its collection runs through an action plugin, directly or through runtime.yml routing |
stops the run: Volant does not run a collection’s action plugins |
| no module by that name | stops the run with the reference’s sentence |
| a module it cannot run: removed, PowerShell only, or failing to build | stops the run, with the reason |
The refusals come before the first connection, with exit code 4:
task 'Forward': couldn't resolve module/action 'ns.coll.mod'. This often indicates a misspelling, missing collection, or incorrect module path. The collection 'ns.coll' is not installed on the controller: install it with ansible-galaxy collection install ns.colltask 'Forward': module 'ns.coll.mod' needs an action plugin from its collection, which this release does not runtask 'Forward': module 'ns.coll.mod' cannot run: <reason>The install hint appears only when the collection itself is missing. A module missing from a collection that is installed gets the first sentence alone. ansible.builtin.nosuch also gets it alone, because no collection can supply a name in the namespace ansible-core owns. Without an ansible-core on the controller, a name outside ansible.builtin is refused with the same first sentence and the reason nothing could look it up.
In the union, a builtin keeps its short name and a collection module is keyed by its full name. Two collections that each ship a module called sysctl stay apart, and community.general.command is never mistaken for command.
A collection module named only in a role’s file for another platform is resolved too. If it can be built, it joins the union. Anything else is left out without stopping the run, and the include that reaches it fails for that host before any of the file’s tasks run:
task 'Forward': module 'ansible.posix.sysctl' was not resolved to a module before the run, so no payload holds it: its collection is not installed on the controller, runs it through an action plugin, or it is named only in a file nothing read before the first connectionA collection module’s arguments written as a string (ansible.posix.sysctl: name=net.ipv4.ip_forward value=1) are split into key=value pairs the way ansible-core’s parse_kv splits them.
Modules backed by an action plugin
Section titled “Modules backed by an action plugin”Some ansible-core modules only work together with an action plugin that runs on the controller first. package picks the host’s package manager, service picks its init system, and template renders the file on the controller before anything is sent. Sending those modules without their plugin would do something different from what the playbook asked.
Volant has its own version of eight of these plugins: copy, dnf, fetch, package, reboot, service, template and unarchive run on the controller as a short sequence of sub-tasks, each an ordinary Python module or a bare command sent over the host’s existing connection. Action plugins describes each one. The modules whose plugin Volant does not have yet, such as uri and script, are not supported yet: Volant names them before the first connection.
assert, fail and pause have action plugins too, but none of them calls a module, so Volant runs them on the controller. setup runs as an ordinary module, so fact gathering works.