Skip to content
modules — 566-module execution library

modules — 566-module execution library

modules

CI

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 prints

Collection 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.

modulewhat it returnedwhat real returns
template3 keys, dest path in msgthe same 13 keys copy returns, no msg
unarchive3 keys, dest path in msg13 keys incl. handler, extract_results
known_hostsan English sentenceits own parameters, echoed back
slurppath in msg, no sourcecontent, encoding, source, no msg
statan empty msgno msg at all
set_factmsg: "facts set"no msg — a sentence real never emits
pause3 keys12 keys: rc, echo, delta, start, stop, user_input, stdout(+_lines), stderr(+_lines)
wait_for_connectionmsg: "connection is usable"changed, elapsed, failed — no msg
async_status6 of 11 keys+ results_file, stdout(+_lines), stderr(+_lines)
package_factsits own failure wordingreal’s, verbatim
service_factsfailedskipped
setupmsg: "gathered facts"no msg — another sentence real never emits
uri6 keys, content always15 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-Since from the destination’s mtime) and reports changed=false with msg: "HTTP Error 304: Not Modified";
  • the 304 run reports fourteen keys against eighteen, differing by exactly src/checksum_src/checksum_dest/md5sum;
  • checksum_dest is the empty string when the destination did not exist, not an absent key;
  • force forces the download, not the verdict — with identical content real reports changed=false and status_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:

  • stdout kept its trailing newline, and stdout_lines/stderr_lines did not exist at all — {{ result.stdout_lines }} is everyday playbook usage and rendered null. Both are handled in Run, 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, in playbook’s runDirective, for the same reason real Ansible makes assert an 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.