go-ansible

A pure-Go, functional-parity port of Ansible โ€” Vault-compatible encryption, inventory parsing, the variable precedence ladder, Jinja2-compatible templating, module execution, fact gathering, the playbook engine, and all 8 ansible-* CLI binaries, with no Python, no C extensions, and one static binary.

CGO_ENABLED=0 byte-compatible with ansible-vault real per-host playbook execution 8 CLI binaries 326 modules amd64 ยท arm64 ยท riscv64 ยท loong64 ยท ppc64le ยท s390x BSD-3-Clause
Documentation GitHub

Ansible's own engine is a Python program that shells out to modules and needs a matching interpreter on the control node. go-ansible reimplements that engine as importable Go packages instead of wrapping the Python binary โ€” the same wire formats, merge orders, and rendering rules, compiled into a single static executable with no interpreter to install and no C toolchain to cross-compile against.

The bar for every component here is compatibility with the real thing, not a plausible reimplementation of it: vault reproduces ansible.parsing.vault.VaultAES256's exact byte format, and template implements the native-type rendering rule that trips up naive Jinja2 ports. All eight core repositories below are shipped and tagged; parity is still partial โ€” see the module count and the engine feature matrix further down this page for exactly what that does and doesn't cover today, re-checked against the code rather than assumed.

71 / 71full ansible.builtin surface (modules + directives)
326modules registered (builtin + posix + community.general batches 1-5)
8CLI binaries, all shipped
6CPU architectures, race-clean

Repositories

vault libcrypto

Ansible Vault-compatible AES256 encryption

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

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.

CI

inventory libdata

INI/YAML inventory parsing, groups, host patterns

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

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.

CI

vars libengine

Ansible's variable precedence ladder

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

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?".

CI

template libengine

Jinja2-compatible templating + Ansible's filters/tests

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

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.

CI

modules libexec

Module execution protocol + the module library

326 module names registered โ€” the full ansible.builtin surface (62) plus ansible.posix (14) plus five community.general batches (250 of 577) โ€” run against a live go-remoteexec/transport connection

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 326 modules on top of it: all 62 of ansible.builtin's real 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 five 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 and more), language/dev tooling (cpanm/cargo/golang_package/maven_artifact/pear/opkg/django), filesystem/storage (archive/decompress/ini_file/xml/read_csv/crypttab/lvg/lvol/lvm_pv/btrfs/zfs/zpool/vdo/parted/filesystem/xfs_quota 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 and more), SELinux (selinux_permissive/sefcontext/selogin/seport), Pacemaker cluster management, 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), and a handful of misc modules (git_config/ssh_config/htpasswd/java_cert/mail) โ€” deliberately excluding SaaS-API wrappers and cloud-VPS/hardware providers that need real API client SDKs rather than shell composition. 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.

CI

facts libprobe

Fact gathering โ€” the gather_facts/setup module equivalent

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

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.

CI

playbook libengine

Playbook/task/handler execution engine

17 end-to-end tests against a real Local connection, including a real become/sudo run

Runs a parsed playbook against an inventory with a real per-host linear-strategy 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. Only a strategy other than linear is rejected outright; 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.

CI

cli clicli

All 8 Ansible CLI binaries

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

Eight thin binaries โ€” ansible, ansible-playbook, 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. All eight 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); ansible-config reads real ANSIBLE_* environment variables (no ansible.cfg file support); 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.

CI

Engine feature matrix

The silent-no-op class โ€” a playbook key that parses without error but does nothing โ€” is a worse risk than an outright missing feature, since a playbook using it succeeds while quietly not doing what it claims. This table reflects playbook/engine.go and playbook/playbook.go as read directly, not the shipped-package list: several of these fields exist on the Play/Task structs and parse cleanly from YAML while the engine never reads them.

Playbook featureStatusDetail
when / loop / registerImplementedPer-host, real conditionals and loop expansion
block / rescue / alwaysImplementedGenuine per-host recovery, not just parsed
notify / handlersImplementedDeduplicated, run once per host after the play
becomeImplementedReal sudo end-to-end test in CI
pre_tasks / post_tasksImplementedConcatenated into the task list in pre โ†’ tasks โ†’ post order and executed normally
rolesImplementedLoaded from roles/<name>/{tasks,handlers,defaults,vars}/main.yml, spliced into a synthetic block task; role handlers fold into the play's handler list
include_role / import_roleImplementedSame synthetic-block path as roles:, resolved statically at parse time (not re-evaluated per host)
include_tasks / import_tasksImplementedReferenced file's task list spliced in as a synthetic block, resolved statically at parse time
vars_filesImplementedLoaded and merged into Play.Vars at parse time, lower precedence than an explicit vars: key
delegate_toImplementedTask 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
serialImplementedSplits matched hosts into batches; each batch runs every task and handler to completion before the next starts
tags / --tags, --skip-tagsImplementedTag 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_factsImplementedBoth run for real; any other meta: action errors instead of silently no-op'ing
add_hostImplementedAdds a host (with vars and groups) to the live inventory mid-run
group_byImplementedAdds the current host to an inventory group mid-run
include_varsImplementedLoads a YAML file's keys into the host's vars, bare-name accessible like set_fact
inventory_hostname / playbook_dirImplementedSet as magic variables after host_vars, so a same-named host_var can't shadow them
--version (all 8 CLI binaries)Implementedansible, ansible-playbook, ansible-vault, ansible-galaxy, ansible-pull, ansible-doc, ansible-config, ansible-console all print a version string and exit 0
strategyNot yetAnything other than linear (or unset) is rejected with an explicit parse error โ€” never silently accepted and ignored
import_playbookImplementedResolved 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 scopingDocumented limitSingle-level save/restore only: a role included from inside another role's own tasks leaves its vars/defaults in place for the rest of the outer role, rather than being scoped to just the inner role
register: on a setup:/gather_facts: taskDocumented limitThe registered variable holds the facts flat, not nested under ansible_facts โ€” the common bare ansible_facts.X access (via automatic per-host fact injection) works fine regardless

Also out of scope today: the namespace/collection metadata system, dynamic inventory plugins, lookup/callback plugins, and ansible.cfg file support (though ansible-config does read real ANSIBLE_* environment variables). ansible-galaxy here only clones a role from a git URL โ€” no galaxy.ansible.com API, no collection installs. This matrix reflects the code as read on 2026-09-03; engine work is active in parallel, so treat it as a snapshot, not a promise.

Module coverage

modules/registry.go registers 326 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 five curated batches of 250 of community.general's 577 modules โ€” package managers, language/dev tooling, filesystem/storage, networking, system/service management, SELinux, read-only facts, Pacemaker cluster management, 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, and a handful of misc modules, deliberately excluding SaaS-API wrappers (Slack, PagerDuty, GitLab, Jenkins, and similar) and cloud-VPS/ hardware providers (Scaleway, Linode, OpenNebula, Redfish/iLO/iDRAC, and similar) that need real API client SDKs rather than shell composition over a connection. ~327 more community.general modules remain โ€” an increasingly SaaS/cloud/hardware-vendor-skewed remainder โ€” plus every cloud-provider collection (amazon.aws/azure/google.cloud and similar โ€” a fundamentally different kind of work, not yet started), remain unported.

Dependencies

The low-level connection layer โ€” SSH, local execution, become/sudo โ€” lives outside this org, in go-remoteexec/transport, a project-neutral package shared with go-puppet-bolt/bolt. modules, playbook, and cli depend on it directly rather than shipping their own transport.

Benchmarks

Real, measured numbers against a real ansible-core install โ€” one machine, N=10 runs, first discarded as warm-up, ansible_connection: local on both sides. Full methodology, every command, and the caveats in BENCHMARKS.md.

Measurego-ansiblereal ansible-core
Control-node footprint~14M static binary (9.5M stripped)~132M (51M venv + 81M Python interpreter it needs)
8-task playbook, median wall clock25ms1694ms (~68ร— slower)
Process-startup cost alone (1-task no-op)7ms300ms
Minimal working containerFROM scratch, 21.3MB, runs and exits 0python:3.13-slim + pip install, 284MB โ€” scratch is not reachable at all

The FROM scratch proof has a real limit, reported rather than smoothed over: command/shell tasks fork /bin/sh, which scratch doesn't have, so those specific modules need a shell-bearing base (e.g. busybox:musl) even though every module that runs entirely on the controller โ€” debug, set_fact, and others โ€” works in true scratch today.