Ansible Best Practice: Creating a Unified Package Management Module for Cross-Platform Compatibility


2 views

When automating infrastructure across heterogeneous environments, one of the most common pain points in Ansible is handling package managers across different Linux distributions. Unlike some configuration management tools that abstract package operations behind a unified interface (like SaltStack's pkg state), Ansible requires explicit module selection:


# Traditional approach with conditional statements
- name: Install NGINX on RedHat
  yum:
    name: nginx
    state: latest
  when: ansible_os_family == "RedHat"

- name: Install NGINX on Debian
  apt:
    name: nginx
    state: latest
  when: ansible_os_family == "Debian"

We can solve this by creating a custom module that automatically selects the appropriate package manager based on the OS family. Here's how to implement it:


# In library/unified_package.py
from ansible.module_utils.basic import AnsibleModule

def main():
    module = AnsibleModule(
        argument_spec=dict(
            name=dict(type='str', required=True),
            state=dict(type='str', default='present')
        )
    )

    pkg_name = module.params['name']
    pkg_state = module.params['state']
    os_family = module.get_best_parsable_fact(['os_family', 'system'])[0]

    if os_family == 'RedHat':
        module.run_command(['yum', 'install', '-y', pkg_name], check_rc=True)
    elif os_family == 'Debian':
        module.run_command(['apt-get', 'install', '-y', pkg_name], check_rc=True)
    else:
        module.fail_json(msg="Unsupported OS family: {}".format(os_family))

    module.exit_json(changed=True, msg="Package {} installed".format(pkg_name))

if __name__ == '__main__':
    main()

Once you've created the custom module (saved in a library directory relative to your playbook), you can use it like this:


- name: Install packages cross-platform
  hosts: all
  tasks:
    - name: Install Apache
      unified_package:
        name: httpd
        state: latest

    - name: Install Vim
      unified_package:
        name: vim-enhanced  # RedHat
        name: vim-nox       # Debian

Some packages have different names across distributions. We can extend our module to handle this:


# In library/smart_package.py
def get_package_name(os_family, base_name):
    package_map = {
        'httpd': {
            'RedHat': 'httpd',
            'Debian': 'apache2'
        },
        'vim': {
            'RedHat': 'vim-enhanced',
            'Debian': 'vim-nox'
        }
    }
    return package_map.get(base_name, {}).get(os_family, base_name)

# In your playbook
- name: Install web server (httpd on CentOS, apache2 on Ubuntu)
  smart_package:
    name: httpd

If you prefer not to maintain custom modules, consider using community-maintained roles like geerlingguy.package from Ansible Galaxy that provide similar functionality:


- hosts: all
  roles:
    - role: geerlingguy.package
      vars:
        package_name: httpd

When working with heterogeneous Linux environments in Ansible, package management quickly becomes complex. While RedHat-based systems use yum or dnf, Debian-based systems rely on apt, and Arch Linux uses pacman. This fragmentation leads to repetitive conditional logic in playbooks.

Ansible actually provides a solution through its generic package module:

- name: Install httpd using generic package module
  package:
    name: httpd
    state: present

The package module automatically uses the appropriate package manager based on the ansible_pkg_mgr fact. However, it has limitations with advanced package operations.

For more control, we can create a custom module. Save this as library/unified_pkg.py:

from ansible.module_utils.basic import AnsibleModule

def run_module():
    module_args = dict(
        name=dict(type='str', required=True),
        state=dict(type='str', default='present')
    )
    
    module = AnsibleModule(
        argument_spec=module_args,
        supports_check_mode=True
    )
    
    result = dict(
        changed=False,
        original_message='',
        message=''
    )
    
    pkg_mgr = module.params['name']
    state = module.params['state']
    os_family = module.params['ansible_os_family']
    
    if os_family == 'RedHat':
        module.run_command(['yum', '-y', 'install' if state == 'present' else 'remove', pkg_mgr])
    elif os_family == 'Debian':
        module.run_command(['apt-get', '-y', 'install' if state == 'present' else 'remove', pkg_mgr])
    
    module.exit_json(**result)

def main():
    run_module()

if __name__ == '__main__':
    main()

For a more robust solution, we can use Ansible's facts and include additional package managers:

- name: Unified package installation
  block:
    - name: Install package (RedHat)
      yum:
        name: "{{ package_name }}"
        state: "{{ package_state }}"
      when: ansible_pkg_mgr == "yum" or ansible_pkg_mgr == "dnf"

    - name: Install package (Debian)
      apt:
        name: "{{ package_name }}"
        state: "{{ package_state }}"
      when: ansible_pkg_mgr == "apt"

    - name: Install package (Arch)
      pacman:
        name: "{{ package_name }}"
        state: "{{ package_state }}"
      when: ansible_pkg_mgr == "pacman"
  vars:
    package_name: httpd
    package_state: latest

Different distros sometimes use different package names. We can handle this with a mapping dictionary:

- name: Install web server across distros
  vars:
    pkg_map:
      RedHat: httpd
      Debian: apache2
      Suse: apache2
      Arch: apache
  package:
    name: "{{ pkg_map[ansible_os_family] | default(pkg_map['RedHat']) }}"
    state: present
  • Always test package operations in check mode first
  • Include timeout parameters for package operations
  • Consider using roles to organize package installation logic
  • Document all package name variations in your team's playbook standards