modules — 566-module execution library
modules
github.com/go-ansible/modules implements Ansible’s module execution model
and the core module library: 576 modules registered — 62 of
ansible.builtin, all 14 of ansible.posix, and 500 curated from
community.general (577 real modules in that collection; the remainder is
deliberately excluded, each with a real, checked reason — see the
org profile for the full list).
ansible.builtin has 71 modules, and the nine this registry does not hold
are not remote modules at all: add_host, group_by, meta and the
import_*/include_* family are playbook constructs, implemented in
playbook — which is where real Ansible puts them too, as
action plugins and strategy behaviour rather than modules. The collection
is covered; the registry holds the 62 that are modules.
The counts were measured on 2026-10-18 against real ansible-core 2.21.4
by intersecting Default().Names() with ansible-doc -l <collection> -t module, not by adding up batches — a sum of batch sizes says how many
were written, never which names are there.
Unlike real Ansible, which copies a Python script to the target and runs it
there, a module here runs its logic on the control node and reaches the
target only through a go-remoteexec/transport
Connection’s Exec/Put/Fetch primitives. The observable behavior is the
same — the target ends up in the same state — but the difference is
architectural: a module needs no Go toolchain, no interpreter, nothing
installed on the target at all.
API
type Result struct {
Changed bool
Failed bool
Msg string
Facts map[string]any // merged into ansible_facts — set_fact, setup, ...
Extra map[string]any // module-specific fields — command's rc/stdout/stderr, ...
}
func Ok(msg string) Result
func Changed(msg string) Result
func Fail(msg string) Result
func (r Result) WithExtra(key string, value any) Result
type Func func(ctx context.Context, conn remoteexec.Connection, args map[string]any) (Result, error)
type Registry struct { /* ... */ }
func NewRegistry() *Registry
func Default() *Registry // pre-populated with all 576 built-in modules
func (r *Registry) Register(name string, fn Func)
func (r *Registry) Get(name string) (Func, bool)
func (r *Registry) Names() []string
func (r *Registry) Run(ctx context.Context, name string, conn remoteexec.Connection, args map[string]any) (Result, error)
func NormalizeName(name string) string // strips a known collection prefix
func Collection(name string) string // "ansible.builtin" | "ansible.posix" | "community.general"
func FQCN(name string) string // the name real ansible-doc printsCollection and FQCN exist because NormalizeName makes the registry a
flat namespace, and a flat namespace cannot say where a name came from. A
consumer printing a qualified name had to guess, and cli’s ansible-doc
guessed ansible.builtin. for all of them — true when it was written, wrong
for 514 of the 576 registered today. That is not cosmetic: because
NormalizeName strips any known prefix, ansible.builtin.rocketchat ran
here and real Ansible rejects it, so the documentation was handing out a
name that works nowhere else and nothing local could catch it.
The three lists behind Collection were measured on 2026-10-18 by
intersecting Default().Names() with ansible-doc -l <collection> -t module
from real ansible-core 2.21.4 — 62, 14 and 500 names. They are disjoint
and their union is exactly the registry, both asserted, so a module added
without an entry fails rather than silently becoming ansible.builtin.
Get falls back to NormalizeName(name), so a fully-qualified collection
name (ansible.builtin.copy, community.general.ufw, and the other three
known prefixes) resolves to exactly the same Func as its bare form — no
separate registration needed per FQCN.
A module’s arguments arrive already Jinja2-rendered by the caller (this
package never templates anything itself). A non-nil error from Run means
the module could not determine an outcome at all (a transport failure); an
expected failure — the fail module itself, assert on a false condition,
a package manager reporting “not found” — is a Result{Failed: true} with a
nil error, since it is not the module’s own execution that went wrong.
Run also finalizes a result on its way out, which is where real Ansible
does the same work (AnsibleModule.exit_json) rather than leaving each of
the ~40 modules that produce output to remember it: a trailing newline is
trimmed off stdout/stderr, and a matching stdout_lines/stderr_lines
is added. The trim is exactly Python’s rstrip("\r\n") — every trailing
carriage return and newline goes, trailing spaces and tabs survive, and
leading newlines survive.
Run also attaches real’s exception key to every failing result,
and to no successful one. Real emits it on every fail_json path, and
where Python has no traceback to show — which, measured across eight
failures in six modules, is every time — the value is the literal
string (traceback unavailable). The key is in the result and not in
the printed output: real’s own _dump_results pops exception,
warnings and deprecations before printing, so r.exception
templates and registers while no failure line shows it.
The result-key census
Run’s finalization is only half the story: each module’s own key set
is real’s too, and comparing values rather than key sets hid
five divergences in modules a value-only pass had called identical.
| module | what it returned | what real returns |
|---|---|---|
template | 3 keys, dest path in msg | the same 13 keys copy returns, no msg |
unarchive | 3 keys, dest path in msg | 13 keys incl. handler, extract_results |
known_hosts | an English sentence | its own parameters, echoed back |
slurp | path in msg, no source | content, encoding, source, no msg |
stat | an empty msg | no msg at all |
set_fact | msg: "facts set" | no msg — a sentence real never emits |
pause | 3 keys | 12 keys: rc, echo, delta, start, stop, user_input, stdout(+_lines), stderr(+_lines) |
wait_for_connection | msg: "connection is usable" | changed, elapsed, failed — no msg |
async_status | 6 of 11 keys | + results_file, stdout(+_lines), stderr(+_lines) |
package_facts | its own failure wording | real’s, verbatim |
service_facts | failed | skipped |
setup | msg: "gathered facts" | no msg — another sentence real never emits |
uri | 6 keys, content always | 15 keys built from the response HEADERS; content only with return_content |
git | "<dest> cloned" | before / after — the commit it was at and is at — no msg |
cron | "<name> unchanged" | envs / jobs — the entries it manages — no msg |
| get_url | 12 keys, skipped blindly when dest existed | 18 on a download, 14 on a 304 |
get_url was the one deliberately left for its own change, and it got
it. The download now goes through a staging file and is moved into
place, which is what makes src nameable and checksum_dest
meaningful. Four things the measurement settled:
- a second run is a 304, not a skip — real sends a conditional
request (
If-Modified-Sincefrom the destination’s mtime) and reportschanged=falsewithmsg: "HTTP Error 304: Not Modified"; - the 304 run reports fourteen keys against eighteen, differing by
exactly
src/checksum_src/checksum_dest/md5sum; checksum_destis the empty string when the destination did not exist, not an absent key;forceforces the download, not the verdict — with identical content real reportschanged=falseandstatus_code=200.
uri is the largest of them. Real builds most of its result from the
response headers — lowercasing each name and turning hyphens into
underscores — so content_type, content_length, date,
last_modified and server are the server’s own headers rather than a
list this port could hardcode. It captured no headers at all, which is
why its curl invocation changed. Three further rules each needed
measuring: msg is the status line’s reason phrase plus the body size
(“OK (12 bytes)”, not “OK (200)”); content appears only with
return_content: true; and json appears only when the body parses as
one, real skipping the key rather than reporting null.
service_facts is the one that was not about keys at all. Real does not
fail when it finds no services — it sets skipped=True — and a failed
task stops a play that a skipped one does not, so this port halted
playbooks real lets through. Reaching a playbook took both halves: the
module setting Result.Skipped and playbook’s own resultToMap
serialising it, which it never had. skipped is emitted only when
true, which is how real does it.
pause carries a distinction only a measurement shows: delta is
the int seconds while stdout carries the real elapsed to two
decimals (“Paused for 1.02 seconds” against delta: 1). They are not
the same number, so reusing one for both would be wrong in whichever
place it was reused. Its stdout_lines/stderr_lines needed no code
at all — finalizeOutput derives them from stdout/stderr for every
module, so setting stdout produced all four keys.
Three of those needed a measurement rather than a judgement. Real’s
template reports exactly the key set real’s copy reports, so
the builder here is copy’s own rather than a second implementation.
And addPathInfo overwrites state, so known_hosts reports the
file’s state and not its own state: parameter — real reports
state=file for a task whose parameter said present.
One divergence is deliberate and stays. Real’s unarchive fails on
macOS for .tar and .tgz with “Failed to find handler”, because it
probes for a GNU tar this platform does not have; this port uses bsdtar
and succeeds. Levelling down to match would be a regression, not
parity — the same reasoning that leaves getent working here while
real requires a getent binary macOS does not ship.
Measured against real Ansible
The module surface has been run side by side with real ansible-core
2.21.4: the same playbook through both engines, twice, comparing the
registered results and diff -r of the filesystem trees each produced.
Running it twice is the point — the second pass is what shows whether
idempotence matches, which is where a module port most easily drifts.
Across file (directory/touch/absent), copy, template, lineinfile
(both plain and regexp), blockinfile, replace, stat, slurp,
find, command, shell, set_fact and assert, the compared values
are byte-identical on both passes, and the two produced trees match
exactly, permissions included.
That sentence used to say “byte-identical to real Ansible” without
qualification, and a later, wider comparison disproved it. The run
above compared the values a playbook reads; it did not compare the
registered result’s full KEY SET. A census that did found slurp
returning the file path in msg and omitting source entirely, stat
returning an empty msg where real returns none at all, and find
returning an empty msg where real always reports one of three
sentences. All three are fixed, and the lesson is kept here rather than
quietly edited away: a comparison is only as wide as the fields it
looks at, and “identical” means nothing without naming them.
Two defects were found and fixed that way, neither of which any unit test had caught:
stdoutkept its trailing newline, andstdout_lines/stderr_linesdid not exist at all —{{ result.stdout_lines }}is everyday playbook usage and renderednull. Both are handled inRun, as above.assert: that: ["x == 5"]— the form every real playbook uses — failed with “did not evaluate to a boolean”. This package’s own documentation already stated that the playbook engine would evaluate those expressions before calling the module, and nothing did; the engine now does, inplaybook’srunDirective, for the same reason real Ansible makesassertan action plugin rather than a module — the conditions are Jinja2 expressions over the host’s own variables, and only the engine holds those.
Example
package main
import (
"context"
"fmt"
"log"
"github.com/go-ansible/modules"
remoteexec "github.com/go-remoteexec/transport"
)
func main() {
reg := modules.Default()
conn := remoteexec.NewLocal() // runs on this machine
result, err := reg.Run(context.Background(), "copy", conn, map[string]any{
"src": "/tmp/source.txt",
"dest": "/tmp/dest.txt",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Changed, result.Msg)
}command expands variables; it still does not glob
Real’s command runs no shell, yet command: echo $HOME * prints the
home directory and a literal asterisk: run_command applies
expanduser(expandvars(x)) to each argv element, which command.py
exposes as expand_argument_vars (default true since 2.16). This port
expanded neither until modules v0.81.0.
The interesting part is where it expands. Real’s module runs on the
target, so $HOME is the target’s; a module here runs on the
controller, and substituting the controller’s home into a command
meant for another machine is a wrong answer that looks right whenever
both are the same user.
So the expansion is delegated to the target’s shell, and the port decides only which spans may be delegated. The only unquoted text it ever emits is
"${NAME-\$NAME}"for a name matching [A-Za-z_][A-Za-z0-9_]*. Everything else stays
quoted, so no glob, no $(...), no backtick and no word split can reach
the shell from an argument — and the - default is what leaves an
unset name as the literal $NAME, which is real’s documented
behaviour and the opposite of a bare "$NAME".
Named rather than papered over: real’s expanduser consults the
password database when HOME is unset, where this falls back to a
literal ~.
What is not implemented
Every architectural substitution and disclosed gap is documented in the
module’s own Go doc comment, not hidden — ansible-doc <module> (via the
cli package) prints it. The recurring patterns: a module whose
real Python implementation calls a SaaS/cloud API directly instead shells out
to that platform’s own official CLI when one exists (gh/glab/scw/
aliyun/ilorest/… — never a secret in argv, always an environment
variable or a pre-existing authenticated session; see
Security, which records where that rule was broken and
what the audit that found it measured); a service with no CLI at all —
most of the notification senders, and the vendor REST APIs — is reached with
curl on the target, where real’s own module runs and calls fetch_url
from, through one curlDo primitive that sends the url, the query string and
the body on stdin via curl’s own -K - config, because a credential hides
in all three of those places (a form field, a URL path, a query parameter) and
every one of them is somewhere a naive implementation would put on the command
line; a module real Ansible runs
from the controller against the target’s own SSH endpoint directly
(ansible.posix.synchronize) fails loud rather than silently approximating,
since this port’s Connection abstraction has no way to expose a target’s
host/port/credentials from inside a module; and a handful of modules
knowingly reproduce a real upstream bug verbatim rather than “fixing” it,
because the reference implementation — not this project’s own judgment — is
the source of truth for what a real user’s playbook may already depend on.