Use ansible package module to work with apt and homebrew - macos

I have two problems with creating playbook that works on linux and on macOS.
I have many steps like this one in my playbooks:
- name: install something
package:
name: [something_1, something_2, ...]
state: present
become: yes
It works nice for apt and yum, but when I tried to run this on macOS, homebrew complains:
Running Homebrew as root is extremely dangerous and no longer supported.
I couldn't find elegant way of fixing that in many places. Copy all tasks and use when clause seems overwhelming to me. Probably I could use become_user variable set to root/local_user dependent on distribution, but that's a lot changes too.
Second problem is with head-only formula (homebrew packages that can be installed only with --head flag). What if something_2 need to be installed with this flag? Again I could copy task and change package module to homebrew but that's a lot boilerplate.
Any help?

If you want a single set of tasks that flexible enough for for multiple Linux
package managers and macOS brew, the choice is either more logic or more
duplication.
These three patterns should help. They still have repetition and
boilerplate code, but that's the territory we're in with Ansible plays
for cross-platform.
Declare become: yes (root) globally for Linux only
Address packages that need platform-specific treatment as-needed with when
This might be --head for brew, or setting up a PPA for apt, etc
Map package name discrepancies with variables
For example: brew install ncurses, apt install libncurses5-dev, and dnf install ncurses-devel are all the same library.
1) Declare become: yes (root) globally for Linux only
For Linux hosts, switching to root for installation is the intended behavior.
For macOS a la Homebrew, installing as root is not good. So, we need become: no (false) when using brew, and become: yes (true) otherwise (for
Linux).
In your example the become directive is nested inside each task ("step"). To
prevent duplication, invoke become at a higher lexical scope, before the tasks
start. The subsequent tasks will then inherit the state of become, which is
set based on a conditional expression.
Unfortunately a variable for become at the root playbook scope will be
undefined and throw an error before the first task is run:
# playbook.yml
- name: Demo
hosts: localhost
connection: local
# This works
become: True
# This doesn't - the variable is undefined
become: "{{ False if ansible_pkg_mgr == 'brew' else True }}"
# Nor does this - also undefined
become: "{{ False if ansible_os_family == 'Darwin' else True }}"
tasks:
# ...
To fix this, we can store the tasks in another file and import them, or
wrap the tasks in a block. Either of these patterns will provide
a chance to declare become with our custom variable value in time for the
tasks to pick it up:
# playbook.yml
---
- name: Demo
hosts: localhost
connection: local
vars:
# This variable gives us a boolean for deciding whether or not to become
# root. It cascades down to any subsequent tasks unless overwritten.
should_be_root: "{{ true if ansible_pkg_mgr != 'brew' else false }}"
# It could also be based on the OS type, but since brew is the main cause
# it's probably better this way.
# should_be_root: "{{ False if ansible_os_family == 'Darwin' else True }}"
tasks:
# Import the tasks from another file, which gives us a chance to pass along
# a `become` context with our variable:
- import_tasks: test_tasks.yml
become: "{{ should_be_root }}"
# Wrapping the tasks in a block will also work:
- block:
- name: ncurses is present
package:
name: [libncurses5-dev, libncursesw5-dev]
state: present
- name: cmatrix is present
package:
name: cmatrix
state: present
become: "{{ should_be_root }}"
Now there is a single logic check for brew and a single before directive
(depending on which task pattern above is used). All tasks will be executed as
the root user, unless the package manager in use is brew.
2) Address packages that need platform-specific treatment as-needed with when
The Package Module is a great convenience but it's quite limited. By
itself it only works for ideal scenarios; meaning, a package that doesn't
require any special treatment or flags from the underlying package manager. All
it can do is pass the literal string of the package to install, the state, and
an optional parameter to force use of a specific package manager executable.
Here's an example that installs wget with a nice short task and only becomes
verbose to handle ffmpeg's special case when installed with brew:
# playbook.yml
# ...
tasks:
# wget is the same among package managers, nothing to see here
- name: wget is present
when: ansible_pkg_mgr != 'brew'
package:
name: wget
state: present
# This will only run on hosts that do not use `brew`, like linux
- name: ffmpeg is present
when: ansible_pkg_mgr != 'brew'
package:
name: ffmpeg
state: present
# This will only run on hosts that use `brew`, i.e. macOS
- name: ffmpeg is present (brew)
when: ansible_pkg_mgr == 'brew'
homebrew:
name: ffmpeg
# head flag
state: head
# --with-chromaprint --with-fdk-aac --with-etc-etc
install_options: with-chromaprint, with-fdk-aac, with-etc-etc
The play above would produce this output for ffmpeg against a Linux box:
TASK [youtube-dl : ffmpeg is present] ******************************************
ok: [localhost]
TASK [youtube-dl : ffmpeg is present (brew)] ***********************************
skipping: [localhost]
3) Map package name discrepancies with variables
This isn't specifically part of your question but it's likely to come up next.
The Package Module docs also mention:
Package names also vary with package manager; this module will not "translate"
them per distro. For example libyaml-dev, libyaml-devel.
So, we're on our own to handle cases where the same software uses different
names between package manager platforms. This is quite common.
There are multiple patterns for this, such as:
Use separate variable files for each OS/distro and import them
conditionally
Use a role with its own variables
Use the same package manager across platforms, such as Homebrew or
Conda
Compile everything from source via git
None of them are very pleasant. Here is an approach using a role. Roles do
involve more boilerplate and directory juggling, but in exchange they provide
modularity and a local variable environment. When a set of tasks in a role
requires more finagling to get right, it doesn't end up polluting other task
sets.
# playbook.yml
---
- name: Demo
hosts: localhost
connection: local
roles:
- cmatrix
# roles/cmatrix/defaults/main.yml
---
ncurses:
default:
- ncurses
# Important: these keys need to exactly match the name of package managers for
# our logic to hold up
apt:
- libncurses5-dev
- libncursesw5-dev
brew:
- pkg-config
- ncurses
# roles/cmatrix/tasks/main.yml
---
- name: cmatix and its dependencies are present
become: "{{ should_be_root }}"
block:
- name: ncurses is present
package:
name: '{{ item }}'
state: latest
loop: "{{ ncurses[ansible_pkg_mgr] | default(ncurses['default']) }}"
- name: cmatrix is present
when: ansible_pkg_mgr != 'brew'
package:
name: cmatrix
state: present
The task for ncurses looks for an array of items to loop through keyed by the
corresponding package manager. If the package manager being used is not defined
in the variable object, a Jinja default filter is employed to reference the
default value we set.
With this pattern, adding support for another package manager or additional
dependencies simply involves updating the variable object:
# roles/cmatrix/defaults/main.yml
---
ncurses:
default:
- ncurses
apt:
- libncurses5-dev
- libncursesw5-dev
# add a new dependency for Debian
- imaginarycurses-dep
brew:
- pkg-config
- ncurses
# add support for Fedora
dnf:
- ncurses-devel
Combining everything into a real play
Here's a full example covering all three aspects. The playbook has two roles
that each use the correct become value based on a single variable. It also
incorporates an special cases for cmatrix and ffmpeg when installed with
brew, and handles alternate names for ncurses between package managers.
# playbook.yml
---
- name: Demo
hosts: localhost
connection: local
vars:
should_be_root: "{{ true if ansible_pkg_mgr != 'brew' else false }}"
roles:
- cmatrix
- youtube-dl
# roles/cmatrix/defaults/main.yml
ncurses:
default:
- ncurses
apt:
- libncurses5-dev
- libncursesw5-dev
brew:
- pkg-config
- ncurses
dnf:
- ncurses-devel
# roles/cmatrix/tasks/main.yml
---
- name: cmatrix and dependencies are present
# A var from above, in the playbook
become: "{{ should_be_root }}"
block:
- name: ncurses is present
package:
name: '{{ item }}'
state: latest
# Get an array of the correct package names to install from the map in our
# default variables file
loop: "{{ ncurses[ansible_pkg_mgr] | default(ncurses['default']) }}"
# Install as usual if this is not a brew system
- name: cmatrix is present
when: ansible_pkg_mgr != 'brew'
package:
name: cmatrix
state: present
# If it is a brew system, use this instead
- name: cmatrix is present (brew)
when: ansible_pkg_mgr == 'brew'
homebrew:
name: cmatrix
state: head
install_options: with-some-option
# roles/youtube-dl/tasks/main.yml
---
- name: youtube-dl and dependencies are present
become: "{{ should_be_root }}"
block:
- name: ffmpeg is present
when: ansible_pkg_mgr != 'brew'
package:
name: ffmpeg
state: latest
- name: ffmpeg is present (brew)
when: ansible_pkg_mgr == 'brew'
homebrew:
name: ffmpeg
state: head
install_options: with-chromaprint, with-fdk-aac, with-etc-etc
- name: atomicparsley is present
package:
name: atomicparsley
state: latest
- name: youtube-dl is present
package:
name: youtube-dl
state: latest
The result for Ubuntu:
$ ansible-playbook demo.yml
[WARNING]: provided hosts list is empty, only localhost is available. Note that
the implicit localhost does not match 'all'
PLAY [Demo] ********************************************************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [cmatrix : ncurses is present] ********************************************
ok: [localhost] => (item=libncurses5-dev)
ok: [localhost] => (item=libncursesw5-dev)
TASK [cmatrix : cmatrix is present] ********************************************
ok: [localhost]
TASK [cmatrix : cmatrix is present (brew)] *************************************
skipping: [localhost]
TASK [youtube-dl : ffmpeg is present] ******************************************
ok: [localhost]
TASK [youtube-dl : ffmpeg is present (brew)] ***********************************
skipping: [localhost]
TASK [youtube-dl : atomicparsley is present] ***********************************
ok: [localhost]
TASK [youtube-dl : youtube-dl is present] **************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=6 changed=0 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0

Related

How to get latest package version with Ansible

I'm trying to replicate the command yum check-update package_name preferably with the Ansible yum module.
It provides an information to what version package would be updated after yum update execution (or ansible equivalent). For example:
root#host: $ yum check-update kernel
[...]
kernel.x86_64 3.10.0-1160.80.1.el7
[...]
root#host: $
I tried various combination of
- name: Xyz
ansible.builtin.yum:
list: updates
update_cache: true
But I can't limit it to a single package or pattern (like java*).
What I ended up with is ugly and slow (because of the download) workaround:
- name: Check latest available xyz version
yum:
name: xyz
state: latest
download_only: true
become: true
register: _result
- name: Register xyz version
set_fact:
latestXyz: "{{ _result.changes.updated[0][1] | regex_search('xyz-(.+).x86_64.*', '\\1') }}"
Is there any better way to achieve this?
One pretty easy way to achieve this is to run the yum module as you would to update the package, but enforce a dry run on it, with the check_mode option, and register the result of this task.
Inspecting the result, you'll realise that the module will list you the current version of the package and the version it would have been updated to, would it not have been run in dry run.
Given, for example
- yum:
name: expat
state: latest
check_mode: true
register: yum_latest_versions
Will populate the variable yum_latest_versions on a fedora:35 container with:
yum_latest_versions:
ansible_facts:
pkg_mgr: dnf
changed: true
failed: false
msg: 'Check mode: No changes made, but would have if not in check mode'
rc: 0
results:
- 'Installed: expat-2.5.0-1.fc35.x86_64'
- 'Removed: expat-2.4.9-1.fc35.x86_64'
Then to extract, you can indeed use a regex to search in the result:
- debug:
msg: >-
{{
(
yum_latest_versions.results
| map('regex_search', '^Installed: expat\-(.*)\.x86_64$', '\1')
).0.0
}}
Finally yields:
msg: 2.5.0-1.fc37

Whats the best way in Ansible to check if a command is available?

I use the following block to detect if homebrew is already installed. In a new version, homebrew it is installed in the /opt/ folder and my “solution” is no longer working. What is a better way to check if a command is available?
- name: Check if homebrew is installed
stat:
path: "/usr/local/bin/brew"
register: "homebrew_check"
Considerations I know which and command -v but assumed there should be something in Ansible itself, am I wrong?
In respect to a
... way to check if a command is available
and which was
installed directly from a binary through a script and does not leave any traces in a package manager
it might also be feasible to check directly the version of it. To do so in example
---
- hosts: test.example.com
become: no
gather_facts: no
tasks:
- name: Gather installed Java version, if there is any
shell:
cmd: java -version 2>&1 | head -1 | cut -d '"' -f 2
register: result
check_mode: false
changed_when: false
failed_when: result.rc != 0 and result.rc != 127
- name: Set default version, if there is no
set_fact:
result:
stdout_lines: "0.0.0_000"
when: "'command not found' in result.stdout"
check_mode: false
- name: Report result
debug:
msg: "{{ result.stdout_lines }}"
check_mode: false
Based on the installed version an installer or updater could be called to install or update to the latest version if necessary.
Also, one could just check if the file exists somewhere via find_module.
Using which or command in a command task would be ok here IMO since homebrew is installed directly from a binary through a script and does not leave any traces in a package manager.
Meanwhile, since ansible has support for Homebrew, we can use the community.general.homebrew module to test if it is available.
Notes:
since I don't have Homebrew I tested my script only in that situation. Meanwhile you should get the expected result testing on a machine where it is available.
this method depends on the configured path to find Homebrew. Here I'm using a local connection to my local machine. When using a remote target, ansible will connect via ssh and use sh by default with a non-login shell (not loading any shell init files like login, .bashrc, ...). If your binary is installed outside the available path for the task, you'll get a false negative response.
Here is the idea, adapt to your needs.
The playbooks:
---
- name: Test homebrew presence
hosts: localhost
gather_facts: false
tasks:
- name: Check if homebrew is available
block:
- name: try using homebrew in check_mode (no changes)
homebrew:
update_homebrew: true
check_mode: true
- name: Homebrew available
debug:
msg: Homebrew is installed
rescue:
- name: No homebrew
debug:
msg: Homebrew is not installed
Gives (without homebrew):
PLAY [Test homebrew presence] ***********************************************
TASK [try using homebrew in check_mode (no changes)] ************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Failed to find required executable \"brew\" in paths: /usr/local/bin:/home/user/.local/bin:/home/user/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin"}
TASK [No homebrew] **********************************************************
ok: [localhost] => {
"msg": "Homebrew is not installed"
}
PLAY RECAP ******************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0

Ansible Test what a Variable Begins with

I need to be able to install a MySQL library. Python has 1 package for v2 and another for v3. I need to be able to tell Ansible which package to install.
- name: Ensure MySQL-python is installed
pip:
name: MySQL-python
state: present
become: true
when: python_version is regex("^2.*")
- name: Ensure mysqlclient is installed
pip:
name: mysqlclient
state: present
become: true
when: python_version is regex("^3.*")
The regular expression is valid but Ansible is skipping them both even though this:
- debug:
var: python_version
returns this:
TASK [debug] ****************************************************************************************************************************************************************
ok: [localhost] => {
"python_version": "2.7.10"
}
regex works for me with ansible 2.7.9. The example below
vars:
python_version: "2.7.10"
tasks:
- debug:
msg: Python version 2
when: python_version is regex('^2.*')
gives
"msg": "Python version 2"
Version Comparison is more convenient for complex tests. The example below gives the same result.
- debug:
msg: Python version 2
when:
- python_version is version('2', '>=')
- python_version is version('3', '<')
The test regex is documented in Ansible 2.8 for the first time. In earlier versions, only the tests search and match are documented. In the current source the tests search and match are implemented on top of regex
def match(value, pattern='', ignorecase=False, multiline=False):
return regex(value, pattern, ignorecase, multiline, 'match')
def search(value, pattern='', ignorecase=False, multiline=False):
 return regex(value, pattern, ignorecase, multiline, 'search')
Like Vladimir said.
Another improvement might be the source of python_version. When using gather_facts you can do it without regexp.
---
- hosts: localhost
gather_facts: True
tasks:
- name: do some stuff
debug:
msg: do something because it is python 3
when: ansible_facts.python.version.major is version('3', '=')
- name: do other stuff
debug:
msg: I don't support legacy software
when: ansible_facts.python.version.major is version('2', '=')

Ansible won't see a handler when using the group_by

I used to have simple playbook (something like this) which I run on all over my machines (RH & Debian based) to update them, and for each machine which was updated run a script (notify handler).
Recently I tried to test a new module called group_by, so instead using the when condition to run yum update when ansible_distribution == "CentOS", I will first gather the facts and group the host based on there ansible_pkg_mgr as key and then I was looking to run yum update on all the hosts which the key is PackageManager_yum , see the play book example:
---
- hosts: all
gather_facts: false
remote_user: root
tasks:
- name: Gathering facts
setup:
- name: Create a group of all hosts by operating system
group_by: key=PackageManager_{{ansible_pkg_mgr}}
- hosts: PackageManager_apt
gather_facts: false
tasks:
- name: Update DEB Family
apt:
upgrade=dist
autoremove=yes
install_recommends=no
update_cache=yes
when: ansible_os_family == "Debian"
register: update_status
notify: updateX
tags:
- deb
- apt_update
- update
- hosts: PackageManager_yum
gather_facts: false
tasks:
- name: Update RPM Family
yum: name=* state=latest
when: ansible_os_family == "RedHat"
register: update_status
notify: updateX
tags:
- rpm
- yum
- yum_update
handlers:
- name: updateX
command: /usr/local/bin/update
And this is the error message I get,
PLAY [all] ********************************************************************
TASK [Gathering facts] *********************************************************
Wednesday 21 December 2016 11:26:17 +0200 (0:00:00.031) 0:00:00.031 ****
....
TASK [Create a group of all hosts by operating system] *************************
Wednesday 21 December 2016 11:26:26 +0200 (0:00:01.443) 0:00:09.242 ****
TASK [Update DEB Family] *******************************************************
Wednesday 21 December 2016 11:26:26 +0200 (0:00:00.211) 0:00:09.454 ****
ERROR! The requested handler 'updateX' was not found in either the main handlers list nor in the listening handlers list
thanks in advance.
You defined handlers only in one of your plays. It's quite clear if you look at the indentation.
The play which you execute for PackageManager_apt does not have the handlers at all (it has no access to the updateX handler defined in a separate play), so Ansible complains.
If you don't want to duplicate the code, you can save the handler to a separate file (let's name it handlers.yml) and include in both plays with:
handlers:
- name: Include common handlers
include: handlers.yml
Note: there's a remark in Handlers: Running Operations On Change section regarding including handlers:
You cannot notify a handler that is defined inside of an include. As of Ansible 2.1, this does work, however the include must be static.
Finally, you should rather consider converting your playbook to a role.
A common method to achieve what you want is to include the tasks (in tasks/main.yml) using file names with the architecture in their names:
- include: "{{ architecture_specific_tasks_file }}"
with_first_found:
- "tasks-for-{{ ansible_distribution }}.yml"
- "tasks-for-{{ ansible_os_family }}.yml"
loop_control:
loop_var: architecture_specific_tasks_file
Handlers are then defined in handlers/main.yml.

Ansible apt-get install output

I am using vagrant with an ansible playbook to automatically install a bunch of programs on an ubuntu image. One program is failing to install on the vagrant VM. In the Vagrant file I have
config.vm.provision :ansible do |ansible|
ansible.verbose = "vvv"
ansible.playbook = "provisioning/playbook.yml"
end
but the verbose output does not include the apt-get output. My playbook.yml looks like
---
- hosts: all
sudo: true
tasks:
- name: get vi
apt: state=latest name=vim
How can I see the console output of an individual (or all) apt-get install's on the VM since ansible instead outputs each install in the format
TASK: [Install vim] ***********************************************************
failed: [default] => {"failed": true}
...
Reproducing the stdout of apt
Here is how to reproduce the stdout of apt…
---
- name: 'apt: update & upgrade'
apt:
update_cache: yes
cache_valid_time: 3600
upgrade: safe
register: apt
- debug: msg={{ apt.stdout.split('\n')[:-1] }}
…with nice line breaks, thanks to .split('\n'), and omitting the last empty string with [:-1], all of which is Python string manipulation, of course.
"msg": [
"Reading package lists...",
"Building dependency tree...",
"Reading state information...",
"Reading extended state information...",
"Initializing package states...",
"Building tag database...",
"No packages will be installed, upgraded, or removed.",
"0 packages upgraded, 0 newly installed, 0 to remove and 0 not upgraded.",
"Need to get 0 B of archives. After unpacking 0 B will be used.",
"Reading package lists...",
"Building dependency tree...",
"Reading state information...",
"Reading extended state information...",
"Initializing package states...",
"Building tag database..."
]
You can register to a variable the output of the apt module execution and then print it.
- hosts: localhost
sudo: true
tasks:
- name: get vi
apt: state=latest name=vim
register: aptout
# show the content of aptout var
- debug: var=aptout
You can use directly stdout_lines, after registering the output as also shown above, but you must make sure that apt did output something, for example, it installed something, otherwise that element is not defined. Here's a possible example:
- name: Install Gnome Packages
become: yes
apt:
update_cache: yes
state: latest
pkg:
- gnome-tweaks
- dconf-editor
- guake
register: aptout
- debug: msg="{{ aptout.stdout_lines }}"
when: aptout.stdout_lines is defined
In the version of ansible I'm using at the moment, ansible-playbook -v seems sufficient to get apt output. Admittedly I haven't tested failures. The output is in the form of JSON, which makes it a bit hard to read (as the other answer works around).
The Ansible version I tested was 2.3.2.0.

Resources