The Ansible Automation Platform is a great tool for automation and configuration deployment. In General. That’s why I am also using it on my local clients to keep the configuration in sync and even do the most minuscule changes.
Very rarely I implement changes that do not need to be implemented right away or that will have to be reversed later.
The usual workflow for this is to wait until that moment, implement the change and roll it out. Or at least implement the change and schedule the Ansible run rather sooner than later.
This has some minor drawbacks: I either have to wait or to remember not to run Ansible when the code already has been updated, but is not supposed to be rolled out.
My particular requirement for this case was to remove a script and a cron-job for an advent calendar after the 24. of December. One might think that this also triggered the reason for this article being published in December, but this was purely coincidental. Back to the topic.
Out of the box Ansible does not offer a time-controlled and simple way of scheduling tasks other than capsuling them into separate playbooks and run them at the given time (with e.g. Ansible Tower). The idea is: There’s a change, deploy it now.
So my question was: How would I implement such a conditional trigger in a way, that a) is easy and simple to do and b) I would still understand in a couple of months what has been done.
When it gets that complex and too complex for plain Ansible syntax, Jinja templates usually can provide an answer. The complex structures for handling and manipulating data like that, quickly becomes ugly though.
This is a code example I just quickly drafted to prove my point:
---
- hosts: all
gather_facts: false
tasks:
- name: JINJA EXAMPLE
set_fact:
timeywimey: >-
{% set return_value = 0 -%}
{% set return_value = ( ("1970-01-01" | to_datetime('%Y-%m-%d')) - (ansible_date_time.date | to_datetime('%Y-%m-%d')) ).total_seconds() | int -%}
{% if return_value < 0 %}True{% else -%}False{% endif -%}
- debug:
msg: "Random output"
when: timeywimey
In this playbook the variable timeywimey
is set to either True
or False
, depending on the date calculation. If the provided date lies in the past from the execution point, then the return value is True
and the debug module will output Random output
.
In the back of my head there’s always the mantra: “If you find yourself programming in Ansible, you are doing it wrong.”. And I think there’s something to it.
After going crazy in Jinja, the next level is to write custom Filters for Ansible. I do this on occasion when I cannot solve a problem with Ansible in a satisfactory manner and this solution would certainly not please my future me.
Implementing filters is a rather simple matter in Ansible.
You need to create a file for your custom filter, extend the Ansible class FilterModule
, write the methods you require to handle the filter input and connect the method with the filter name you want to use.
The basic structure for a file with custom filter(s) looks like this:
# file: filter_plugins/example.py
class FilterModule:
def foo(self, parameter1, parameter2):
"""Custom method to handle the filter input."""
do_some_magic(parameter1)
do_some_magic(parameter2)
return True
def filters(self):
"""Ansible method for assigning the filter name to the method."""
return {
"new_filter": self.foo,
}
The custom filter new_filter
becomes available, when the file example.py
is Placed within the correct folder in the file-system for Ansible to detect and integrate. Using it in an Ansible task it then trivial.
---
# Example Ansible play
- name: EXAMPLE TASK
debug:
msg: "Random output"
when: "'parameter1, parameter2' | new_filter"
With that approach I have added a couple of custom filters to ensure tasks are only executed when a certain “timely” condition is met.
I will just pull out one example filter to show the approach. The complete code is available as GitHub Project.
class FilterModule:
def is_past(self, datestring):
"""Checks if a given datestring lies in the past."""
check_date = self.get_dates(datestring)["check_date"]
now_date = self.get_dates(datestring)["now_date"]
if check_date < now_date:
return True
return False
def filters(self):
return {
"is_past": self.is_past
}
Let’s just quickly skim through the code. I have numbered the lines to make it easier to follow:
- [3] I define a method
is_past
with a single parameterdatestring
. I kind of expect a date in the formatYYYY-mm--dd
here for the parameter. The purpose of the method is to compare two dates,datestring
and right-now and figure out if the date indatestring
lies in the past. - [5-6] Using a method called
get_dates()
I create two objects ofdatetime
and return them as fields in a dictionary. I do this in each line, once for each object. The first fieldcheck_date
contains thedatetime
object ofdatestring
, the second fieldnow_date
contains thedatetime
object of the current date. - [7] The if-condition compares these two
datetime
objects and returnsTrue
if the object incheck_date
is smaller thannow_date
; it therefore lies in the past. - [9] The last line then returns
False
if the if-condition hasn’t been met. - [11-14] These lines assign the method
self.is_past
to a filter with the same name. They are required by the Ansible Framework when adding custom filters.
With values instead of python variables, the method is_past
would look like this:
# executed on 2022-12-01
# otherwise rubbish python-syntax
def is_past(self, '1970-01-01'):
object:'1970-01-01' = self.get_dates('1970-01-01')["check_date"]
object:'2022-12-01' = self.get_dates('2022-12-01')["now_date"]
if object:'1970-01-01' < object:'2022-12-01':
return True
return False
Integrating this in Ansible the first of the following tasks is executed, while the second one is skipped.
---
- name: EXAMPLE TASK 01
debug:
msg: "This will be executed."
when: "'1970-01-01' | is_past "
- name: EXAMPLE TASK 02
debug:
msg: "This will never show."
when: "'2170-01-01' | is_past "
With the custom filter in place, the example from the beginning looks now like this:
---
- hosts: all
gather_facts: false
tasks:
- debug:
msg: "Random output"
when: "'1970-91-91' | is_past"
A filter like this creates a clear and concise condition. Even without knowing what the filter actually does, I have an idea about why a certain task is running or not.
Creating a custom filter is simple, but there are some pitfalls:
- You should choose a good name. My first idea was to use the
schedule
as filter name. While this is somehow explanatory in itself, I decided against it. Providing additional parameters to configure different time periods, etc. would have made the usage too complex. Instead I split up a single filter into multiple ones:is_past
,is_today
,is_future
,is_today_or_past
, … you get the idea. Check out the GitHub Project for more filters like that. - Do not over-optimise the code. I could easily reduce the method
is_past
from the example just down to three lines: the if-condition and the two return statements. Hell, probably one line would do. But I wanted to make sure I can quickly understand the code even next year. So I simply assign the dates first and then run the comparison. - The only filter I needed personally was
is_past
to solve my case this time. But I totally see the use-cases for the other filters as well, so I have added them right in. Exceptis_past_or_future
. That one is on the to-do list and I am sure I will come back to me at some point. Look at it as an invitation to contribute. - Simplicity * n did not turn out to be easy. Date-comparison in python is quite easy. Putting it in a Ansible filter is easy. Adding support for different timestamp formats is easy. All together became quite complex. That’s why I added tests as well. These cover many valid input cases and they made the developing and testing far easier than including the filter into an Ansible run. I do not remember how many times I discovered some issue using the tests, while I thought the code should work now. When I added it to Ansible in the end, it just worked.
The complete filter is available in the GitHub Project ansible_plugin_filters on GitHub. Feel free to use it.