A CLI-driven operation is repeated across many of your Ansible roles as a shell task. How do you decide whether to leave it, wrap it in a role, or invest in a custom module?
answer
- buy before you build
- count the call sites first
- is the state inspectable?
- check mode is the acceptance bar
- an unowned module rots invisibly
basics
~20 sDecide on repetition, idempotency and blast radius. Search for an existing module in a maintained collection first; wrap in a role when the shell-out is rare and guardable; write a module when the operation is widespread, needs honest changed reporting and check mode, and someone will own it.
solid answer
~50 sStart by checking whether the work already has a module — `ansible-doc -l` and the certified or community collections cover far more than people assume, and `ansible.builtin.uri` handles most "the CLI just calls an API" cases. If nothing fits, the question becomes cost versus reach. A shell task guarded with `creates` and a clear name is fine when it appears once or twice and its effect is obvious. A role that encapsulates the shell-out is right when several playbooks need it but its semantics are still "run this thing". A custom module earns its keep when the operation is used widely, when correct `changed` reporting and check mode actually matter — because a wrong prediction in a dry run is dangerous — and when the result should be structured data other tasks consume. The deciding factor is usually ownership: a module is code with tests, docs, a collection to version it in, and someone on the hook when the vendor CLI changes.
go deeper
Know the cheap first move: search for an existing module before writing anything, and if you must shell out, guard the task with creates so it does not run every time.
Be able to compare the options on cost — a guarded command task, a role that encapsulates it, or a custom module — and explain what each does and does not give you in terms of change reporting.
Argue the decision from operational evidence: how many call sites, whether the state is inspectable, whether operators depend on --check being meaningful, and what the failure costs if the shell version is wrong.
Own the extension policy for the estate: what gets promoted to a supported module, how it is packaged and versioned in a collection, who maintains it against upstream CLI churn, and how consumers pin it so one change cannot alter every playbook at once.
## The question behind the question Interviewers ask this to see whether you treat Ansible extension as an engineering investment or as a reflex. Both failure modes are common: the team that shells out to everything and never converges cleanly, and the team that writes a bespoke module for a one-off task and then cannot maintain it. ## Step one: has someone already written it Before any build decision, exhaust the search. `ansible-doc -l` lists what is installed; the collections ecosystem (`ansible.builtin`, `community.general`, vendor-certified collections) covers a very wide surface. Two specific substitutions eliminate a surprising share of shell tasks: - **`ansible.builtin.uri`** — most vendor CLIs are thin wrappers over an HTTP API. Calling the API directly with `uri` gives you real status codes, structured JSON in the registered result, retry control, and no dependency on a binary being installed on every target. - **The file and service family** — `lineinfile`, `blockinfile`, `template`, `file`, `unarchive`, `service`, `package`, `git`, `cron`, `mount`. A large fraction of shell tasks in an inherited repository are these modules not looked for. Buying beats building whenever the maintained thing exists, because someone else absorbs the upstream churn. ## Step two: the three options, and what each costs **Leave the shell task, guarded.** Cost: near zero. Requirements: a `creates` or `removes` guard so it is not always-changed, an explicit task name that says what and why, and `ansible.builtin.command` rather than `shell` unless a shell feature is genuinely needed. Right when the operation appears in one or two places and nobody is confused by it. **Encapsulate in a role.** Cost: low, and the mechanism is one the whole team already knows. It gives you one place to fix the command line, defaults for its parameters, and a name that documents intent. What it does *not* give you is honest change reporting — a role wrapping `command` is still a role that always reports changed. Right when several playbooks need the same steps and the semantics really are procedural. **Write a module.** Cost: real. Python code that must run on every target OS in the fleet, an argument spec, check-mode support, unit tests, `DOCUMENTATION`/`RETURN` strings so `ansible-doc` works, a collection to distribute and version it in, and consumers pinning a version so a change does not silently alter every playbook at once. Right when the reach is wide, when the operation manages a *resource* with a state you can inspect and compare, and when check mode and diff matter operationally. There is a fourth, less-known option worth naming: for controller-side work — resolving a value, choosing which module to dispatch — a plugin (a lookup or an action plugin) is sometimes the correct extension point rather than a module. Reaching for a module when the work never touches the target is a design error. ## The signals that tip it Toward a module: - The operation appears in many roles and its command line has already drifted between copies. - The state it manages is inspectable, so `changed` can be computed rather than assumed. - Operators run `--check` before applying and would be misled by a task that predicts nothing. - Downstream tasks need structured data, not a scraped `stdout`. - The failure mode is expensive — the shell version is one typo from destroying something. Away from a module: - Used once; the cost is all fixed and the benefit is all marginal. - The underlying CLI is volatile, so you would be tracking someone else's interface forever. - No named owner. An unowned module is worse than a shell task, because it looks authoritative and rots invisibly. - Target hosts have constrained or inconsistent Python, making a Python payload a new dependency problem. ## Framing the answer well The strong answer sequences it: search for an existing module first; if none, guard the shell-out immediately so the estate keeps converging cleanly; then decide the investment on reach, on whether the operation has an inspectable state, and on whether anyone will own it. The weak answer picks a side on aesthetics — "shell is always bad" or "custom modules are over-engineering" — without pricing either option. And the answer that lands is the one that names the acceptance bar for the module you would build: it must report changed honestly and behave correctly under `--check`, or it has not bought you anything the shell task did not already provide.
- What would make you reject a proposed custom module in review even though the operation is repeated everywhere?No inspectable state and no named owner. If the module cannot read the current state, it will report changed on every run and buys nothing over a guarded command task. And a module without an owner, tests and a versioned home in a collection is worse than the shell task it replaces, because it carries the authority of a real module while quietly drifting from the CLI it wraps.
- Why is ansible.builtin.uri often the better answer than either a shell-out or a new module?Because most vendor CLIs are wrappers over an HTTP API. Calling the API with `uri` removes the dependency on a binary being present on every target, returns parsed JSON into the registered result, and lets you control status-code handling and retries. It is maintained upstream, so you inherit none of the maintenance burden a bespoke module creates.
- How do you stop a shared custom module from becoming a change-everything-at-once risk?Ship it in a collection with a version, and have consuming repositories depend on a pinned version rather than a copied file. Then a behaviour change lands when each consumer bumps it, with its own review and its own plan run, instead of the next playbook execution everywhere picking up a different meaning for the same task.
saying these in an interview costs you the question
- Writes a module before searching existing collections
- Treats every shell task as automatically unacceptable
- Ignores who will maintain the module afterwards
- Builds a module that still always reports changed
- Overlooks that a module adds a Python dependency on targets