Best way to build a configurable when statement ?

In order to get instances matching some criteria, I wrote the following tasks file:

---
# Required variables :
# - deployment_type: "vm" or "container"
# - cmp_operator: '<', '>', '<=', '>=' or '=='
# - cmp_days: number of days (positive or negative) from today to obtain the comparison date
#
# Output variable :
# - matching_instances

- name: Get comparaison date
  ansible.builtin.set_fact:
    __test_date: "{{ lookup('ansible.builtin.pipe', 'date -d \"' + cmp_days + ' days\" +%d-%m-%Y') }}"

- name: Call REST API to list all instances
  ansible.builtin.uri:
    url: "XXXXXXXXXXXXXXXXXXXXXXXX"
    method: GET
    status_code: 200
    body_format: json
  register: __all_instances

- name: Init return variable
  ansible.builtin.set_fact:
    matching_instances: []

- name: Built return variable
  ansible.builtin.set_fact:
    matching_instances: "{{ matching_instances + [item] }}"
  when: >
    item.{{ deployment_type }}_deployment is defined and
    item.{{ deployment_type }}_deployment.end_date is defined and
    (item.{{ deployment_type }}_deployment.end_date | to_datetime("%d-%m-%Y")) {{ cmp_operator }} (__test_date | to_datetime("%d-%m-%Y"))
  loop: "{{ __all_instances.json.results }}"
  loop_control:
    label: "{{ item.name }}"

It can be used the following way:

- name: Get expired VM instances
  ansible.builtin.include_tasks: get_instances_by_end_date_task.yml
  vars:
    deployment_type: "vm"
    cmp_operator: "<"
    cmp_days: "0"

or

- name: Get container instances that will end in 2 weeks
  ansible.builtin.include_tasks: get_instances_by_end_date_task.yml
  vars:
    deployment_type: "container"
    cmp_operator: "=="
    cmp_days: "+14"

I did some tests and it works: the matching_instances variable is filled with expected instances. But I got the following warning: [WARNING]: when statements should not include jinja2 templating delimiters such as {{ }} or {% %}. about the statement in the tasks file.

The way I used jinja in the when statement is not usual. So I don’t know whether I should take into account or ignore this warning (whose purpose seems to be to prevent another problem as in when statements should not include jinja2 templating delimiters). Is it risky to ignore it in my case?

More generally, what is the best way to build a configurable when statement?

Can you try something like this instead:

  when: >
    item[deployment_type % '_deployment'] is defined and
    item[deployment_type % '_deployment']['end_date'] is defined and
    (item[deployment_type % '_deployment']['end_date'] | to_datetime("%d-%m-%Y")) == (__test_date | to_datetime("%d-%m-%Y")) if cmp_operator == '==' else ... # <- you have to add all possible `cmp_operator` values here.

This is the only way I can think of that removes templating. Yeah, the operator part is quite ugly.

I’d probably create a user-defined test plugin to help reducing the ugliness.

2 Likes

Some things could be written differently, but TBH, cannot make the omellete without breaking the eggs. It still looks quite ugly:

# assuming you _are_ gathering facts
- name: Get comparison date
  ansible.builtin.set_fact:
    __test_date: "{{ ansible_date_time.date | to_datetime('%Y-%m-%d') }}"

- name: Call REST API to list all instances
  ansible.builtin.uri:
    url: "XXXXXXXXXXXXXXXXXXXXXXXX"
    method: GET
    status_code: 200
    body_format: json
  register: __all_instances

- name: Built return variable
  vars:
    _test_date_converted: "{{ __test_date | to_datetime("%d-%m-%Y") }}"
    _deployment_attr: "{{ deployment_type + "_deployment }}"
    _end_date_attr: "{{ _deployment_attr }}.end_date"
    _end_date_conv_attr: "{{ _deployment_attr }}.__end_date_conv"
    __items: >
      {{
        __all_instances.json.results 
        | selectattr(_end_date_attr, "defined")
        | map(
          "combine", 
          {_deployment_attr: 
            {"__end_date_conv": _item[_deployment_attr].end_date | to_datetime("%d-%m-%Y") }})
        | list
      }}
  ansible.builtin.set_fact:
    matching_instances: "{{ matching_instances | default([]) + [item] }}"
  loop: >
    {{ __items | selectattr(_end_date_conv_attr, cmp_operator, _test_date_converted) }}
  loop_control:
    label: "{{ item.name }}"

I have not tested, so if you want to use any of that, make sure to validate each intermediate step.

Caveats:

  • it modifies the data structure (adding the converted date)
  • the operators have to be “gt”, “le”, “eq”, etc instead of >, <=, ==, etc for selectattr() to use

UPDATE: I don’t think this code I pasted actually works. You might pick some ideas from it, but that map() statement is flawed.

This would be the best thing here. A complex when statement does not pass the 2am test and can be very hard to understand the logic. Moving this to Python code can make your playbooks more descriptive and silo out the more complex logic in a language better designed for it.

2 Likes

Thanks all for your advice.

I wrote a custom test plugin with the following date_compare function (inspired by version_compare from ansible/lib/ansible/plugins/test/core.py at devel · ansible/ansible · GitHub ):

import operator as py_operator

from ansible import errors
from ansible.module_utils.common.text.converters import to_native
from datetime import datetime


def date_compare(value, date, operator="eq"):
    """Perform a date comparison on a value"""
    op_map = {
        "==": "eq",
        "=": "eq",
        "eq": "eq",
        "<": "lt",
        "lt": "lt",
        "<=": "le",
        "le": "le",
        ">": "gt",
        "gt": "gt",
        ">=": "ge",
        "ge": "ge",
        "!=": "ne",
        "<>": "ne",
        "ne": "ne",
    }

    if not isinstance(value, datetime):
        raise errors.AnsibleFilterError("Input date value must be a datetime")

    if not isinstance(date, datetime):
        raise errors.AnsibleFilterError(
            "Date parameter to compare against must be a datetime"
        )

    if operator in op_map:
        operator = op_map[operator]
    else:
        raise errors.AnsibleFilterError(
            "Invalid operator type (%s). Must be one of %s"
            % (operator, ", ".join(map(repr, op_map)))
        )

    try:
        method = getattr(py_operator, operator)
        return method(value, date)
    except Exception as e:
        raise errors.AnsibleFilterError("Date comparison failed: %s" % to_native(e))


class TestModule(object):
    """Custom jinja2 tests"""

    def tests(self):
        return {"date_compare": date_compare}

And I modified the when statement like this:

    item[deployment_type + '_deployment'] is defined and
    item[deployment_type + '_deployment']['end_date'] is defined and
    (item[deployment_type + '_deployment']['end_date'] | to_datetime("%d-%m-%Y")) is date_compare(__test_date | to_datetime('%d-%m-%Y'), cmp_operator)
2 Likes

I would still recommend you use selectattr to reduce the clutter in your when clause. Something like:

- name: Built return variable
  ansible.builtin.set_fact:
    matching_instances: "{{ matching_instances + [item] }}"
  when: >
    (item[deployment_type + '_deployment']['end_date'] | to_datetime("%d-%m-%Y")) is date_compare(__test_date | to_datetime('%d-%m-%Y'), cmp_operator)
  loop: "{{ __all_instances.json.results | selectattr(deployment_type + '_deployment.end_date', 'defined' }}"
  loop_control:
    label: "{{ item.name }}"

And maybe put the converted __test_date in a var so you don’t have to convert it every single time (in the loop).

1 Like