In the last couple of days I've typed away at some Ansible automation, and one specific group of tasks I dealt with was the creation of a target symlink and parent directories. I wasn't able to do this in one go, and thus arrived at the following result:
- name: "absolute home {{ config_path }}"
set_fact:
absolute_home_config_path: "{{ user_home_dir + '/.' + config_path }}"
- name: "{{ absolute_home_config_path|dirname }} present"
file:
dest: "{{ absolute_home_config_path|dirname }}"
state: directory
become_user: "{{ username }}"
- name: "{{ config_name }}"
file:
src: "{{ playbook_dir }}/files/home/{{ config_path }}"
dest: "{{ absolute_home_config_path }}"
state: link
force: true
mode: "{{ config_mode if config_mode is defined else '0600' }}"
The shell script translation for the task above is:
mkdir --parents ~/.config/parent
ln --force --symbolic files/home/config/parent/file ~/.config/parent/file
While I don't mind the fact that Ansible is verbose - a fine trade-off for the extra properties
it offers (such as idempotency) - when I run the playbook, the output feels rather cluttered for this
specific project. It would be nice if the ansible.builtin.file
module would be able to do this out of the
box, but as things stand I started looking for alternatives.
First I combed through the documentation looking at all the various callback plugin available. As unintuitive as it may seem, callback plugins are used as output filtering tools. The one that came closest to what I've been trying to do was the selective plugin. To take advantage of this plugin, I needed to go through all my tasks and tag the ones I would like to get printed with 'print_action' For this automation, this was the single circumstance in which I would have liked to ignore output, and it didn't make sense to go through my playbooks and tag every other task.
Writing a module that combines those two tasks into a single unit is what I was going to attempt next.
While I've been writing Ansible playbooks for some years at this point, going as far back as 2013, I never needed to step outside the confines of the built-in primitives. I know some Python, Ansible is well established, plentiful of examples out there, how hard could it be? Right? Right?
I was expecting to write something quick based on available code out there.
The official documentation has an introductory page on "Developing modules".
After copying the boilerplate example Python code, which I've named deepsymlink.py
, in a new ./library/
folder
within my project, I was able to invoke this new module via:
ANSIBLE_LIBRARY=./library ansible -m deepsymlink -a "name=hello new=true" localhost
Arguments passed in via the
-a
flag are those found in the example code. At this point the single difference from the example code was the filename.
"Great, now I just have to find the file module code", thinking that I can just copy/paste the code paths I'm interested in, effectively merging the symlinking procedure with the one that recursively creates directories.
The
state
parameter of the file module switches the module behavior. Withlink
as value it will create a symlink, and withdirectory
it will create the desired directory and all its parents along the way.
Incidentally on the same day - after seeing it praised by random users on HN - I've signed up for a Kagi search trial account. While I tend to use a mix of Google and DuckDuckGo, I wanted to see how it fares in comparison for development sessions. This sidequest will be a good opportunity for review.
The builtin file module is a 987 line Python script, with ~220 lines of documentation strings (used in the generation of official docs). It became clear from the start that the patterns used within diverge visibly from the boilerplate template code.
After about half an hour of going through some cycles of code deletion, imports juggling, and reruns I didn't feel like I was making progress. In part because there were other operations at play, with some level of indirection, that I wasn't wrapping my head around.
The answer to that questions is no!
Based on Konstantin Suvorov's StackOverflow answer, modules must be self-contained, as they run in isolation from one another on remote hosts. Couple things I didn't check at the time:
- are these module restrictions mentioned anywhere in the official documentation?
- if I'm running the tasks locally (
connection: local
), can I "cheat" and access the other modules anyway?
Starting from the example posted in the StackOverflow answer, I had the following code.
from ansible.plugins.action import ActionBase
class ActionModule(ActionBase):
def run(self, tmp=None, task_vars=None):
result = super(ActionModule, self).run(tmp, task_vars)
print(self._task.args.get('src'))
print(self._task.args.get('dest'))
pass
"This seems less boilerplate-y. I like it!". Although, when it came to testing, things didn't seem as straightforward. While skimming the documentation page, I didn't notice the fact that action plugins need to bear the name of a module which they "augment". This became clear when I looked over the bundled action plugins (I recognized module names in there).
Renamed my plugin to file.py
and tried to run the file module hoping to see the extra outputs:
ANSIBLE_LIBRARY=./library ansible -m file -a "src=main.yaml dest=/tmp/main.yaml state=link" localhost
localhost | FAILED! => {
"msg": "module (file) is missing interpreter line"
}
This error, based on reports I've seen on GitHub show up in a variety of
circumstances when there's some name overlap between various files, playbooks, etc. For me, like
another commenter, and which seems reasonable given the error message, was to add a Python
shebang line to my script (#!/usr/bin/env python3
). I'm not sure why this was necessary, as plugins
are written in Python and none of the bundled plugins seem to have a shebang line.
While the action now progressed further, it wasn't outputting anything:
localhost | FAILED! => {
"changed": false,
"module_stderr": "",
"module_stdout": "",
"msg": "MODULE FAILURE\nSee stdout/stderr for the exact error",
"rc": 0
}
Spent some time with extra verbosity flags, a Display
class, etc. But in the end progress stalled
here. Without any output or error message, I did not know how to proceed.
Took a break, and came back ready to reconsider my approach. Since Ansible modules can be written in any language, and bash is generally available on the systems I'm targeting with this automation…
First search result links to a well written guide on Ansible bash modules.
Had to take a refresher on how to use jq
to create objects without excessive string interpolation,
and wrote the following deepsymlink
script:
#!/usr/bin/env bash
# $1 - file that contains module arguments key=value pairs on separate lines
source "$1"
changed=0
alias json="jq --null-input '\$ARGS.named'"
if [ ! -f "$src" ]; then
json --arg msg "src=$src not found" --argjson failed 'true'
exit 1
fi
realsrc=$(realpath "$src")
parent=$(dirname "$dest")
if [ ! -d "$parent" ]; then
mkdir -p "$parent"
changed=1
fi
if [ ! -L "$dest" ]; then
ln -f -s "$realsrc" "$dest"
changed=1
else
target=$(readlink -f "$dest")
if [ "$realsrc" != "$target" ]; then
ln -f -s "$realsrc" "$dest"
changed=1
fi
fi
json --argjson changed "$changed"
Encountered a bash: json: command not found
error. Forgot that aliases aren't expanded by default when
shell scripts run in non-interactive environments. StackOverflow answer, I need
to set shopt -s expand_aliases
somewhere within my bash script before I use the json
alias.
ANSIBLE_LIBRARY=./library ansible -m deepsymlink -a 'src=main.yaml dest=/tmp/a/b/cx/y/z/.main.yaml' localhost
localhost | CHANGED => {
"changed": 1
}
ANSIBLE_LIBRARY=./library ansible -m deepsymlink -a 'src=main.yaml dest=/tmp/a/b/cx/y/z/.main.yaml' localhost
localhost | SUCCESS => {
"changed": 0
}
Good enough, and as a compromise I was fine with having jq
as a dependency on my system (or target systems)
for the shell script to reliably work. I'll find a better solution next time