cli — the 8 ansible-* binaries
cli
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.
| Binary | What it does |
|---|---|
ansible | Ad-hoc module execution against a pattern (ansible all -m ping) |
ansible-playbook | Runs 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-inventory | Dumps 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-vault | Encrypt/decrypt/view/edit/rekey — wraps vault |
ansible-galaxy | install 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-pull | Clones/pulls a git repo and runs a playbook from it against this machine only — see below |
ansible-doc | Prints each module’s own Go doc comment via a build-time codegen tool — real content, not real Ansible’s structured DOCUMENTATION YAML |
ansible-config | list/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-console | Interactive 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.ymlansible’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:
| warning | fires when |
|---|---|
Failed to parse inventory: <cause> | the source exists and parsing failed |
Unable to parse <abspath> as an inventory source | the source is unusable |
No inventory was parsed, only implicit localhost is available | no 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 happened | real | this port, before v0.128.0 |
|---|---|---|
| success | 0 | 0 |
| a task failed | 2 | 2 |
| a host was unreachable | 4 | 4 |
| a YAML or structural error | 4 | 4 |
| a module name that cannot be resolved | 4 | 2 |
| encrypted content, no or wrong vault password | 1 | 4 |
| the playbook file is missing | 1 | 1 |
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 emptyungroupednever appears although it always exists; - a host with no variables at all is left out of
_meta.hostvarsrather 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; --limitfilters hosts but not structure: a child stays listed even when the limit emptied it, while the emptied group loses its own entry;--graphprints 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:
| stderr | exit | |
|---|---|---|
-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 && deploysucceeded, 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.
-a | result |
|---|---|
msg=hello | msg="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 world | refused: 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 ak=vis an option or part of the free-form text. The old ad-hoc parser knew onlycommandandshell, so the other four were parsed as pairs.RAW_PARAM_MODULES, which is wider, decides whether a module may receive_raw_paramsat 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:
-a | result |
|---|---|
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 ameta/.galaxy_install_info; - the version shown comes from
meta/.galaxy_install_info, which the installer writes — a role declaringgalaxy_info.versioninmeta/main.ymlstill lists as(unknown version); --roles-pathdoes not replace the default search path (~/.ansible/roles,/usr/share/ansible/roles,/etc/ansible/roles), it goes in front of it, which is whylist -p somewherestill 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 becomeA 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
becomeis 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
cdgoes to*, not toall.
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
--inventorynames a file in the CHECKOUT, not in the directoryansible-pullwas run from — real runs the playbook with the checkout as its working directory, which is what makes-i hostsfind 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.copyUntil 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.