How to do simple vars manipulation?

I’m new to Ansible (like days). I’m working in 9.2/2.16.3 if that matters and can upgrade if that helps. I think I’m trying to do something simple but I can’t get it. I want to write a role that:

  1. Accepts a list of paths as one of its params (vars)
  2. Calls stat on each item in (1)
  3. Computes (pseudocode) “if stat.exists then stat.mtime else 0” for each item in (2)
  4. Computes the minimum value across (3)
  5. Stores the result of (4) in a role- or block-local variable for use by later tasks.

I have seen solutions involving set_fact (pollutes the global fact namespace), custom modules (this seems like overkill), and register (pollutes global variable namespace), but none of those seem right.

I’m a software engineer so I may be applying principles from true programming languages where they don’t belong but I’m sure that Jinja can do this so it seems like Ansible should be able to also.

What am I missing?

Thank you!

1 Like
  • set_fact polluting global fact namespace - While true, it’s irrelevant, especially if you aren’t caching facts. If you are caching facts, then pick a naming convention that makes this not a problem. (Pro’ly should do that anyway.)

  • custom modules seem like overkill - Also true, but custom filters and tests (which run on the controller, btw) and custom modules (which run on the target) allow you to bring the power of Python right into Ansible. Oddly, this can often be more readable/maintainable that a pipeline of Jinja filters, at least to people more comfortable with Python. And that’s okay.

  • register pollutes global variable namespace - Also also true, but again, naming conventions are your friend. It may seem “not right”, but for now it’s The Way™ to carry info from one task to another.

  • What am I missing? - Lazy evaluation. You can define a variable to be the expression you’ll want to evaluate, including other variables which don’t exist yet, and it won’t be evaluated until you use it. See the example below.

Here’s a solution to your problem, not as a role but as a playbook. You could make it a role if you want to. And it’s a little mind-bending at first because it sort of runs your pseudocode steps through a blender, but all the parts are still there. Strap in:

- name: Stat stuff
  hosts: localhost
  gather_facts: false
  vars:
    files_to_stat:
      - ./date-utc.yml    # This file exists.
      - ./date-utc2.yml   # This file exists also.
      - ./date-utc99.yml  # This file doesn't exist.
    min_mtime: >-
          {{ [{"mtime":0.0}]
             | product(stats.results
                       | map(attribute="stat"))
             | map("combine")
             | map(attribute="mtime")
             | sort
             | first }}
  tasks:
    - name: Get stat of files_to_stat
      ansible.builtin.stat:
        path: '{{ item }}'
      register: stats
      loop: '{{ files_to_stat }}'

    - name: Show the minimum mtime
      ansible.builtin.debug:
        msg: '{{ min_mtime }}'

The only thing not immediately obvious is what’s happening in the min_mtime expression. Let’s start with the parameter to the product() filter. It’s taking the results list from our not-yet-existing stats registered variable, then the map() filter is pulling from each of those their "stat" dict. So: product(“just the 'stat” dicts"). Notice that stat dicts for existing files will have an mtime, while non-existing files’ stat dicts will contain only exists:false.

Just before “| product(…” there’s a single-element list containing a Json dict that has only the "mtime":0.0 key-value pair. That’s the default mtime we want for stat dicts of files which don’t exist as per your original requirements – except it’s a float to be consistent with the value of mtimes of existing files.

The product filter takes that single element list, and produces the Carteasian product of it and the stat dicts from all the files. That is, it produces a list of lists, where each “inner list” contains two elements: that {"mtime":0.0} default dict as the first element, and one of the files of interest’s stat dict as the second element.

Next, the map() filter takes those inner list pairs in turn and runs the combine filter on each pair. That is, it takes that “default” mtime dict, then overlays onto it all the values from the corresponding stat dict. Existing files’ mtimes will replace the default, but non-existing files - which have no mtime - will retain the mtime default of 0.0. The result then is a list of stat dicts just like we had before, except now they all have an mtime key-value, including those for non-existent files.

Then another map pulls out just the mtime values. A sort puts them in low→high order, and first takes the first one, which is the lowest value, which I believe is what you were after to start with.

Please ask about anything that isn’t totally clear. And enjoy using Ansible!

1 Like

I would disagree that using set_fact or register is “polluting”. The whole point of using set_fact and register is so you can call on things later. So a couple of assumption here. I’ll assume you’re passing the list of paths as an extra var at run time. Here’s a simple example of what you’re trying to do. Keep in mind that something that is registered can be called upon later. The below example skips any files that don’t exist. With my registered variable, I can call upon things later as needed. Not sure if this helps, but hopefully you get the idea.

- hosts: localhost
  connection: local
  become: true
  vars:
    mypaths:
      - /etc/shadow
      - /etc/group
      - /etc/passswd
      - /etc/motd
      - /etc/foo
      - /etc/bar
  tasks:

    - name: stat paths
      ansible.builtin.stat:
        path: "{{ item }}"
      loop: "{{ mypaths }}"
      register: myresults

    - name: debug
      ansible.builtin.debug:
        msg:
          - "path: {{ item['stat']['path'] }}"
          - "mtime: {{ '%Y%m%d-%H%M%S' | strftime(item['stat']['mtime']) }}"
          - "mtime: {{ item['stat']['mtime'] }}"
      loop: "{{ myresults['results'] }}"
      when:
        - item['stat']['exists'] | bool

1 Like

I think you’re probably right. What I would suggest doing is, instead of chaining jinja2 operations, write a filter plugin.

You can register your stat module call and any time you need the computed result, just include an incantation that feeds the register into the filter plugin.

Agree with the other posters ITT that you shouldn’t worry about polluting namespaces in ansible w/ set_fact, that’s just kind of how it works in ansible, and it ends up being fine in practice most of the time.

If you must have some sort of local scope like that your only option (AFAIK) is a custom module, lookup plugin (you might store the result in a sqlite db or something, and then look it up there later), or writing your own vars plugin.