Fork me on GitHub

Ansible's engine,
without Python.

Ansible's engine is a Python program that needs a matching interpreter on the control node. go-ansible reimplements it as importable Go packages rather than wrapping the Python binary — the same wire formats, the same merge orders, the same rendering rules, compiled into one static executable. Pure Go, CGO_ENABLED=0. BSD-3-Clause.

576 modules9 CLI binariesCGO=0 one static binaryLinux · macOS · Windows control node
ansible-playbook — zsh
$ ansible-playbook -i hosts site.yml
TASK [Gathering Facts]
ok: [localhost]
TASK [the fact gathering was real]
ok: [localhost] => {"msg": "Darwin / arm64"}
TASK [write a file]
changed: [localhost]
TASK [loop, with a registered result]
changed: [localhost] => (item=alpha)
changed: [localhost] => (item=beta)

# banners are padded with stars to 80 columns, as real's are; trimmed here for width

# run it again — the copy is idempotent, the command is not
TASK [write a file]
ok: [localhost]
8 repositories — one engine in importable pieces, not one binary that hides them
What is honoured, not what parses

A keyword that parses and does nothing is the worst failure.

A missing feature stops a playbook. A silently ignored one lets it succeed while not doing what it says — so this table is read from playbook/engine.go and playbook/playbook.go directly, not from the shipped-package list. Several fields exist on the Play/Task structs and parse cleanly from YAML while the engine never reads them; those are the rows that say so.

Playbook keywordStatusWhat that means here
when / loop / registerhonouredPer-host conditionals and loop expansion, with loop_control: loop_var, index_var, label, extended and break_when
block / rescue / alwayshonouredGenuine per-host recovery, not merely parsed
notify / handlershonouredDeduplicated, run once per host after the play
failed_when / changed_whenhonouredBoth replace the module’s own verdict, in either direction, and each records its boolean in failed_when_result / changed_when_result
until / retries / delayhonouredA real retry loop, with attempts in the result and the FAILED - RETRYING lines — deliberately reproducing a genuine off-by-one in real ansible-core’s own retry loop (registered attempts undercounts by one on exhaustion) rather than “fixing” it
serial / order / throttlehonouredRolling batches, each running every task and handler to completion before the next starts; ansible_play_batch, ansible_play_hosts and ansible_play_hosts_all each mean what they mean in Ansible
any_errors_fatal / max_fail_percentagehonouredEither brake ends the run, not just its play, and a failed host leaves the run for every play after it
timeouthonouredGives up on the task’s action and discards what it was doing, rather than reporting it half-done
becomehonouredReal sudo, end to end, in CI
pre_tasks / post_taskshonouredConcatenated into the task list in pre → tasks → post order and executed normally
roleshonouredLoaded from roles/<name>/{tasks,handlers,defaults,vars}/main.yml, spliced into a synthetic block task; role handlers fold into the play’s handler list. Task-level vars: reach the role; vars: inside the mapping is refused, exactly as Ansible refuses it
include_role / import_rolehonouredSame synthetic-block path as roles:, resolved statically at parse time (not re-evaluated per host)
include_tasks / import_taskshonouredReferenced file’s task list spliced in as a synthetic block, resolved statically at parse time
import_playbookhonouredResolved statically at parse time like import_tasks: splices in every play from the referenced file, whose own nested imports/roles resolve relative to its own directory
Nested role variable scopinghonouredComposes to any depth: a role included from inside another role’s own tasks merges its own defaults/vars on top of the enclosing role’s instead of replacing them outright, restoring exactly the prior state once its block ends — verified against a real ansible-playbook run
vars_fileshonouredLoaded and merged into Play.Vars at parse time, lower precedence than an explicit vars: key
vars_prompthonouredPrompts interactively for each named var; private defaults to true (masked input); a var already set via -e/extra-vars skips its prompt entirely, matching real Ansible’s preprocess_vars
delegate_tohonouredTask runs against the delegate target’s connection (templated, cached per batch); falls back to a batch host’s own connection when the target is also a play host
tags / --tags, --skip-tagshonouredTag inheritance computed once at parse time; Engine.RunTags/SkipTags filter tasks (never handlers), and both CLI flags are wired on ansible-playbook
meta: flush_handlers / clear_factshonouredBoth run for real; any other meta: action errors instead of silently no-op’ing
add_hosthonouredAdds a host (with vars and groups) to the live inventory mid-run
group_byhonouredAdds the current host to an inventory group mid-run
include_varshonouredLoads a YAML file’s keys into the host’s vars, bare-name accessible like set_fact
run_oncehonouredRestricts actual module execution to one host in the batch and broadcasts its register: value to the rest — not coordinated across strategy: free’s independent per-host lanes
Engine.Forks / --forkshonouredReal concurrency cap on per-host execution, default 5 (ANSIBLE_FORKS), matching real Ansible’s own default
async / pollhonouredReally backgrounds a job on the target’s filesystem (~/.ansible_async/<jid>) for command/shell — the only two modules whose whole job reduces to one remote invocation — surviving the connection closing. A disclosed limit: an overrunning job is not actively killed on timeout the way real Ansible’s async_wrapper.py does, since POSIX process-group kill needs setsid, absent on macOS
strategyhonouredlinear (default) and free — with free, each host runs its whole task list and its own notified handlers independently, with no per-task barrier across hosts
inventory_hostname / playbook_dirhonouredSet as magic variables after host_vars, so a same-named host_var cannot shadow them
register: on a setup:/set_fact: taskhonouredNests the result under ansible_facts, matching real Ansible’s registered-result shape exactly — verified against a real ansible-playbook run
Fully-qualified collection nameshonouredansible.builtin.copy, community.general.ufw and the other known collection prefixes resolve exactly like their bare form, for modules and the 9 playbook-engine directives alike
Dynamic inventory scriptshonouredAn executable inventory path runs as a script against the real --list/--host <name> JSON protocol, including the _meta.hostvars optimization and both the shorthand-array and full-object group forms
ansible.cfg filehonouredThe [defaults] section is read for the settings this port supports, at the precedence real Ansible uses: host var > environment variable > ansible.cfg > compiled default; discovery order is $ANSIBLE_CONFIG > ./ansible.cfg > ~/.ansible.cfg > /etc/ansible/ansible.cfg, first found wins outright
become_exe / become_flagshonouredThe escalation program and its flags, on the play or the task. The precedence is real Ansible’s and its top rung is counter-intuitive: the host var ansible_become_flags beats the become_flags keyword, then the environment, then ansible.cfg’s [privilege_escalation]. Each rung also has a per-method spelling — ansible_sudo_flags, ansible_su_flags — which follows whichever method is in use
ansible_connection: winrmhonouredReaches a Windows host over WS-Management, with the real plugin’s own variables — ansible_winrm_port (5986), ansible_winrm_scheme (inferred from the port), ansible_winrm_path, ansible_winrm_transport, ansible_winrm_server_cert_validation and the rest. kerberos and credssp are refused by name rather than downgraded. The connection is there; the ansible.windows module family is not, so what runs over it today is whatever does not need a POSIX shell
Module result keyshonouredCompared per module against real by key set, not only by value — which is what a value-only comparison missed on five of them. template and unarchive each returned three keys where real returns thirteen, with the destination path in msg; slurp omitted source and put the file path in msg; known_hosts returned an English sentence where real echoes its own parameters; stat returned an empty msg where real returns none. An absent msg and an empty one are different things, and each module follows real’s own choice. Every failed result carries exception, and no printed line shows it — real’s own callback pops it before printing
Jinja testshonoured30 of them: the regex family (match, search, regex, with ignorecase/multiline/match_type), truthiness, set theory, any/all, and the result-status names. match anchors at the start only, like Python’s re.match. Until v0.31.0 the regex three were absent, and since select/reject read an unknown test as false, reject(‘match’, …) kept every element and select kept none — without an error
Lookup pluginshonouredlookup()/query()/q() as real Jinja globals, with wantlist and errors handled by the shared dispatcher — 17 built in: env, pipe, file, fileglob, first_found, lines, random_choice, varnames, and the nine item/list lookups (items, list, dict, nested, together, subelements, flattened, indexed_items, sequence)
Callback pluginshonouredA real Callback interface — OnPlayStart, OnTaskResult, OnTaskRetry, OnStats — with the default stdout callback implemented against it rather than hardcoded into the CLI, so a second callback can be added without touching the engine
connection / remote_user / port at task levelhonouredThey were refused while a connection was built once per play and could not change. A task’s own variables decide which connection it gets now, so these three are the keyword spelling of ansible_connection/ansible_user/ansible_port, on the ladder measured against real: host var > task keyword > play keyword — the same one become: uses
a task that CHANGES the connectionhonouredset_fact: {ansible_connection: ssh} used to leave the variable visible and change nothing: the next task still ran locally. Real switches and reports UNREACHABLE, and so does this now — while an ordinary play still connects once, and a task that needs no connection still dials none, both pinned by tests
collectionshonouredAccepted and without effect, which is not the same as ignoring a keyword that changes what runs: the registry is one flat namespace by construction (every known collection prefix is stripped) and no two modules share a bare name — 576 registrations, 576 distinct, pinned by a test. A search path has nothing to search. It was already accepted on a play while refused on a task, so the rule was only half applied
debugger: neverhonouredThe one value that asks for nothing — it turns off a debugger this port does not have. Every other value is refused by name, because they are not no-ops without a terminal: measured with stdin closed, debugger: on_failed on a failing task still prints its prompt and ends the run
delegate_factsrefusedNamed, with what it would need: facts would have to be written to another host’s variables and read back, and hostvars is built once from the inventory at play start — a snapshot, not a live store. Refused rather than half-built, since the half that would work (a delegate inside the play) looks identical to the half that would not
strategy: host_pinnedhonouredReal implements it as free plus one rule — a host does not start until a fork slot is free and holds it until the play is done. The difference shows at -f 1, where free collapses to linear and this does not: one[h1] two[h1] three[h1] one[h2] two[h2] three[h2], matching real’s transcript
strategy: debug, a strategy pluginrefusedRejected with an explicit parse error, never silently run as linear. debug is refused for its own reason: it is real’s linear plus an interactive debugger, so running it as linear would drop the only thing it asks for

Still out of scope: the namespace/collection metadata system, Python-style inventory plugins (executable inventory scripts are supported, see the row above), and real ansible-galaxy collection install — this port’s ansible-galaxy only clones a role from a git URL. This table is read from the code on 2026-09-30, and engine work runs in parallel with it, so treat it as a snapshot rather than a promise.

How far the module library reaches

71 of 71 of ansible.builtin, module and directive alike.

modules/registry.go registers 576 module names, each running against a live go-remoteexec/transport connection. All 62 of ansible.builtin’s real modules are there, and its 9 non-module playbook-engine directives — add_host, group_by, import_playbook, import_role, import_tasks, include_role, include_tasks, include_vars, meta — are wired into the engine itself rather than registered as modules, which is why they appear in the matrix above and not in the registry count. Beyond builtin: all 14 of ansible.posix, and fourteen curated batches covering 500 of community.general’s 577. Every count here was measured on 2026-10-18 by intersecting what the binary lists with ansible-doc -l <collection> -t module from real ansible-core 2.21.4, not by adding up batch sizes: a sum of batches says how many were written, never which names are there.

What is not covered is worth saying as plainly: 77 community.general modules remain — an increasingly SaaS-only remainder — along with every cloud-provider collection (amazon.aws, azure, google.cloud and the rest), which needs real API client SDKs rather than shell composition over a connection, and is a different kind of work that has not been started. Where a module could not be done honestly it fails loud instead: synchronize is an always-failing stub, because real synchronize runs rsync from the controller straight at the target’s own SSH endpoint, which this port’s connection abstraction cannot expose from inside a module. Each repository card above opens to the full scope rationale for its own package.

The collection-by-collection rationale, in full

modules/registry.go registers 576 module names in total, each running against a live go-remoteexec/transport connection. All 62 of ansible.builtin's real modules are there, plus its 9 non-module playbook-engine directives (add_host, group_by, import_playbook, import_role, import_tasks, include_role, include_tasks, include_vars, meta) wired directly into the playbook engine (see the feature matrix above) — 71 of 71 ansible.builtin items, module and directive alike, for real. Beyond builtin: all 14 of ansible.posix (synchronize is an honest, always-failing stub — real synchronize runs rsync from the controller directly against the target's own SSH endpoint, which this port's connection abstraction can't expose from inside a module), and fourteen curated batches of 500 of community.general's 577 modules — package managers (including bower/easy_install), language/dev tooling, filesystem/storage (including file_remove), networking, system/service management, SELinux, read-only facts, Pacemaker cluster management (including stonith/fencing), LDAP, FreeIPA, Redis, Consul, Kerberos, desktop/system config, LXD/LXC containers, HashiCorp Nomad, database admin, RHEL subscription management, AIX, Elastic Stack plugins, InfluxDB, Icinga2, Kopia backup, version control (bzr/hg), web/app servers, ISO tools, provisioning (including Stacki), Django extensions, IPMI, Univention UDM, XenServer, GitHub, GitLab, Keycloak, Scaleway, Huawei Cloud, Jenkins, Equinix Metal, Linode, OpenNebula, Rundeck, Alibaba Cloud, IBM SoftLayer, 1Password, Pulp, Twilio, Aerospike, Alerta, Heroku, Mattermost, New Relic, DNSimple, ipinfo.io, Cloudflare, OVHcloud, Dell EMC VNX, IBM Spectrum Accelerate, HPE 3PAR, the Redfish hardware-vendor family (Dell iDRAC via racadm, HPE iLO via ilorest, Lenovo XCC via OneCli), hponcfg (HP's own local iLO config binary, not a substitution), Jira (via Atlassian's acli, Jira Cloud only), Honeybadger (via hb), Rollbar (via rollbar-cli), Memset (via ma-shell), ansible_galaxy_install (also not a substitution — shells out to ansible-galaxy directly), Airbrake (via airbrake-cli), UptimeRobot (via the newly-launched official @uptimerobot/cli), SendGrid (via twilio-cli's built-in email:send), Circonus (via circli), Kea DHCP (via kea-shell), Broadcom Spectrum (via vnmsh — local-only, a genuine architecture mismatch against the real modules' remote OneClick REST server, documented), FusionLayer Infinity IPAM (via curl — not Infinidat's storage arrays as the platform name might suggest, and no official CLI exists for it), IP2Location (via ip2locationio — keyless, no secret involved at all), and a handful of misc modules, deliberately excluding SaaS-API wrappers with no comparable official CLI (Slack, PagerDuty, Datadog, and similar) — except GitHub, GitLab, Keycloak, and Jenkins, whose official gh/glab/kcadm.sh/ jenkins-cli.jar CLIs this port shells out to directly, except Scaleway, Huawei Cloud, Equinix Metal, Linode, Alibaba Cloud, and IBM SoftLayer — cloud-VPS providers this page once named as an exclusion example, reconsidered because each also ships a genuine official CLI (scw/KooCLI/metal/linode-cli/aliyun/slcli), and except the Redfish hardware-vendor family (also once named as an exclusion example), reconsidered because HPE's ilorest and Lenovo's OneCli are genuine, strong-fit official Redfish CLIs and Dell's racadm is a real official (if older, parallel) interface — extending the same CLI-substitution approach already used for Consul/Redis/Terraform/Icinga2/Kopia across four separate, explicit, deliberate scope decisions (Western Digital's Redfish gear was checked and found to have no official CLI, so it remains excluded). The same local ilorest also now covers hpilo_boot/hpilo_info — older, RIBCL-era HPE iLO modules that predate Redfish, but whose real one-time-boot/power/basic-info behavior the same physical iLO already exposes over Redfish too. The vendor-neutral redfish_command/redfish_config/redfish_info were reconsidered too, separately from the vendor family above: DMTF's own redfishtool (the Redfish standard's own authoring body's reference CLI, not a vendor's) covers real, networked Systems/Chassis power, boot override, indicator LED, sessions, account management, Manager power/logs/network-protocol/host-interface config, and inventory/config/health-report info across all 7 real redfish_info categories (Systems, Chassis, Accounts, Sessions, Update, Manager, Service) — Manager and Chassis are now complete, every real command wired, Systems has 13 of 14, Update has 3 of 4 — a genuine, real credential-bearing substitution (unlike the vendor CLIs' local-only, ignored baseuri/username/password), shipped incrementally with each real gap disclosed rather than approximated (virtual media, storage/RAID config, and two commands genuinely unreachable through further CLI-substitution work: GetBiosRegistries (needs vendor-aware HPE iLO4/iLO5 workarounds this port has no hardware to verify against) and GetUpdateStatus — blocked on redfishtool's own raw subcommand exposing only a JSON body, never the HTTP status code real Ansible's own status logic needs). Two platforms were investigated and found to have no usable official CLI for what their modules need — Pritunl (org/user management, four modules) and two of OVHcloud's three modules (ovh_monthly_billing has full coverage) — all fail loud rather than fake parity, confirmed gaps rather than assumed ones. rollbar-cli and ma-shell were both confirmed, by reading their real source, to have no environment-variable alternative to their access-token/API-key argv flag — a documented, unavoidable exception to this project's own no-secrets-in-argv rule. ~87 more community.general modules remain — an increasingly SaaS-only remainder — plus every cloud-provider collection (amazon.aws/azure/google.cloud and similar — a fundamentally different kind of work, not yet started), the namespace/collection metadata system, real ansible-galaxy collection install (this port's ansible-galaxy only clones a role from a git URL) remain unported. Lookup and callback plugins, named here as unported when this page was last written, both shipped since: template registers 17 lookups and playbook carries a real Callback interface.

What it stands on

The transport lives outside this org, on purpose.

SSH, local execution and become/sudo are in go-remoteexec/transport, a project-neutral package shared with go-puppet-bolt/bolt. modules, playbook and cli depend on it directly instead of each shipping a transport of its own — the connection layer is not an Ansible-shaped problem, and a package two ports share is held to a harder interface than one with a single caller.

Why go-ansible

Compatibility with the real thing, measured.

The bar is not a plausible reimplementation: it is agreeing with ansible-core on the same input. A differential corpus runs the same playbook through both and compares the transcripts byte for byte — and the divergences that remain are named rather than rounded off.

🔐

The vault format, byte for byte

AES-256-CTR with a PBKDF2-HMAC-SHA256 key and an encrypt-then-MAC tag, hex-wrapped at 80 columns — ansible.parsing.vault.VaultAES256's wire format, not a reinterpretation of it. Each side decrypts the other's files; a bad HMAC fails closed, in constant time, before the ciphertext is touched.

🧩

The rule naive ports miss

A value that is a single {{ expr }} and nothing else renders to the expression's native type — a list, a dict, an int — not a string. And a null is not an undefined: default() does not fire on one, which is what Jinja2 does and what a port usually gets wrong.

📐

Precedence as a ladder

Role defaults up through play, role, block and task vars, registered vars, role params and --extra-vars, merged low to high. Which(key) answers the question a precedence bug actually raises: why did this value win?

🧪

Judged against the real binary

Every claim here was checked by running ansible-core beside this port and diffing the output — six of six transcripts byte-identical on the last sweep, with and without -v. The sweep is also what finds things: it turned up that every banner was missing the stars real pads it to 80 columns with, because the expectations in our own tests had been hand-written rather than captured. Where the two disagree and the cause is upstream — three cases inside the template engine, one needing a second account — the case stays in the corpus as a named witness rather than being deleted.

📦

576 modules, one contract

A function taking a target connection and rendered args, returning changed/failed/msg. All 62 real ansible.builtin modules, all 14 of ansible.posix, and fourteen curated community.general batches. Unlike Ansible, nothing is copied to the target: each module runs on the control node and reaches the host through Exec/Put/Fetch.

🚫

Honest stubs over quiet approximations

synchronize is an always-failing stub with its reason written down: the real one runs rsync from the controller straight at the target's own SSH endpoint, which this connection abstraction cannot expose from inside a module. A silent approximation would have been easier, and wrong.

Security

A value that reaches a command string reaches a shell.

That is the whole threat model, and it has two halves: a playbook value changing a command's structure, and a credential landing where ps can read it. The module library was audited against both. It found one of each — written down here rather than quietly patched, because a port that only publishes its clean audits has not published an audit.

⛔

A target’s data is not a template

A module’s result is data the managed host chose. This port re-rendered it as Jinja — and lookup('pipe', …) runs on the control node, so a host returning {{ lookup('pipe','touch FILE') }} created that file where the vault password and the fleet’s SSH keys live. Real refuses by wrapping such data in AnsibleUnsafeText. Three routes, and the proof of concept kept firing until all three closed — including one call site added by a feature written hours earlier. A brake is only as good as its least-covered caller.

⛔

Quoting is the control, not care

Every interpolated value goes through shellQuote — 1 715 call sites across 608 files. Mechanical on purpose: being careful does not scale to that, and a missed site looks exactly like a deliberate one until somebody looks. What the audit found: async_status's jid, straight from a playbook argument, was interpolated unquoted — into an rm -rf. A jid of x; touch /tmp/pwned ; ran that touch.

🔭

The ps boundary, measured

An environment assignment written as a command prefix is not always visible, and which shape it is decides it. sh -c "VAR=x sleep 4" leaves nothing — the shell execs into the command. sh -c "VAR=x sleep 4; true" leaves the whole value, because a second statement means the shell must stay alive holding it. An earlier probe said the opposite; its canary was a fixed string the shell that had written the probe still carried.

🔑

Credentials go in the environment

Through transport's ExecWithEnv — what real Ansible does with run_command's environ_update. An optional interface, not a Connection method, because SSH genuinely cannot set arbitrary variables: sshd's AcceptEnv decides. What the audit found: of 24 modules building a CREDENTIAL= prefix, 23 were the safe lone-command shape and keyring was not — two statements, so the shell held both passwords for the command's whole life.

🧯

A fix that breaks parity is not a fix

The first jid validator returned an error for a malformed id. Real Ansible accepts jid: nope and reports could not find job, and an existing test caught the difference. A malformed jid is now mapped to exactly that answer: real's behaviour, and a value that never reaches a shell. The next person's workaround for a security fix that breaks their playbook is to turn it off.

🔍

A test that drove one state

The first keyring fix closed two of its three call sites and its regression test passed — it drove state: present, and state: absent is the only state that reaches the third. The detector that found the original exposure still named the file afterwards; that is what surfaced it. Each case now carries a witness: the command its own state must reach, so a case that returns early cannot pass by finding no secret in no commands.

⛔

The file we examined and the file we ran

An inventory path is stated and its mode decides it is a dynamic inventory script — then the same string went to exec.Command, which does a $PATH lookup for any name with no separator. So -i inv.sh validated ./inv.sh and executed a different inv.sh from $PATH. Measured with an impostor of the same basename: FROM-SOMEWHERE-ON-PATH before, the named file after, and real ansible-core runs the file it was pointed at. The audit question is not “is there a $PATH lookup?” — ansible-vault’s $EDITOR is right to do one — but “did something already decide this string names one file?”

♾️

One typo, a goroutine per host per task

gonja’s lexer runs in a goroutine on an unbuffered channel, so an expression that fails to parse mid-stream left it blocked on a send nobody would ever receive. when: is evaluated once per task, per host: +200 goroutines for 200 evaluations, held with their input, for the length of the run. The first probe reported zero — its bad expression failed at the end of the stream, where nothing is left unsent. A test case has to fail in the right place. Lexing synchronously fixed it and made that path 2.05× faster.

📍

What remains, named

On macOS keyring runs security add-generic-password … -w "$USER_PASSWORD", and that value is in one short-lived process's argv while it runs. There is no stdin form — measured — and the Keychain API needs cgo, which this port does not use. Linux does not have it: secret-tool reads stdin. Two vendor CLIs, rollbar-cli and ma-shell, have the same irreducible flag, confirmed by reading their source.

Every detector behind those numbers was shown able to report a violation before its zero was believed — the credential-prefix check was handed the pre-fix keyring line and fired on it. Full writeup: the Security page.

The repositories

Eight packages, not one monolith.

Each is importable on its own: a program that only needs to read an inventory takes inventory and nothing else. The line under each name is what was actually checked against ansible-core.

vault lib

encrypt with go-ansible/vault, decrypt with the real ansible-vault, and back

Ansible Vault-compatible AES256 encryption

What it actually does

Reads and writes the Ansible Vault 1.1 file format byte-for-byte: AES-256-CTR with a PBKDF2-HMAC-SHA256 derived key and an encrypt-then-MAC HMAC-SHA256 tag, hex-wrapped at 80 columns — the exact wire format of ansible.parsing.vault.VaultAES256, not a reinterpretation of it. Decryption fails closed on a bad HMAC with a constant-time comparison, before ever touching the ciphertext.

inventory lib

group_vars/host_vars merge order and ancestor precedence match ansible-inventory

INI/YAML inventory parsing, groups, host patterns

What it actually does

Parses Ansible-compatible INI and YAML inventories — single file or a directory of them — into the same group/host graph Ansible builds: group_vars and host_vars merged in, ancestor groups resolved in the order that makes a child group's vars win over its parent's, and "ungrouped"/"all" computed the way Ansible computes them. Ships Ansible's own host-pattern language too: globs, numeric ranges, and colon/comma combinations with ! exclusion and & intersection.

vars lib

an order-faithful subset of ansible-core's ~22 precedence levels

Ansible's variable precedence ladder

What it actually does

Implements Ansible's variable precedence as a fixed ladder of named layers — role defaults up through play/role/block/task vars, registered vars, role params, and -e/--extra-vars — merged low to high so a value set higher always wins. Doesn't know about roles or plays itself; a playbook engine assigns each layer's content, this package only owns the merge order, plus a Which(key) call that answers "why did this variable win?".

template lib

a bare {{ expr }} renders to its native type, not a stringified one — Ansible's own rule

Jinja2-compatible templating + Ansible's filters, tests and lookups

What it actually does

Renders Ansible-flavored Jinja2 on top of a Jinja2-compatible engine: string interpolation, control structures, and Ansible's own filter and test library (to_json/from_yaml, regex_* backed by a PCRE-compatible engine, b64encode, combine, and more) layered on Jinja2's built-ins. Implements the one rule that trips up naive ports: a value that is a single {{ expr }} with nothing else around it renders to the expression's native type — a list, a dict, an int — not a string.

modules lib

every result key real returns, measured per module -- template reports the same thirteen keys copy does, slurp returns source and no msg, known_hosts echoes its own parameters; 576 module names registered, measured 2026-10-18 by intersecting Default().Names() with `ansible-doc -l <collection> -t module` against real ansible-core 2.21.4 — 62 of ansible.builtin's 71 (the other nine are playbook constructs rather than remote modules: add_host, group_by, meta and the import_*/include_* family, which real keeps outside its module library too), all 14 of ansible.posix, and fourteen community.general batches (500 of 577) — run against a live go-remoteexec/transport connection

Module execution protocol + the module library

What it actually does

Defines Ansible's module contract in Go — a function that takes a target Connection and already-rendered args, returns a changed/failed/msg Result — and ships 576 modules on top of it: all 62 of ansible.builtin's remote modules, all 14 of ansible.posix (synchronize is an honest, always-failing stub rather than a silent approximation — real synchronize runs rsync from the controller directly against the target's own SSH endpoint, which the Connection abstraction can't expose from inside a module), and fourteen curated community.general batches — package managers (apk/homebrew/snap/flatpak/pacman/npm/yarn/pnpm/gem/bundler/composer/zypper/dnf_versionlock/macports/pkgin/pkgng/xbps/pkg5/portage/pipx/urpmi/bower/easy_install and more), language/dev tooling (cpanm/cargo/golang_package/maven_artifact/pear/opkg/django and Django extensions), filesystem/storage (archive/decompress/ini_file/xml/read_csv/crypttab/lvg/lvol/lvm_pv/btrfs/zfs/zpool/vdo/parted/filesystem/xfs_quota/write_binary_file/file_remove and more), networking (nmcli/interfaces_file/iptables_state/ufw/nsupdate/wakeonlan/ip_netns), system/service management (sudoers/pam_limits/pamd/timezone/locale_gen/modprobe/cronvar/logrotate/puppet/monit/runit/sysrc/homectl/launchd/svc/nosh and more), SELinux (selinux_permissive/sefcontext/selogin/seport), Pacemaker cluster management (including stonith/fencing), LDAP (ldap_entry/ldap_attrs/ldap_search/ldap_passwd/ldap_inc), FreeIPA (ipa_user/ipa_group/ipa_host/ipa_vault/ipa_pwpolicy and more), Redis, Consul (kv, acl policies/roles/tokens, agent services/checks), Kerberos, desktop/system config (dconf/osx_defaults/kdeconfig/java_keystore/xfconf/gconftool2 and more), LXD/LXC containers, HashiCorp Nomad, database admin (mssql_db/vertica_*/influxdb_* via sqlcmd/vsql/influx), RHEL subscription management, AIX (aix_devices/aix_filesystem/aix_lvg and more), Elastic Stack plugins (elasticsearch/logstash/kibana), Icinga2, Kopia backup, version control (bzr/hg), web/app servers (apache2/jboss), ISO tools (xorriso-backed), provisioning (cobbler_sync/cobbler_system/stacki_host), IPMI (ipmi_boot/ipmi_power), Univention UDM (udm_user/udm_group/udm_share/udm_dns_record/udm_dns_zone), XenServer (xenserver_guest/xenserver_facts and more), GitHub (github_repo/github_release/github_issue and more, via the gh CLI), GitLab (gitlab_project/gitlab_merge_request/gitlab_issue and more, via the glab CLI), Keycloak (keycloak_realm/keycloak_client/keycloak_user and more, via kcadm.sh), Scaleway (scaleway_compute/scaleway_ip/scaleway_lb and more, via scw), Huawei Cloud (hwc_ecs_instance/hwc_vpc_subnet and more, via KooCLI), Jenkins (jenkins_job/jenkins_build and more, via jenkins-cli.jar), Equinix Metal (packet_device and more, via metal), Linode (linode_v4, via linode-cli), OpenNebula (one_host/one_image/one_template/one_vm/one_vnet/one_service and more, via its own onehost/oneimage/onetemplate/onevm/onevnet/oneflow CLIs), Rundeck (rundeck_project/rundeck_job_run and more, via rd), Alibaba Cloud (ali_instance, via aliyun-cli), IBM SoftLayer (sl_vm, via slcli), 1Password (onepassword_info, via op), Pulp (pulp_repo, via pulp-cli), Twilio (via twilio-cli), Aerospike (aerospike_migrations, via asadm), Alerta (alerta_customer, via the alerta CLI), Heroku (heroku_collaborator, via the heroku CLI), Mattermost (via mmctl), New Relic (newrelic_deployment, via the newrelic CLI), DNSimple (dnsimple/dnsimple_info, via dnsimple-cli), ipinfo.io (ipinfoio_facts, via the ipinfo CLI), Cloudflare (cloudflare_dns, via flarectl), OVHcloud (ovh_monthly_billing fully covered via ovhcloud-cli; ovh_ip_failover/ovh_ip_loadbalancing_backend investigated and found to have no CLI verb, fail loud), Dell EMC VNX (emc_vnx_sg_member, via naviseccli), IBM Spectrum Accelerate (ibm_sa_domain/host/host_ports/pool/vol/vol_map, via xcli), HPE 3PAR (ss_3par_cpg, via its own CLI over SSH), the Redfish hardware-vendor family (idrac_redfish_* via Dell's racadm, ilo_redfish_* via HPE's ilorest, xcc_redfish_command via Lenovo's OneCli), hponcfg (HP's own local iLO config binary, not a substitution), Jira (via Atlassian's acli, Jira Cloud only), Honeybadger (honeybadger_deployment, via hb), Rollbar (rollbar_deployment, via rollbar-cli), Memset (dns_reload/memstore_info/server_info/zone/zone_domain/zone_record, via ma-shell), ansible_galaxy_install (not a substitution — the real module already shells out to ansible-galaxy directly), Airbrake (airbrake_deployment, via airbrake-cli), UptimeRobot (via the newly-launched official @uptimerobot/cli), SendGrid (via twilio-cli's built-in email:send), Circonus (circonus_annotation, via circli), Kea DHCP (kea_command, via kea-shell), Broadcom Spectrum (spectrum_device/spectrum_model_attrs, via vnmsh — local-only, a genuine architecture mismatch against the real modules' remote OneClick REST server, documented), FusionLayer Infinity IPAM (infinity, via curl — not Infinidat's storage arrays as the platform name might suggest, and no official CLI exists for it), IP2Location (ip2location_info, via ip2locationio — keyless, no secret involved at all), and a handful of misc modules (git_config/ssh_config/htpasswd/java_cert/mail/keyring/riak/ipwcli_dns) — deliberately excluding SaaS-API wrappers with no comparable official CLI, except GitHub/GitLab/Keycloak/Jenkins/Scaleway/Huawei Cloud/Equinix Metal/Linode/Alibaba Cloud/IBM SoftLayer, which ship official CLIs this port can shell out to just like Consul/Redis/Terraform/Icinga2/Kopia, and except the Redfish hardware-vendor family (also once named as an exclusion example, reconsidered because ilorest/OneCli are genuine strong-fit official Redfish CLIs and racadm is a real official parallel interface) — the cloud-VPS providers and the Redfish family each reconsidered from an earlier stated exclusion example, across four explicit scope decisions. Western Digital's Redfish gear was checked and found to have no official CLI, so it remains excluded. Two platforms were investigated and found to have no usable official CLI for what their modules need — Pritunl (org/user management) and two of OVHcloud's three modules — all fail loud rather than fake parity, confirmed gaps rather than assumed ones. Rollbar's rollbar-cli and Memset's ma-shell were both confirmed, by reading their real source, to have no environment-variable alternative to their access-token/API-key argv flag — a documented, unavoidable exception to this project's own no-secrets-in-argv rule -- as is macOS's `security add-generic-password -w`, which keyring uses and which has no stdin form either (measured: piping the value in with no -w argument stores nothing), the Keychain API needing cgo this port does not use. A security audit of the whole library found exactly one unintended breach of that rule, keyring's own shell prefix, and one command injection, async_status's unquoted jid; both are fixed and written up rather than quietly patched. Unlike real Ansible, which copies a Python script to the target and runs it there, each module here runs its logic on the control node and reaches the target only through the connection's Exec/Put/Fetch primitives. Each module also knows which collection it came from (Collection/FQCN), measured against real's doc index rather than assumed: ansible-doc used to print ansible.builtin for all of them, which was true when it was written and wrong for 514 of the 576 today — and wrong in a way nothing local could catch, since this port's own name resolution accepts the wrong prefix while real Ansible rejects it.

facts lib

collects the whole ansible_facts/ansible_* set in one shell round trip, no Python

Fact gathering — the gather_facts/setup module equivalent

What it actually does

Gathers the standard ansible_facts/ansible_* set — distribution, os_family, architecture, and more — with a single portable shell probe sent over a go-remoteexec/transport connection, instead of copying and running Ansible's Python setup module.

playbook lib

400 test functions (go test -list '.*', 2026-10-20) against a real Local connection, including a real become/sudo run; ansible_connection accepts local, ssh and winrm

Playbook/task/handler execution engine

What it actually does

Runs a parsed playbook against an inventory with a real per-host executor: when/loop/register, block/rescue/always with genuine per-host recovery, notify/handlers, become, pre_tasks/post_tasks ordering, roles, include_role/import_role, include_tasks/import_tasks, import_playbook, vars_files, delegate_to, serial, tags/--tags/--skip-tags, meta: flush_handlers/clear_facts, add_host, group_by, and include_vars — wiring inventory+vars+template+modules+facts together the way ansible-playbook does. Three of real's strategies run here — linear, free and host_pinned — and anything else is rejected outright rather than silently treated as linear; a couple of edge cases carry a documented single-host-layer limitation (nested role variable scoping, register: on setup:); see the feature matrix on this page for exactly which.

cli cli

a real playbook run against localhost proving idempotency/loops/ignore_errors/register+changed_when, plus real ad-hoc runs in both real ansible argument orderings, plus a real interactive ansible-console session

Nine of ansible-core's ten ansible-* binaries

What it actually does

Nine thin binaries — ansible, ansible-playbook, ansible-inventory, ansible-vault, ansible-galaxy, ansible-pull, ansible-doc, ansible-config, ansible-console — wiring the libraries above (plus go-git for ansible-galaxy's role installs and ansible-pull's repo fetch) into the actual commands Ansible users type. Real ansible-core 2.21.4 declares ten console scripts in its own entry_points.txt, and the one this port does not ship is ansible-test, the collection test runner — it drives collection test suites against collections this port does not host. (ansible-connection, often named alongside it, is not a console script in 2.21.4 at all; counted from the package's metadata rather than from memory.) All nine support --version; ansible-playbook also wires --tags/-t and --skip-tags. ansible-doc prints each module's own Go doc comment (not real ansible-doc's structured DOCUMENTATION-YAML rendering, stated plainly) under the module's real collection — community.general.rocketchat, ansible.posix.synchronize — verified header by header against real; ansible-config reads real ANSIBLE_* environment variables and, for the settings this port supports, an actual ansible.cfg [defaults] section; ansible-galaxy only clones a role from a git URL, no galaxy.ansible.com API or collections. Also published as a multi-arch FROM scratch OCI image, ghcr.io/go-ansible/cli, on every version tag. All six of Go's 64-bit targets — amd64, arm64, riscv64, loong64, ppc64le, s390x — cross-compile clean and race-clean, and all nine binaries build for windows/amd64 and windows/arm64 as well, which real ansible-core cannot do at all: it does not support Windows as a control node.

Measured, not asserted

One machine, ten runs, the first discarded.

ansible_connection: local on both sides, the same playbook, the same host. Full methodology, every command and every caveat in BENCHMARKS.md — these numbers are reproducible or they are worthless.

Measurego-ansibleansible-core
Control-node footprint~14M static binary (9.5M stripped)~132M (51M venv + 81M interpreter)
8-task playbook, median wall clock25ms1694ms
Process startup alone (1-task no-op)7ms300ms
Minimal working containerFROM scratch, 21.3MBpython:3.13-slim, 284MB

The FROM scratch result has a limit, reported rather than smoothed over: command and shell fork /bin/sh, which scratch does not have. Every module that runs entirely on the controller — debug, set_fact and the rest — works in true scratch today; those two need a shell-bearing base (busybox:musl, for instance).

71 / 71
ansible.builtin items — 62 modules and 9 engine directives
576
modules registered — the count the binary itself lists, not one counted in source
8
repositories, each importable on its own
6
64-bit architectures — amd64, arm64, riscv64, loong64, ppc64le, s390x — race-clean

Configuration management that ships like Go.

No interpreter, no C toolchain, no runtime to install. The connection layer — SSH, local, become — lives outside this org in go-remoteexec/transport, shared with go-puppet-bolt, so neither project ships its own.

Read the source on GitHub →