Default variables in modular task files

I’d like to use task files and include_tasks for some lightweight reusable modules (I’m open to other module solutions, but want to avoid roles when my files are <50 lines). I’m trying to enable default values within the task files, while deferring to any outside values where possible.

Ansible’s behaviour seems to be to override task file variables with variables from the include_tasks, but not variables defined above:

playbook.yml

- name: var test
  hosts: all
  vars:
    var: playbook-toplvl-default
  tasks:
   - name: Call task file
     # setting vars here would override task-file.yml, but not above
     # e.g. vars: { var: would-override }
     include_tasks: task-file.yml

task-files.yml

- name: task-file task
  vars:
    var: task-file-internal-default
  ansible.builtin.debug:
    msg: 'Value: {{ var }}'

The example above prints Value: task-file-internal-default, where I would like it to print Value: playbook-toplvl-default instead. How best would I achieve my desired effect?

Two possible solutions I’ve come up with on my own:

  1. use yaml anchors to duplicate vars:
- name: var test
  hosts: all
  vars: &vars
    var: playbook-toplvl-default
  tasks:
   - name: Call task file
     vars: *vars
     include_tasks: task-file.yml
  • this requires an extra unusual step from the module user, increasing the chance of error every time the module is used. So not ideal
  1. use a different internal variable name, and explicit default() usage
- name: task-file task
  vars:
    _var: '{{ var | default("task-file-internal-default") }}'
  ansible.builtin.debug:
    msg: 'Value: {{ _var }}'
  • custom logic is self-contained and universal
  • makes the module a little more messy

I’m curious to hear if any more experienced users have other solutions, or if there are pros/cons I haven’t thought about yet.

You do not have to create a complete role, if you want to have reuse only for particular exising role. But if you want reuse in other roles you will have to create separate role. You will just need role tasks/main.yml file and defaults/main.yml for defaults and put your “private” role in ./roles folder near tasks file you import/include that role

This is exacly what defaults/main.yml in your “private” role will do.

Create your role in ./roles folder in ./roles//tasks folder and include it (or import it).
When you import / include the role from your public role tasks file, all the variable from “private” role defaults will be overwritten by any other variable defined at the moment of import.

- include_role:
  name: <your private role, no FQDN>

ansible will automatically look for role in ./roles in your public role tasks folder.

After further thought, I think allowing all upstream variables to override, like I had with solution 2., is more dangerous than the alternatives. If someone upstream sets the variable state to absent, that’s probably going to have a big impact on the behaviour of downstream modules without necessarily intending it. I should be keeping scope tight by requiring variables to be set next to the call.

With that in mind, this is the solution I’ve come up with:

playbook.yaml:

- name: include task test
  hosts: all
  vars:
    _required_var: required
    _var: toplvl-default
  tasks:
    - name: include task
      include_tasks: task-file.yml
      vars:
        # renaming variable required because `var: {{ var }}` causes an infinite loop
        var: '{{ _var }}'
        required_var: '{{ _required_var }}'

task-file.yaml:

- name: included task
  vars:
    required_var: null
    var: task-file-internal-default

  block:
    # might not be necessary if other tasks in this block do variable validation instead
    - name: variable validation
      assert:
        that:
          - required_var != None
        fail_msg: |-
          Missing required variables:
          required_var: {{ required_var | default('UNDEFINED', true) }}

    - name: print values
      ansible.builtin.debug:
        msg: 'Value: {{ var }} and {{ required_var }}'

By setting required_var: null, we ensure that if required_var is not explicitly set next to include_tasks, it doesn’t default to some other lower precedence value of required_var. That way we can keep the variable properly ‘required’.

A further improvement: use mandatory() to define required variables:

task-file.yaml:

- name: included task
  vars:
    required_var: '{{ mandatory() }}'
    var: task-file-internal-default

  block:
    - name: print values
      ansible.builtin.debug:
        msg: 'Value: {{ var }} and {{ required_var }}'

“Module” means something specific in Ansible already. I get what you mean, but terminology matters.

When I first started my “Ansible journey” I really chafed at the proliferation of files in even simple projects. In my head it seemed it would make sense to be able to put an entire project into one YAML file, only breaking out parts to other files when it reduced overall complexity. Sometimes I still fondly recall those days of such elegant projects that never existed outside my head.

But I got over it, and I suggest you do, too. Not to discourage you from exploring ideas like this. It can be incredibly instructive to push to the edges to understand how things can and do work. And if you’ve got something structured the way you like, then go for it.

But – my opinion – you’re muddying up your project to poorly re-implement a subset of roles when roles already provide the variable scoping you hope to achieve. If this is a “you-only” project, have fun. But if this is for something another experienced Ansible user will need to look at after a pager goes off at 3 AM, have pity on your colleague and just use roles.

I think there’s a small trap in your latest example with mandatory().

mandatory is a filter, so the documented syntax is {{ some_variable | mandatory }}. More importantly, it checks whether a variable is defined, not whether it was explicitly passed to include_tasks.

If you set required_var: null inside the included task, that variable is already defined. The mandatory filter won’t reject it just because the caller forgot to supply a value.

For the contract you’re trying to enforce, I’d actually keep your earlier null default and the explicit assertion that required_var is not none.

There is still a limitation, though: Ansible’s variable precedence doesn’t give the included task a completely isolated parameter scope. For example, an extra var passed with -e can still override task variables.

I understand not wanting a whole role for 50 lines of tasks. But if strict input validation and predictable scoping become important, that’s where a small role with argument specs starts to look less like overhead and more like a useful interface.

Assisted-by: LLM (drafting and documentation lookup)

+1 for that.