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 INVENTORYIdentical
-C / --check-C / --checkIdentical
-b / --become-b / --becomeIdentical
--become-user USER--become-user USERIdentical
--become-method METHOD--become-method METHODsudo, su, doas, pfexec
--tags TAGS--tags TAGSComma-separated
--skip-tags TAGS--skip-tags TAGSComma-separated
-e KEY=VALUE-e KEY=VALUESupports key=value, @file, JSON
-l PATTERN-l PATTERNIdentical
-D / --diff-D / --diffIdentical
--start-at-task NAME--start-at-task NAMEIdentical
--forks N--forks NDefault 5

SSH Flags

AnsibleParallaxNotes
--private-key FILE--private-key FILEIdentical
--ask-pass--ask-passIdentical
-u USER--ssh-user USERDifferent flag name
--ssh-common-argsN/AUse individual flags below
N/A--ssh-port PORTDefault 22
N/A--ssh-timeout DURATIONDefault 30s
N/A--ssh-retries NDefault 3
N/A--ssh-retry-interval DURATIONDefault 5s
N/A--ssh-keepaliveDefault true
N/A--ssh-keepalive-interval DURATIONDefault 30s
N/A--strict-host-checkingDefault true
N/A--known-hosts FILEDefault ~/.ssh/known_hosts
N/A--use-agentDefault true

Bastion/Jump Host Flags

AnsibleParallaxNotes
--ssh-common-args "-J user@bastion"--bastion-host HOSTDedicated flag
N/A--bastion-port PORTDefault 22
N/A--bastion-user USERDefaults to –ssh-user
N/A--bastion-key FILEBastion-specific key

Vault Flags

AnsibleParallaxNotes
--vault-id label@source--vault-id label@sourceIdentical
--vault-password-file FILE--vault-password-file FILEIdentical
--ask-vault-pass--ask-vault-passIdentical

Parallax-Only Flags

These flags have no Ansible equivalent:

FlagDefaultDescription
--connection-poolingfalseReuse SSH connections across tasks
--pool-max-connections20Max pooled connections
--precompilefalsePre-build module binaries
--precompile-backgroundfalseBackground precompilation
--cachetrueCache compiled module binaries
--cache-dir~/.parallax/module-cacheCache location
--facts-paralleltrueParallel facts gathering
--facts-concurrency10Max concurrent fact gathers
--facts-cachetrueCache gathered facts
--facts-incrementaltrueOnly re-gather changed facts
--template-cachetrueCache compiled templates
--resource-managementfalseModule resource limits
--debug-enabledfalseEnhanced debug output
--debug-profilingfalseModule execution profiling
--debug-tracingfalseExecution tracing
--callbackdefaultCallback plugin (default, minimal, json, timer, profile_tasks)
--jsonfalseJSON output (shortcut for –callback json)
--validate-featuresfalseCheck 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 notify with 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

  1. 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
  1. shell: with cmd: 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"
  1. local_action: must use the free-form string spelling. The parser rewrites it into delegate_to: localhost before 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, claiming local_action captured 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
  1. 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> --list prints one JSON object. Every top-level key is a group, except the reserved _meta key.
  • 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 bare vars mapping.
  • _meta.hostvars supplies per-host variables. Scripts that do not emit it are called once per host as <script> --host <name>, the legacy contract.
  • children may nest to any depth; a cycle is reported as an error.
  • The implicit all and ungrouped groups 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 prompt
  • ANSIBLE_VAULT_PASSWORD environment 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

TypeFlagDescription
SSHconnection: ssh (default)Standard SSH with agent, key, or password auth
Localconnection: localExecute on controller
WinRMconnection: winrmWindows Remote Management (Basic, NTLM, Certificate, Kerberos)
Dockerconnection: dockerExecute in Docker/Podman containers

Features With Behavioral Notes

These features are fully implemented but may have minor behavioral differences:

FeatureAnsibleParallaxNotes
Strategystrategy: free/linearstrategy: free/linear/host_pinnedAll three strategies implemented
Throttlethrottle: Nthrottle: NLowers the per-task limit below --forks; never raises it
Host orderorder: sortedorder: sorted/reverse_sorted/shuffle/reverse_inventory/inventoryAll 5 modes
Asyncasync: N / poll: Nasync: N / poll: NTarget-side detachment; see Async tasks
Serialserial: N/%/[list]serial: N/%/[list]Integer, percentage, and list forms
Check mode--check--checkPer-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
BehaviourAnsibleParallax
poll: 0returns ansible_job_id, started: 1, finished: 0same
poll: Npolls until finished or async: elapsessame; the controller also removes the job file after a terminal read
Absent poll:defaults to 15 secondssame
Job survives controller exityesyes
Timeout resulttask fails, rc 124same, with the same message text
Timeout kills the job’s childrenyes, by process groupno — the module process exits, but a process it spawned (the sleep behind a shell:) is left running
retries:/until: on the async task itselfre-runs the tasknot 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)rejectedrejected, with an explicit error
Windows targetssupportednot supported — the task fails rather than silently running synchronously
Job directory~/.ansible_async, or ANSIBLE_ASYNC_DIRsame
Abandoned job filesleft foreverswept 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:

BehaviourAnsibleParallax
extra_optsany tar flagonly --strip-components=N; anything else is a hard error
Symlink / hardlink entriesextractedrejected — use command: tar if you need them
Device, fifo and socket entriesextractedrejected
Sparse files, xattrs, ACLs, SELinux contextspreserved by GNU tarnot preserved
--transform, --wildcards, -Pavailable via extra_optsunavailable
remote_src: falsecopies the archive from the controllernot implemented — fails with an explicit error
Format detectionfilename extensionfile contents (magic bytes)
.tar.xztar -xJfpiped 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.

BehaviourAnsibleParallax
remote_src: true (the default)assembles a directory on the targetsame
remote_src: falseassembles a directory on the controller and copies the resultnot 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.

BehaviourAnsibleParallax
Path resolutiontask’s directory, then the role’s files/same
A path containing spacesquote itquote it
Arguments after the pathpassed through a shell on the targetsplit on whitespace and passed as separate arguments — script: run.sh "a b" is two arguments, not one
creates:, removes:, chdir:evaluated on the targetsame
decrypt:vault-encrypted scripts are decryptednot 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.

RemovedWhyInstead
--facts-compression, --facts-compression-algorithm, --facts-compression-level, --facts-auto-compression, --facts-compression-thresholdThe 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:

LimitScopeSet by
serial:Which hosts are in a batchPlay directive
--forksHow many of that batch run a task at onceCLI flag
throttle:How many run this task at onceTask 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

  1. Install Parallax — build from source or use go install
  2. Test existing playbooks — run with --check first to verify parsing
  3. Review notify syntax — ensure notify: uses YAML list format
  4. Review local_action usage — the free-form string form works and captures stdout like any other task; rewrite the local_action: {module: ...} mapping form as delegate_to: localhost
  5. Review shell tasks — prefer shell: "string" form over shell: {cmd: "string"}
  6. Test with real execution — run against a staging environment
  7. Review output — compare task results with Ansible output
  8. Update CI/CD — replace ansible-playbook with parallax play in pipelines
  9. Enable performance features — try --connection-pooling, --precompile, --facts-parallel
  10. Validate compatibility — use --validate-features to check for unsupported patterns