Skip to content
cli — the 8 ansible-* binaries

cli — the 8 ansible-* binaries

cli

CI

github.com/go-ansible/cli ships 9 of real Ansible’s 10 command-line tools — every one but ansible-test, which drives the collection test suites and is out of scope — each a thin run(args []string) int wired directly onto playbook, inventory, vault and modules — no shelling out to a Python ansible-core underneath, no subprocess boundary at all.

BinaryWhat it does
ansibleAd-hoc module execution against a pattern (ansible all -m ping)
ansible-playbookRuns a playbook via playbook.Engine; -f/--forks sets the concurrency cap, and vars_prompt: prompts at a real terminal (masked input for private vars, falling back to defaults when not a TTY); --check/-C predicts changes without making them and --diff/-D shows them as unified diffs
ansible-inventoryDumps the parsed inventory: --list (JSON), --host (one host’s merged vars), --graph (tree), with --export, --vars, --limit, --output and the vault options — see below
ansible-vaultEncrypt/decrypt/view/edit/rekey — wraps vault
ansible-galaxyinstall a role from a git URL via go-git, plus list and remove of installed roles — no galaxy.ansible.com HTTP API, out of scope
ansible-pullClones/pulls a git repo and runs a playbook from it against this machine only — see below
ansible-docPrints each module’s own Go doc comment via a build-time codegen tool — real content, not real Ansible’s structured DOCUMENTATION YAML
ansible-configlist/dump/view — reads real ANSIBLE_* environment variables and, for the settings this port supports, an actual ansible.cfg [defaults] section (host var > env var > ansible.cfg > compiled default; $ANSIBLE_CONFIG > ./ansible.cfg > ~/.ansible.cfg > /etc/ansible/ansible.cfg, first found wins outright)
ansible-consoleInteractive REPL with real’s prompt and banner: cd, list, become/nobecome, forks, remote_user, verbosity, and any registered module by name or a bare shell command — see below

Building

Each binary lives at cmd/<name> and builds the ordinary way:

go build ./cmd/ansible-playbook
./ansible-playbook -i inventory.ini site.yml

ansible’s pattern-first argument convention (ansible all -m ping, not ansible -m ping all) doesn’t fit Go’s standard flag package, which expects flags before positional arguments — a small extractPattern preprocessor in cmd/ansible/main.go pulls the one non-flag token out before handing the rest to flag, so both argument orderings work as they do with real ansible.

Container image

cli also publishes a multi-arch FROM scratch OCI image, ghcr.io/go-ansible/cli, bundling every binary, on every version tag — all six 64-bit architectures (amd64/arm64/riscv64/loong64/ppc64le/s390x).

Those six are the Linux image. The binaries themselves also build for windows/amd64 and windows/arm64, checked on every push by a CI job that builds all nine and asserts the count — so the control node can be Windows, which real ansible-core does not support at all. That is about running ansible-playbook; reaching a Windows target is ansible_connection: winrm, and the ansible.windows module family it would need is not ported. The build stage cross-compiles from the runner’s own native architecture instead of running under QEMU for every target, which is what makes loong64 possible at all: the official golang image itself publishes no linux/loong64 manifest. A CGO_ENABLED=0 Go binary needs no libc and no interpreter — real Ansible fundamentally cannot reach scratch, since it needs Python plus several pip-installed packages just to be the controller. See BENCHMARKS.md for the measured comparison.

Known limitation, not hidden: command/shell (and any module that execs a shell on a local connection) need /bin/sh, which does not exist in a scratch image. debug/copy/template/set_fact and friends work; a playbook that also targets a real host over SSH is unaffected either way, since the shell requirement is the target’s, not this image’s.

Diagnostics, and an inventory that cannot be read

Diagnostics use real Ansible’s prefixes on stderr: [ERROR]: for something that stops the run, [WARNING]: for something that does not.

An unusable inventory is the second kind. Real never dies because of the inventory — an absent, unreadable or unparseable source warns and the run continues with only the implicit localhost, exiting 0 — and so does no -i at all, which is why ansible localhost -m ping works there with no inventory. This port behaves the same way; it used to exit 1 and to require -i, which broke the script around a drop-in replacement.

Four independent warnings, each with its own condition:

warningfires when
Failed to parse inventory: <cause>the source exists and parsing failed
Unable to parse <abspath> as an inventory sourcethe source is unusable
No inventory was parsed, only implicit localhost is availableno source parsed at all
provided hosts list is empty, only localhost is available. …the inventory has no hosts

They are not one warning in four places. An empty .ini parses, so it gets the last one only; a missing one gets the middle two but no cause line, because real’s plugins report a parse failure only once they have actually tried to parse. The path is reported absolute even when -i was given a relative one. The last warning is suppressed when the host pattern is one the implicit localhost answers to, so ad-hoc ansible -i /nope.ini localhost -m ping prints two warnings where the same command with a pattern of h1 prints three.

A host pattern that matches nothing is likewise a warning and not a failure, for a play’s hosts: and for ad-hoc alike. --limit is the exception, and real’s own: a limit that leaves the whole inventory with nothing to target is [ERROR]: Specified inventory, host pattern and/or --limit leaves us with no hosts to target. and exit 1 — but only when the inventory was not empty to begin with.

A directory is an unusable source when it holds no inventory file at all — every entry a subdirectory, group_vars/host_vars, or a dotfile. It is named like a missing file, with no cause line, because real never reached a parser for it. A directory holding an empty file still parses: it is the directory that must hold a source, not the source that must hold hosts.

One difference from real remains here. A source that fails to parse gets a one-line cause where real prints a nine-line block naming the plugin it tried, a source position and an excerpt, which needs per-node positions this port’s parsers do not record.

Verbosity, and the status it exits with

-v is the flag a reader reaches for first, and ansible-playbook had none: it exited 2 with flag provided but not defined. The forms real accepts are supported as of cli v0.129.0 — -v, -vv and longer runs, a repeated -v -v, and --verbose — and the count is additive, because real declares -v/--verbose … action="count" and argparse’s short-option bundling makes -vvv three occurrences.

At -v an ok or changed line carries the whole result, in the compact single-line form a fatal: line uses rather than the pretty block. A module that dumps at every verbosity (debug) keeps its pretty block; at -v real shows both side by side, and so does this.

no_log still wins at every level — see Security.

Accepted but adding nothing beyond -v: the higher levels. Real’s -vv prefixes task path: <file>:<line>, which needs source positions playbook’s parser does not keep, and -vvv prints a version banner and connection tracing. The flag’s own help text says so. Refusing -vv outright would break every habitual invocation, which is worse than accepting it with less output.

Exit statuses

Real distinguishes cases this port used to collapse into one. Measured against ansible-core 2.21.4, case by case:

what happenedrealthis port, before v0.128.0
success00
a task failed22
a host was unreachable44
a YAML or structural error44
a module name that cannot be resolved42
encrypted content, no or wrong vault password14
the playbook file is missing11

A vault failure is not a parse error: the file parsed fine, it just could not be read, and a caller needs to tell “I cannot read this” from “this is not a playbook”. The classification matches vault’s sentinels (ErrNoPassword, ErrHMACMismatch) rather than message text.

An unresolvable module name exits 4 under --syntax-check and --list-tasks too, which is what real does.

ansible-inventory

--list, --host and --graph, with --export, --vars, --limit, --output, and --vault-password-file / --vault-pass-file / --ask-vault-password / --ask-vault-pass — the same vault surface ansible-playbook takes, because the canonical place real Ansible keeps a secret is a group_vars/*/vault.yml, and a command that reads an inventory has to be able to unlock one. ansible takes them too. The output shapes follow real’s, which are more particular than they look:

  • a group is emitted only if it has hosts, children, or (with --export) vars — which is why an empty ungrouped never appears although it always exists;
  • a host with no variables at all is left out of _meta.hostvars rather than carried as an empty object;
  • without --export, a host carries its merged vars and groups show none; with it, a host carries only its own and groups keep theirs;
  • --limit filters hosts but not structure: a child stays listed even when the limit emptied it, while the emptied group loses its own entry;
  • --graph prints children first, then hosts, then vars;
  • no action at all exits 5, not 1 or 2;
  • orders are document order, not alphabetical — "all": {"children": ["ungrouped", "prod"]}, "prod": {"children": ["web", "db"]} for a [prod:children] section written web-then-db.

An unusable source warns; one you cannot decrypt is fatal

These are different failures and real separates them. An inventory source that cannot be parsed means “carry on with what you have”; one that is encrypted and cannot be read means the inventory you asked for is not the one you would get. Measured against ansible-core 2.21.4, and identical from ansible-inventory, ansible-playbook and ansible:

stderrexit
-i /nope.ini[WARNING]: Unable to parse …0
encrypted, no vault secret[ERROR]: Attempting to decrypt but no vault secrets found.4
encrypted, wrong password[ERROR]: Decryption failed (no vault secrets were found that could decrypt).4

This port treated the encrypted cases as ordinary parse failures until cli v0.139.0: a warning, and exit 0. The consequence is worth stating plainly, because it is why the distinction is not pedantry —

ansible-inventory --list > hosts.json && deploy

succeeded, with a hosts.json holding no hosts. The classification keys on vault’s sentinels (ErrNoPassword, ErrHMACMismatch), not on message text.

Unlike ansible-playbook, it prints two warnings for an unusable inventory rather than three: the “provided hosts list is empty” one comes from real’s own get_host_list, which this binary never calls because it resolves no host list of its own.

--yaml and --toml are real output formats that are not implemented. They are refused explicitly, so a command line asking for one fails rather than silently receiving JSON.

One difference from real: --host localhost omits ansible_python_interpreter, which real reports for the implicit localhost. It is a path to a Python this port never runs, so stating one would be a claim about the target that is not true here.

ansible: how -a is parsed

The -a string goes through playbook.ParseModuleArgs, which is this stack’s port of real’s parse_kv/split_args: quote state, nested {{ }} and {% %} blocks, escaped equals, and _raw_params. It is the same parser the k=v task form uses, deliberately — ad-hoc had its own strings.Fields loop until cli v0.140.0, and it was wrong in a way that did not announce itself.

-aresult
msg=hellomsg="hello"
msg="hello world"msg="hello world" — the quotes hold it together
msg="a=b"msg="a=b" — only the first equals splits
msg=hello worldrefused: world is a raw param and debug cannot take one
{"msg": "a b"}decoded as JSON, which real’s ad-hoc tries first
command: echo hi_raw_params="echo hi"

A stray token used to be silently dropped: -a 'msg=hello world' ran with msg="hello", and -a 'src=/a badtoken dest=/b' ran as if badtoken had never been typed. Real refuses both, and so does this now — [ERROR]: Task failed: Action 'ansible.builtin.copy' does not support raw params., exit 2.

Two lists, and they are not the same list

  • MODULE_REQUIRE_ARGS — command, raw, script, shell, win_command, win_shell — decides whether a k=v is an option or part of the free-form text. The old ad-hoc parser knew only command and shell, so the other four were parsed as pairs.
  • RAW_PARAM_MODULES, which is wider, decides whether a module may receive _raw_params at all.

Reading the first off the second would make set_fact a=1 a raw param, so there is a test for exactly that case.

The arguments are templated, per host

Since cli v0.141.0, the parsed arguments are rendered against the variables a play would give that host — playbook.AdHocHostVars, the same construction the play path uses, so there is one place deciding which variables exist rather than two. All of these resolve, and were measured against real:

-aresult
msg={{ inventory_hostname }}"h1"
msg={{ group_names }}["web"]
msg={{ groups.web | length }}2
msg={{ hostvars["h2"].wv }}the other host’s value
msg={{ wv }}a group_vars value
msg={{ 1 + 1 }}2 — unquoted, a whole-expression template keeps its native type

inventory_dir and playbook_dir are not the same thing here, which an inventory in a subdirectory shows: -i sub/inv2 gives inventory_dir …/sub while playbook_dir is the working directory, there being no playbook.

The template engine is built per host goroutine rather than shared, because gonja’s filter registration is not atomic and an engine costs little beside opening a connection.

What still differs

Real exits 250 with Unexpected Exception on a string that merely looks like JSON (-a '{not json}'). That is a traceback rather than a behaviour, so this falls through to the k=v parser instead.

ansible-galaxy list and remove

Neither needs the Galaxy API — they read and delete directories — so both are implemented, and match real across the eight invocations they were measured on. Three rules are worth knowing because they are not what you would guess:

  • a directory is a role only when it holds meta/main.yml; a plain directory is skipped, and so is one carrying only a meta/.galaxy_install_info;
  • the version shown comes from meta/.galaxy_install_info, which the installer writes — a role declaring galaxy_info.version in meta/main.yml still lists as (unknown version);
  • --roles-path does not replace the default search path (~/.ansible/roles, /usr/share/ansible/roles, /etc/ansible/roles), it goes in front of it, which is why list -p somewhere still warns about the defaults that do not exist.

Roles are listed in the order the filesystem returns them, not sorted, because that is what real does.

ansible-console

The prompt is real’s, and carries four things — the user the console would connect as, the current pattern, how many hosts it matches, and the fork count. It ends in # rather than $ while become is on, the way a root shell prompt does:

david@all (3)[f:5]$
david@all (3)[f:5]#     under become

A banner is printed at startup and Ansible-console was exited. when the session ends, including when the input simply runs out — which is what a piped script does.

Two behaviours are worth knowing because the obvious guess is wrong, and both are real’s:

  • a bare become is refused with a request for a value rather than toggling, since the prompt already shows the state. Anything not plainly affirmative turns it off, so a typo cannot leave a session escalated;
  • a bare cd goes to *, not to all.

Two differences from real remain: its help lists every module alongside the built-in commands, and a failing command prints the structured [ERROR] block this port cannot reproduce without per-node source positions.

ansible-pull

Two behaviours are worth stating because a pull agent runs unattended:

  • The run is limited to this machine, however wide the playbook’s own hosts: is. Real does the same, and it matters: without the limit, a repository whose inventory names other hosts would have them configured by whichever machine happened to pull it.
  • A relative --inventory names a file in the CHECKOUT, not in the directory ansible-pull was run from — real runs the playbook with the checkout as its working directory, which is what makes -i hosts find the inventory a repository carries. An absolute path is used as given.

The clone is reported first, in the shape of real’s own git-module task: CHANGED with a null before on a first clone, SUCCESS with both revisions and remote_url_changed on a later run.

One difference from real remains. Real runs the clone as a separate ansible invocation, which resolves --inventory against the working directory and so prints one more Could not match supplied host pattern warning than this single-process design does. Emitting it here would mean warning about an inventory this port never loads.

What is not implemented

ansible-galaxy here only clones a role from a git URL — no galaxy.ansible.com API client, no collection installs (a standalone galaxy repository was planned early on for this; the actual implementation turned out simple enough to fold directly into cli instead, and the standalone repository was never developed further). ansible-doc prints this port’s own Go doc comments, not real Ansible’s structured DOCUMENTATION/EXAMPLES/ RETURN YAML blocks — but it names the module’s own collection in the header and in -l, as real does:

> MODULE community.general.rocketchat
> MODULE ansible.posix.synchronize
> MODULE ansible.builtin.copy

Until v0.143.0 it printed ansible.builtin. for every module, from a premise stated in its own comment: “Every module this port implements is one of ansible-core’s own.” True when written, wrong for 514 of the 576 registered today — and wrong in a way nothing local could catch, since modules.NormalizeName strips any known prefix, so ansible.builtin.rocketchat ran here while real Ansible rejects it. The attribution comes from modules.Collection, measured against real’s own doc index rather than assigned by hand. ansible-config’s ansible.cfg support covers only the settings this port itself reads (see ConfigDefaults) — an unrecognized-but-valid Ansible setting in the file is silently ignored rather than surfaced, matching how unset environment variables were already treated.