How to unify package installation tasks in ansible?

I am starting with ansible and will use it, among others, to install packages on several Linux distros.

I see in the docs that the yum and apt commands are separated - what would be the easiest way to unify them and use something like this:

- name: install the latest version of Apache
  unified_install: name=httpd state=latest

instead of

- name: install the latest version of Apache on CentOS
  yum: name=httpd state=latest
  when: ansible_os_family == "RedHat"

- name: install the latest version of Apache on Debian
  apt: pkg=httpd state=latest 
  when: ansible_os_family == "Debian"

I understand that the two package managers are different, but they still have a set of common basic usages. Other orchestators (salt for instance) have a single install command.


Solution 1:

Update: As of Ansible 2.0, there is now a generic & abstracted package module

Usage Examples:

Now when the package name is the same across different OS families, it's as simple as:

---
- name: Install foo
  package: name=foo state=latest

When the package name differs across OS families, you can handle it with distribution or OS family specific vars files:

---
# roles/apache/apache.yml: Tasks entry point for 'apache' role. Called by main.yml
# Load a variable file based on the OS type, or a default if not found.
- include_vars: "{{ item }}"
  with_first_found:
    - "../vars/{{ ansible_distribution }}-{{ ansible_distribution_major_version | int}}.yml"
    - "../vars/{{ ansible_distribution }}.yml"
    - "../vars/{{ ansible_os_family }}.yml"
    - "../vars/default.yml"
  when: apache_package_name is not defined or apache_service_name is not defined

- name: Install Apache
  package: >
    name={{ apache_package_name }}
    state=latest

- name: Enable apache service
  service: >
    name={{ apache_service_name }}
    state=started
    enabled=yes
  tags: packages

Then, for each OS that you must handle differently... create a vars file:

---
# roles/apache/vars/default.yml
apache_package_name: apache2
apache_service_name: apache2

---
# roles/apache/vars/RedHat.yml
apache_package_name: httpd
apache_service_name: httpd

---
# roles/apache/vars/SLES.yml
apache_package_name: apache2
apache_service_name: apache2

---
# roles/apache/vars/Debian.yml
apache_package_name: apache2
apache_service_name: apache2

---
# roles/apache/vars/Archlinux.yml
apache_package_name: apache
apache_service_name: httpd



EDIT: Since Michael DeHaan (creator of Ansible) has chosen not to abstract out the package manager modules like Chef does,

If you are still using an older version of Ansible (Ansible < 2.0), unfortunately you'll need to handle doing this in all of your playbooks and roles. IMHO this pushes a lot of unnecessary repetitive work onto playbook & role authors... but it's the way it currently is. Note that I'm not saying we should try to abstract package managers away while still trying to support all of their specific options and commands, but just have an easy way to install a package that is package manager agnostic. I'm also not saying that we should all jump on the Smart Package Manager bandwagon, but that some sort of package installation abstraction layer in your configuration management tool is very useful to simplify cross-platform playbooks/cookbooks. The Smart project looks interesting, but it is quite ambitious to unify package management across distros and platforms without much adoption yet... it'll be interesting to see whether it is successful. The real issue is just that package names sometimes tend to be different across distros, so we still have to do case statements or when: statements to handle the differences.

The way I've been dealing with it is to follow this tasks directory structure in a playbook or role:

roles/foo
└── tasks
    ├── apt_package.yml
    ├── foo.yml
    ├── homebrew_package.yml
    ├── main.yml
    └── yum_package.yml

And then have this in my main.yml:

---
# foo: entry point for tasks
#                 Generally only include other file(s) and add tags here.

- include: foo.yml tags=foo

This in foo.yml (for package 'foo'):

---
# foo: Tasks entry point. Called by main.yml
- include: apt_package.yml
  when: ansible_pkg_mgr == 'apt'
- include: yum_package.yml
  when: ansible_pkg_mgr == 'yum'
- include: homebrew_package.yml
  when: ansible_os_family == 'Darwin'

- name: Enable foo service
  service: >
    name=foo
    state=started
    enabled=yes
  tags: packages
  when: ansible_os_family != 'Darwin'

Then for the different package managers:

Apt:

---
# tasks file for installing foo on apt based distros

- name: Install foo package via apt
  apt: >
    name=foo{% if foo_version is defined %}={{ foo_version }}{% endif %}
    state={% if foo_install_latest is defined and foo_version is not defined %}latest{% else %}present{% endif %}
  tags: packages

Yum:

---
# tasks file for installing foo on yum based distros
- name: Install EPEL 6.8 repos (...because it's RedHat and foo is in EPEL for example purposes...)
  yum: >
    name={{ docker_yum_repo_url }}
    state=present
  tags: packages
  when: ansible_os_family == "RedHat" and ansible_distribution_major_version|int == 6

- name: Install foo package via yum
  yum: >
    name=foo{% if foo_version is defined %}-{{ foo_version }}{% endif %}
    state={% if foo_install_latest is defined and foo_version is not defined %}latest{% else %}present{% endif %}
  tags: packages

- name: Install RedHat/yum-based distro specific stuff...
  yum: >
    name=some-other-custom-dependency-on-redhat
    state=latest
  when: ansible_os_family == "RedHat"
  tags: packages

Homebrew:

---
- name: Tap homebrew foobar/foo
  homebrew_tap: >
    name=foobar/foo
    state=present

- homebrew: >
    name=foo
    state=latest

Note that this is awfully repetitive and not D.R.Y., and although some things might be different on the different platforms and will have to be handled, generally I think this is verbose and unwieldy when compared to Chef's:

package 'foo' do
  version node['foo']['version']
end

case node["platform"]
when "debian", "ubuntu"
  # do debian/ubuntu things
when "redhat", "centos", "fedora"
  # do redhat/centos/fedora things
end

And yes, there is the argument that some package names are different across distros. And although there is currently a lack of easily accessible data, I'd venture to guess that most popular package names are common across distros and could be installed via an abstracted package manager module. Special cases would need to be handled anyway, and would already require extra work making things less D.R.Y. If in doubt, check pkgs.org.

Solution 2:

You can abstract out package managers via facts

- name: Install packages
  with_items: package_list
  action: "{{ ansible_pkg_mgr }} state=installed name={{ item }}"

All you need is some logic that sets ansible_pkg_mgr to apt or yum etc.

Ansible are also working on doing what you want in a future module.

Solution 3:

From Ansible 2.0 there is the new Package-modul.

http://docs.ansible.com/ansible/package_module.html

You can then use it like your proposal:

- name: install the latest version of Apache
  package: name=httpd state=latest

You still have to consider name differences.

Solution 4:

Check out Ansible's documentation on Conditional Imports.

One task to ensure that apache is running even if the service names is different across each OS.

---
- hosts: all
  remote_user: root
  vars_files:
    - "vars/common.yml"
    - [ "vars/{{ ansible_os_family }}.yml", "vars/os_defaults.yml" ]
  tasks:
  - name: make sure apache is running
    service: name={{ apache }} state=running