Skip to main content
RunBook Academy

AnsibleXL · Patch and Reboot ManagementPatch and Reboot Management

The package module and the portability trap

Advanced⏱ ~22 minansible-playbookansible-doc

What you'll learn

  • Decide when ansible.builtin.package is the right abstraction and when it is a false economy
  • Structure a vars/ split so package names resolve per distribution family
  • Explain why check-mode guarantees weaken when a task goes through package
  • Use use and ansible_package_use correctly, and know which wins

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

ansible.builtin.package looks like the answer to running one patching role across a mixed fleet. It selects apt on Debian-family hosts and dnf on RHEL-family ones, so the role stops needing a when: ansible_facts.os_family == 'Debian' on every task.

It does exactly that, and it does only that. The documentation says so in a sentence that is easy to skim past:

Package names also vary with package manager; this module will not “translate” them per distribution. For example libyaml-dev, libyaml-devel.

The manager is abstracted. The names are not. Half a problem solved is still half a problem, and the half that remains is the half that fails in production rather than at parse time.

What it actually abstracts

Read-only / Safethe module's complete parameter list
$ ansible-doc ansible.builtin.package
   name    Package name, or package specifier with version.
         Syntax varies with package manager. For example `name-1.0'
         or `name=1.0'.
         Package names also vary with package manager; this module
         will not "translate" them per distribution.

 state   Whether to install (`present'), or remove (`absent') a
         package.
         You can use other states like `latest' ONLY if they are
         supported by the underlying package module(s) executed.

 use     The required package manager module to use (`dnf', `apt',
         and so on). The default `auto' will use existing facts or
         try to auto-detect it.
      default: auto

Three parameters, and the documentation is explicit about why: “This documentation only covers the minimum intersection of module arguments that all packaging modules support.”

Everything from the previous two lessons is outside that intersection. cache_valid_time, dpkg_options, lock_timeout, only_upgrade, security, update_only, download_only, allowerasing — none of them are package parameters.

The documentation does say “all arguments will be passed to the underlying module”, so an extra argument is not silently dropped. It is passed through, and it fails on any host whose selected manager does not accept it. A package task carrying security: true works on your RHEL-family hosts and fails on your Debian-family ones — which is to say, the abstraction stops abstracting at exactly the point where you needed it to.

The name problem, and the shape that solves it

libyaml-dev on Debian and Ubuntu. libyaml-devel on RHEL, Rocky and Alma. The module will not bridge that, so you bridge it in variables.

Configuration changea role vars/ split keyed on os_family
# roles/appdeps/vars/Debian.yml
app_packages:
- libyaml-dev
- libssl-dev
- python3-dev

# roles/appdeps/vars/RedHat.yml
app_packages:
- libyaml-devel
- openssl-devel
- python3-devel

# roles/appdeps/tasks/main.yml
- name: Load the package names for this distribution family
ansible.builtin.include_vars: '{{ ansible_facts.os_family }}.yml'

- name: Install the application build dependencies
ansible.builtin.package:
  name: '{{ app_packages }}'
  state: present

This is the shape worth internalising, because it puts the variability where it belongs. The task expresses the intent once. The vars files carry the per-family facts, and a reviewer can see the whole distribution difference by opening two short files rather than reading every task in the role.

ansible_facts.os_family is the right key for this, not ansible_facts.distribution. os_family groups Rocky, Alma, RHEL, CentOS and Fedora as RedHat, and Debian and Ubuntu as Debian, which is the granularity package names actually vary at. Keying on distribution gives you five near-identical files that drift apart.

use and ansible_package_use

The default auto selects the manager from facts — ansible_facts.pkg_mgr is the fact that carries it. Two overrides exist.

use is the task parameter. ansible_package_use is a variable, available since ansible-core 2.17, which can be set in inventory or group vars. The documentation states the precedence directly: the variable is available “but this option still takes precedence” — so use on the task wins over ansible_package_use.

Read-only / Safesetting the manager where the exception lives
# host_vars/build07.example.com.yml
# This host runs a dnf-4 stack that auto-detection gets wrong after the
# in-place upgrade. Owner: platform. Remove once the host is rebuilt.
ansible_package_use: dnf

The order to reach for these: fix the facts, then set the variable in inventory, and only put use on a task when the task genuinely must pin a manager regardless of the host. A use: hard-coded in a shared role is an assumption about every host the role will ever run against.

Check mode is where the abstraction leaks most

Every other module in this part declares a definite check-mode capability. apt is check_mode: full. dnf and dnf5 are check_mode: full.

package declares check_mode: N/A, with the detail “support depends on the underlying plugin invoked”.

The same caveat applies to diff_mode, documented identically as depending on the dispatched plugin.

When package is genuinely the right call

The abstraction earns its place when three things are true at once:

  1. The package name is the same everywhere. curl, git, rsync, jq, tmux, chrony. Development headers and library packages almost never qualify.
  2. You need only present or absent. These are the two states the module documents; anything else is conditional on the dispatched plugin.
  3. No manager-specific option is required. No cache handling, no lock timeout, no security filter, no repository scoping.
Configuration changethe case package was written for
- name: Ensure the standard operator toolset is present
ansible.builtin.package:
  name:
    - curl
    - git
    - rsync
    - tmux
  state: present

That task is better than the two-conditional version it replaces. It is shorter, it has one failure mode instead of two, and it does not go stale when someone adds a third distribution family.

The judgement is not “abstract or do not abstract”. It is knowing that this particular abstraction covers the manager and not the vocabulary, and putting it where that is enough.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A role uses ansible.builtin.package with name: libyaml-dev against a fleet of Ubuntu and Rocky hosts. What happens on the Rocky hosts?

  2. Q2. Why is ansible.builtin.package a poor choice for the task that performs a fleet patch run?

  3. Q3. Which statements about check mode and ansible.builtin.package are correct? Select all that apply.

  4. Q4. If both are set, the use parameter on a package task overrides the ansible_package_use variable set in inventory.

Passing score: 75%. Answers are checked in this browser.