Migration Guide
Ansible-to-Parallax Migration Guide
This guide covers migrating existing Ansible playbooks and workflows to Parallax.
Overview
Parallax is a Go-based Ansible-compatible playbook executor. Key differences from Ansible:
- Single binary — no Python runtime or pip dependencies on the controller or remote hosts
- Compiled modules — modules compile to Go binaries, shipped and executed on targets
- gonja templating — Go-based Jinja2 implementation (compatible with most Jinja2 syntax)
- Lock-free concurrency — channel-based parallelism instead of Python multiprocessing
Most Ansible playbooks run without modification. This guide documents the areas that need attention.
Installation
# From source
git clone https://go.digitalxero.dev/parallax.git
cd parallax
task build
# Or via go install
go install go.digitalxero.dev/parallax/cmd/parallax@latest
The parallax binary is the only dependency. No Python, pip, or ansible packages are needed on the controller or targets.
Quick Start
# Run a playbook (same as ansible-playbook)
parallax play playbook.yml
# With inventory
parallax play -i inventory.ini playbook.yml
# Check mode (dry run)
parallax play --check playbook.yml
CLI Flag Mapping
Core Flags
Ansible (ansible-playbook) | Parallax (parallax play) | Notes |
|---|---|---|
-i INVENTORY | -i INVENTORY | Identical |
-C / --check | -C / --check | Identical |
-b / --become | -b / --become | Identical |
--become-user USER | --become-user USER | Identical |
--become-method METHOD | --become-method METHOD | sudo, su, doas, pfexec |
--tags TAGS | --tags TAGS | Comma-separated |
--skip-tags TAGS | --skip-tags TAGS | Comma-separated |
-e KEY=VALUE | -e KEY=VALUE | Supports key=value, @file, JSON |
-l PATTERN | -l PATTERN | Identical |
-D / --diff | -D / --diff | Identical |
--start-at-task NAME | --start-at-task NAME | Identical |
--forks N | --forks N | Default 5 |
SSH Flags
| Ansible | Parallax | Notes |
|---|---|---|
--private-key FILE | --private-key FILE | Identical |
--ask-pass | --ask-pass | Identical |
-u USER | --ssh-user USER | Different flag name |
--ssh-common-args | N/A | Use individual flags below |
| N/A | --ssh-port PORT | Default 22 |
| N/A | --ssh-timeout DURATION | Default 30s |
| N/A | --ssh-retries N | Default 3 |
| N/A | --ssh-retry-interval DURATION | Default 5s |
| N/A | --ssh-keepalive | Default true |
| N/A | --ssh-keepalive-interval DURATION | Default 30s |
| N/A | --strict-host-checking | Default true |
| N/A | --known-hosts FILE | Default ~/.ssh/known_hosts |
| N/A | --use-agent | Default true |
Bastion/Jump Host Flags
| Ansible | Parallax | Notes |
|---|---|---|
--ssh-common-args "-J user@bastion" | --bastion-host HOST | Dedicated flag |
| N/A | --bastion-port PORT | Default 22 |
| N/A | --bastion-user USER | Defaults to –ssh-user |
| N/A | --bastion-key FILE | Bastion-specific key |
Vault Flags
| Ansible | Parallax | Notes |
|---|---|---|
--vault-id label@source | --vault-id label@source | Identical |
--vault-password-file FILE | --vault-password-file FILE | Identical |
--ask-vault-pass | --ask-vault-pass | Identical |
Parallax-Only Flags
These flags have no Ansible equivalent:
| Flag | Default | Description |
|---|---|---|
--connection-pooling | false | Reuse SSH connections across tasks |
--pool-max-connections | 20 | Max pooled connections |
--precompile | false | Pre-build module binaries |
--precompile-background | false | Background precompilation |
--cache | true | Cache compiled module binaries |
--cache-dir | ~/.parallax/module-cache | Cache location |
--facts-parallel | true | Parallel facts gathering |
--facts-concurrency | 10 | Max concurrent fact gathers |
--facts-cache | true | Cache gathered facts |
--facts-incremental | true | Only re-gather changed facts |
--template-cache | true | Cache compiled templates |
--resource-management | false | Module resource limits |
--debug-enabled | false | Enhanced debug output |
--debug-profiling | false | Module execution profiling |
--debug-tracing | false | Execution tracing |
--callback | default | Callback plugin (default, minimal, json, timer, profile_tasks) |
--json | false | JSON output (shortcut for –callback json) |
--validate-features | false | Check Ansible feature compatibility |
Playbook Compatibility
What Works Unchanged
The following Ansible playbook features work identically:
- Play directives:
hosts,name,become,become_user,become_method,gather_facts,vars,vars_files,roles,tasks,handlers,pre_tasks,post_tasks,strategy,serial,max_fail_percentage,any_errors_fatal,force_handlers,order,collections,module_defaults,environment,tags - Task directives:
name,module args,when,register,loop,with_items,with_dict,with_list,with_fileglob,with_indexed_items,with_subelements,with_first_found,notify,tags,become,become_user,ignore_errors,no_log,changed_when,failed_when,until,retries,delay,delegate_to,run_once,check_mode,diff,connection,async,poll,throttle,block,rescue,always - Includes/imports:
import_tasks,include_tasks,import_role,include_vars - Handlers: Standard
notifywith handler names,listen,meta: flush_handlers - Roles: Standard role structure (tasks, handlers, defaults, vars, files, templates, meta)
- Variables: All precedence levels,
set_fact, extra vars (-e), group_vars, host_vars
Known Parser Quirks
notify:must be a YAML list, not a string:
# Works
- name: Restart nginx
service:
name: nginx
state: restarted
notify:
- restart nginx
# Does NOT work
- name: Restart nginx
service:
name: nginx
state: restarted
notify: restart nginx
shell:withcmd:map form can cause issues with multiple handlers. Use the string form:
# Preferred
- name: Run command
shell: "echo hello"
# May cause issues in some contexts
- name: Run command
shell:
cmd: "echo hello"
local_action:must use the free-form string spelling. The parser rewrites it intodelegate_to: localhostbefore anything else sees the task, so the two forms below reach the executor as the same task and capture stdout identically — the note that used to be here, claiminglocal_actioncaptured stdout unreliably, described a difference that does not exist:
# Both of these are the same task
- name: Run locally
command: echo hello
delegate_to: localhost
- name: Run locally
local_action: command echo hello
The mapping spelling is not yet supported — the rewrite leaves
module: as a literal key, which the task validator then counts as a
second action and rejects with “task cannot have multiple actions”:
# Rejected at parse time; use the free-form string above
- name: Run locally
local_action:
module: command
cmd: echo hello
meta:task syntax is fully supported. Valid actions:flush_handlers,clear_facts,clear_host_errors,noop,end_play,end_host,end_batch,refresh_inventory,reset_connection.
Inventory Compatibility
All Ansible inventory formats are supported:
INI Format
[webservers]
web1.example.com ansible_host=10.0.0.1
web2.example.com ansible_port=2222
[dbservers]
db[1:3].example.com
[webservers:vars]
http_port=80
[all:children]
webservers
dbservers
YAML Format
all:
hosts:
web1.example.com:
ansible_host: 10.0.0.1
children:
webservers:
hosts:
web2.example.com:
ansible_port: 2222
vars:
http_port: 80
Dynamic Inventory
Dynamic inventory scripts — executables that print JSON — follow Ansible’s contract:
<script> --listprints one JSON object. Every top-level key is a group, except the reserved_metakey.- A group is either
{"hosts": [...], "vars": {...}, "children": [...]}(all three optional) or the shorthand{"group": ["host1", "host2"]}. A group object with none of those three keys is treated as a barevarsmapping. _meta.hostvarssupplies per-host variables. Scripts that do not emit it are called once per host as<script> --host <name>, the legacy contract.childrenmay nest to any depth; a cycle is reported as an error.- The implicit
allandungroupedgroups behave as they do in Ansible.
A script that exits non-zero, prints invalid JSON, or describes a structurally impossible inventory fails the run with an error naming the script and quoting its stderr — it never resolves to an empty inventory.
Host Patterns
All Ansible host patterns are supported:
- Group names:
webservers - Intersection:
webservers:&dbservers - Exclusion:
webservers:!staging - Ranges:
web[0:2].example.com - Regex:
~.*\.web\..* - Comma-separated:
host1,host2,host3
group_vars / host_vars
Standard group_vars/ and host_vars/ directories are loaded from both the inventory directory and the playbook directory, matching Ansible’s behavior. YAML and JSON files are supported. Vault-encrypted files are automatically decrypted when vault passwords are provided.
Module Compatibility
ansible.builtin (71 modules)
All 71 ansible.builtin modules are implemented:
Core: command, shell, copy, file, template, debug, ping, stat, fetch, service, user, group
Package Management: package, apt, yum, dnf, dnf5, pip, rpm_key
System: systemd, systemd_service, sysvinit, gather_facts, setup, service_facts, mount_facts, reboot, hostname
File Operations: lineinfile, replace, blockinfile, assemble
Network & Utilities: get_url, uri, unarchive, cron, mount
Control Flow: set_fact, assert, fail, pause, wait_for, wait_for_connection, meta, add_host, group_by, include_vars, import_tasks, include_tasks, import_role
Advanced: script, raw, tempfile, validate_argument_spec, vars_prompt, subversion, git, slurp, package_facts
Specialized: apt_key, apt_repository, yum_repository, deb822_repository, debconf, dpkg_selections, expect, getent, iptables, known_hosts, async_status, find
ansible.posix (13 modules)
acl, at, authorized_key, firewalld, mount (posix), patch, seboolean, selinux, synchronize, sysctl, rhel_facts, rhel_rpm_ostree, rpm_ostree_pkg
community.general (50 modules)
ini_file, xml, archive, read_csv, json_query, htpasswd, ssh_config, listen_ports_facts, timezone, locale_gen, alternatives, pam_limits, make, modprobe, capabilities, supervisorctl, npm, yarn, gem, composer, cpanm, easy_install, snap, flatpak, homebrew, ufw, nmcli, sefcontext, syslogd, mail, slack, terraform, parted, lvg, lvol, filesystem, lvm_facts, xfs_quota, zfs, postgresql_user, postgresql_privs, mysql_user, redis, rabbitmq_user, jenkins_job, docker_compose, docker_volume, docker_network, fileglob, dconf
Module Naming
Modules can be referenced by short name or FQCN:
# All equivalent
- command: echo hello
- ansible.builtin.command: echo hello
- parallax.builtin.command: echo hello
Templating
Parallax uses gonja, a Go-based Jinja2 implementation. Most Jinja2 syntax works identically.
Supported Features
- Variable interpolation:
{{ variable }} - Control structures:
{% if %},{% for %},{% set %},{% block %},{% macro %} - Comments:
{# comment #} - Whitespace control:
{%- -%},{{- -}} - Template inheritance:
{% extends %},{% include %} - Expressions: math, comparisons, logical operators
Filters
71 custom Ansible-compatible filters plus all gonja built-in filters:
- String: quote, split, regex_search, regex_replace, regex_findall, regex_escape, comment, ternary
- Collection: count, flatten, dict2items, items2dict, combine, union, intersect, difference, symmetric_difference, zip, zip_longest, subelements
- Math: pow, log, root, human_readable, human_to_bytes
- Type: bool, type_debug, mandatory, to_json, to_nice_json, to_yaml, to_nice_yaml, from_json, from_yaml, to_uuid, to_datetime, strftime
- Path: basename, dirname, expanduser, expandvars, realpath, relpath, splitext, win_basename, win_dirname, win_splitdrive
- Crypto: checksum, hash, password_hash, b64encode, b64decode
- Network: ipaddr, ipv4, ipv6, ip4_hex, ipmath, ipwrap, ipsubnet, nthhost, network_in_network, network_in_usable, hwaddr, macaddr, reduce_on_network, slaac
- URL: urlsplit
- Data Query: json_query (JMESPath)
Jinja2 Tests
24 custom tests plus gonja built-ins: truthy, falsy, match, search, regex, contains, startswith, endswith, subset, superset, any, all, version, version_compare, changed, failed, success, succeeded, skipped, unreachable, started, finished
Lookups
16 lookup plugins: file, env, pipe, dict, indexed_items, sequence, password, lines, fileglob, varnames, url, csvfile, ini, first_found, template, config
Known Templating Differences
gonja is highly compatible with Jinja2 but minor edge cases may differ in:
- Complex nested macro definitions
- Unusual whitespace handling in edge cases
- Some Python-specific string methods
If you encounter a template that renders differently, simplify the expression or split it into multiple steps.
Vault
Parallax reads and writes the Ansible Vault format itself, not a lookalike. Vault files are interchangeable in both directions: ansible-vault opens files Parallax wrote, and Parallax opens files ansible-vault wrote, byte for byte.
Supported Formats
- Vault 1.1:
$ANSIBLE_VAULT;1.1;AES256 - Vault 1.2:
$ANSIBLE_VAULT;1.2;AES256;vault-id
Both use AES-256-CTR with PKCS#7 padding, keys derived with PBKDF2-HMAC-SHA256 at 10,000 iterations, and HMAC-SHA256 integrity that is verified before anything is decrypted. Those are Ansible’s fixed parameters, not configurable options.
Compatibility is enforced by tests rather than asserted: the suite decrypts vault files generated by the real ansible-vault CLI, and re-encrypts each one to confirm it reproduces the original file byte for byte.
Whole-file encryption, !vault-tagged values embedded in plaintext YAML, --vault-id label@source, and several vault passwords in use at once all behave as they do in Ansible.
Breaking change in v0.2.0
Parallax v0.1.x wrote an $ANSIBLE_VAULT header over a body that was not the Ansible format, so those files were never readable by Ansible — and they are no longer readable by Parallax either. There is no automatic migration, because the old format’s integrity check was not sound enough to keep supporting.
To convert an existing file, decrypt it with v0.1.9 and re-encrypt it with the current version:
parallax-0.1.9 vault decrypt secrets.yml --vault-password-file pass.txt
parallax vault encrypt secrets.yml --vault-password-file pass.txt
Parallax detects these files and says so explicitly, rather than reporting them as a wrong password.
CLI Commands
parallax vault encrypt file.yml
parallax vault decrypt file.yml
parallax vault view file.yml
parallax vault edit file.yml
parallax vault encrypt_string "secret" --name my_var
parallax vault rekey file.yml --new-vault-password-file new_pass.txt
Password Sources
--vault-password-file FILE— read password from file--vault-id label@source— labeled vault identity (file or script)--ask-vault-pass— interactive promptANSIBLE_VAULT_PASSWORDenvironment variable
Inline Vault Variables
Vault-encrypted values embedded in YAML files are automatically decrypted during variable loading. This works in playbook vars, group_vars, host_vars, and vars_files.
Connection Types
| Type | Flag | Description |
|---|---|---|
| SSH | connection: ssh (default) | Standard SSH with agent, key, or password auth |
| Local | connection: local | Execute on controller |
| WinRM | connection: winrm | Windows Remote Management (Basic, NTLM, Certificate, Kerberos) |
| Docker | connection: docker | Execute in Docker/Podman containers |
Features With Behavioral Notes
These features are fully implemented but may have minor behavioral differences:
| Feature | Ansible | Parallax | Notes |
|---|---|---|---|
| Strategy | strategy: free/linear | strategy: free/linear/host_pinned | All three strategies implemented |
| Throttle | throttle: N | throttle: N | Lowers the per-task limit below --forks; never raises it |
| Host order | order: sorted | order: sorted/reverse_sorted/shuffle/reverse_inventory/inventory | All 5 modes |
| Async | async: N / poll: N | async: N / poll: N | Target-side detachment; see Async tasks |
| Serial | serial: N/%/[list] | serial: N/%/[list] | Integer, percentage, and list forms |
| Check mode | --check | --check | Per-task override via check_mode: |
Async tasks
async: and poll: detach the task on the target, the way Ansible does.
The module binary is shipped as usual and then started with nohup, with its
standard descriptors redirected away from the connection, so the job is
reparented to init and keeps running after the controller exits — deliberately,
not as a side effect. It writes its result to ~/.ansible_async/<jid> in
Ansible’s job-file format, which is why async_status needs no special
handling.
- name: Start a long job and do not wait for it
shell: /usr/local/bin/reindex.sh
async: 3600
poll: 0
register: reindex
- name: Wait for it, checking every 10 seconds
async_status:
jid: "{{ reindex.ansible_job_id }}"
register: job
until: job.finished
retries: 360
delay: 10
- name: Remove the job file
async_status:
jid: "{{ reindex.ansible_job_id }}"
mode: cleanup
| Behaviour | Ansible | Parallax |
|---|---|---|
poll: 0 | returns ansible_job_id, started: 1, finished: 0 | same |
poll: N | polls until finished or async: elapses | same; the controller also removes the job file after a terminal read |
Absent poll: | defaults to 15 seconds | same |
| Job survives controller exit | yes | yes |
| Timeout result | task fails, rc 124 | same, with the same message text |
| Timeout kills the job’s children | yes, by process group | no — the module process exits, but a process it spawned (the sleep behind a shell:) is left running |
retries:/until: on the async task itself | re-runs the task | not applied — a retry would launch a second detached job and orphan the first. Use async_status with until:, as above |
async: on a controller-side module (debug, set_fact, assert) | rejected | rejected, with an explicit error |
| Windows targets | supported | not supported — the task fails rather than silently running synchronously |
| Job directory | ~/.ansible_async, or ANSIBLE_ASYNC_DIR | same |
| Abandoned job files | left forever | swept after 7 days on the next launch on that host |
The module payload for an async task lives in its own
<remote_tmp>/parallax-async-<jid>/ directory, outside the per-run directory
that is removed at the end of a play, and the job deletes it once it has written
its result. A job whose process is killed outright leaves that directory behind;
it is mode 0700 and owned by the login user.
unarchive
unarchive extracts archives natively, in process, and never invokes tar(1).
That closes a Zip Slip and an argument-injection RCE, and it costs the GNU tar
feature set:
| Behaviour | Ansible | Parallax |
|---|---|---|
extra_opts | any tar flag | only --strip-components=N; anything else is a hard error |
| Symlink / hardlink entries | extracted | rejected — use command: tar if you need them |
| Device, fifo and socket entries | extracted | rejected |
| Sparse files, xattrs, ACLs, SELinux contexts | preserved by GNU tar | not preserved |
--transform, --wildcards, -P | available via extra_opts | unavailable |
remote_src: false | copies the archive from the controller | not implemented — fails with an explicit error |
| Format detection | filename extension | file contents (magic bytes) |
.tar.xz | tar -xJf | piped through xz -dc, so xz must be installed on the target |
Archives are also subject to entry-count and total-size limits, so a decompression bomb fails the task instead of filling the disk.
If you need any of the rejected behaviour, call tar directly:
- name: Extract with GNU tar features
command: tar -xf /tmp/archive.tar --transform 's|^old|new|' -C /opt
assemble
assemble reads src on the target, which is what its default says:
remote_src defaults to true for this module, and only for this module.
| Behaviour | Ansible | Parallax |
|---|---|---|
remote_src: true (the default) | assembles a directory on the target | same |
remote_src: false | assembles a directory on the controller and copies the result | not implemented — fails with an explicit error |
Build the file on the target instead, either by copying the fragments there first or by writing it directly:
- name: Ship the fragments, then assemble them
copy:
src: fragments/
dest: /tmp/fragments/
- assemble:
src: /tmp/fragments
dest: /etc/app.conf
script
script runs a script that lives on the controller. A relative path is
resolved against the directory the task was written in and, inside a role,
against that role’s files/ directory — the same search template: and copy:
use for src:. The file is transferred to the target for the duration of the
task and removed afterwards.
| Behaviour | Ansible | Parallax |
|---|---|---|
| Path resolution | task’s directory, then the role’s files/ | same |
| A path containing spaces | quote it | quote it |
| Arguments after the path | passed through a shell on the target | split on whitespace and passed as separate arguments — script: run.sh "a b" is two arguments, not one |
creates:, removes:, chdir: | evaluated on the target | same |
decrypt: | vault-encrypted scripts are decrypted | not implemented — the script is transferred as it is on disk |
If you need shell processing of the arguments, invoke the interpreter yourself
with executable: or use command:/shell: against a script you have already
copied to the target.
Parallax-Only Features
Features available in Parallax with no Ansible equivalent:
Module Caching
Compiled module binaries are cached to avoid recompilation:
parallax play --cache playbook.yml # enabled by default
parallax play --cache-info stats # show cache statistics
parallax play --cache-clear # clear all cached modules
Connection Pooling
Reuse SSH connections across tasks for improved performance:
parallax play --connection-pooling playbook.yml
parallax play --connection-pooling --pool-max-connections 50 playbook.yml
Parallel Facts Gathering
Gather facts from multiple hosts concurrently:
parallax play --facts-parallel --facts-concurrency 20 playbook.yml
parallax play --facts-incremental playbook.yml # only re-gather changed facts
Module Precompilation
Pre-build module binaries before execution:
parallax play --precompile playbook.yml
parallax play --precompile-background playbook.yml # background precompilation
Debug and Profiling
parallax play --debug-enabled --debug-level verbose playbook.yml
parallax play --debug-profiling playbook.yml # module execution timing
parallax play --debug-report playbook.yml # comprehensive report at end
Resource Management
parallax play --resource-management --max-memory 512 --max-cpu 80 playbook.yml
Template Caching
parallax play --template-cache --template-cache-size 2000 playbook.yml
Callback Plugins
parallax play --callback minimal playbook.yml
parallax play --callback json playbook.yml
parallax play --callback timer playbook.yml
parallax play --callback profile_tasks playbook.yml
Removed Flags
These flags were removed because the features behind them did not work. Each now
fails with unknown flag, which is a clearer answer than a flag that parses and
does nothing.
| Removed | Why | Instead |
|---|---|---|
--facts-compression, --facts-compression-algorithm, --facts-compression-level, --facts-auto-compression, --facts-compression-threshold | The facts cache compressed each fact set only to measure a ratio, then stored the uncompressed map and discarded the compressed bytes. Compression only pays at a serialization boundary, and an in-memory cache is not one. | Nothing needed. The on-disk facts store (--facts-record) compresses its snapshots, where it is real. |
--facts-incremental, --facts-incremental-threshold | “Incremental” gathering connected and ran a full gather every time, then diffed the result for reporting; both branches produced identical facts and no network was saved. gather_facts returns everything or nothing, so this needs a two-phase protocol rather than a patch. | --facts-subset to gather less, which is the honest knob for the same goal. |
--facts-force-full survives: it genuinely bypasses the cache.
Concurrency Limits
Three limits compose, and are never multiplied — the effective limit is the smallest that applies:
| Limit | Scope | Set by |
|---|---|---|
serial: | Which hosts are in a batch | Play directive |
--forks | How many of that batch run a task at once | CLI flag |
throttle: | How many run this task at once | Task directive |
Note that --forks genuinely bounds concurrency from this release onward. Under
the default linear strategy it previously had no effect: tasks ran on hosts one
after another regardless. Playbooks that relied on that accidental serialisation
— for example two hosts writing to one NFS path without any locking — will now
see them overlap. --forks 1 restores the old behaviour.
Above roughly ten concurrent hosts, sshd’s default MaxStartups 10:30:100
begins refusing connections, which appears as hosts randomly going unreachable.
Parallax warns about this; --connection-pooling is the fix.
Migration Checklist
- Install Parallax — build from source or use
go install - Test existing playbooks — run with
--checkfirst to verify parsing - Review notify syntax — ensure
notify:uses YAML list format - Review local_action usage — the free-form string form works and captures stdout like any other task; rewrite the
local_action: {module: ...}mapping form asdelegate_to: localhost - Review shell tasks — prefer
shell: "string"form overshell: {cmd: "string"} - Test with real execution — run against a staging environment
- Review output — compare task results with Ansible output
- Update CI/CD — replace
ansible-playbookwithparallax playin pipelines - Enable performance features — try
--connection-pooling,--precompile,--facts-parallel - Validate compatibility — use
--validate-featuresto check for unsupported patterns