Ansible: define playbook "default" vars with precedence on roles but overridable in inventory
Asked Answered
P

2

6

TL; DR

With Ansible I am trying to define some kind of default variable at playbook level - let's call it playbook defaults - which have precedence over roles defaults but can be overridden by the inventories inventory group_vars/all variables.

How to define some kind of playbook defaults variables which would have precedence over roles defaults but being overridable by inventories (environments) at the same time?


Currently Ansible 2.x variable precedence is such as:

  • role defaults
  • inventory file or script group vars
  • inventory group_vars/all
  • playbook group_vars/all

What I am looking to achieve is something like:

  • role defaults
  • playbook defaults
  • inventory file or script group vars
  • inventory group_vars/all
  • playbook group_vars/all

From most tools and apps I used before, variables defined at environment level (dev, test, QA, prod, etc.) have precedence over "application" variables, which themselves have precedence over "external" components. The precedence order would be then as following:

  • "External" components vars (Roles from Galaxy)
  • "Application" vars (Playbook vars)
  • "Environment" vars (dev, test, etc.)

With Environment having the highest precedence. But I cannot find a way to reproduce this pattern with Ansible. Such things are easy in Chef (with external cookbooks defaults being overridden by wrapper cookbook default) and Puppet, but I can't find a way to achieve the same with Ansible.

Example with a simple application

Let's consider an application consisting of several services interacting with a webserver. These services are generic and re-used in other parts of my organization, but in my case I used apache2. I have these roles:

  • apache
  • green_service
  • blue_service
  • red_service

And these default vars:

# roles/apache/defaults/main.yml
apache_listen_port: 80

# roles/green_service/defaults/main.yml
green_service_port: 80

# roles/blue_service/defaults/main.yml
blue_service_port: 80

# roles/red_service/defaults/main.yml
red_service_port: 80

Each service must know the apache port and this is represented in the default. If other applications need the green service with Nginx or another webserver, they just have to include the related roles.

Now, what if I want to change the port from 80 ot 8081 in one or more environments?

The "straightforward" way

.. I end-up having to duplicate all these variables in each environment such as:

# production/group_vars/all/all.yml
apache_listen_port: 8081
green_service_port: 8081
blue_service_port: 8081
red_service_port: 8081

# ... and so on in each environments inventory!

This may be fine in this simple example, but when you have 10+ services with 5+ environment, it becomes a nightmare... Updating a simple variable may have impacts in lots of services and it becomes hard to maintain and understand.

Aside from the answer "Your service design may be wrong..." I am looking for a way to avoid this.

What I would like to achieve

What I would like to achieve is being able to represent the variables links between my services and the webserver at playbook level such as:

# somewhere in my playbook .yml
# these variables override roles default
# but can be overriden in my inventories
myapp_port: 80

apache_listen_port: "{{ myapp_port }}"
green_service_port: "{{ myapp_port }}"
blue_service_port: "{{ myapp_port }}"
red_service_port: "{{ myapp_port }}"

Now in each environment I just have to override one and only one variable such as:

# production/group_vars/all/all.yml
myapp_port: 8081

I did not find a sensible way without over-complicating my playbooks to achieve something similar.

With Ansible, is there a proper way to accomplish that Playbook vars having precedence over Defaults vars but not Inventories vars?

Prana answered 14/3, 2018 at 15:44 Comment(0)
F
5

Keep in mind that in Ansible {{ ... }} expressions are usually templated in lazy manner (except set_fact), so you don't "assign" a variable, but tell Ansible how to get the value in runtime.

If you have control over roles:

you can make roles/apache/defaults/main.yml:

apache_listen_port: "{{ myapp_port | default(80) }}"

and roles/green_service/defaults/main.yml:

green_service_port: "{{ myapp_port | default(80) }}"

In this case when Ansible bumps into apache_listen_port in some expression, it will template the value to myapp_port's value (if it is available at that particular moment) or use 80 otherwise.

If you define myapp_port in required inventories, then apache_listen_port and green_service_port will get its value.

If you do NOT have control over roles:

you can make this trick inside playbook:

vars:
  myapp_port_playbook: "{{ myapp_port | default(80) }}"
  apache_listen_port: "{{ myapp_port_playbook }}"
  green_service_port: "{{ myapp_port_playbook }}"

in this case apache_listen_port and green_service_port are templated directly to myapp_port_playbook's value, but its value in turn is myapp_port's value or 80 by default.

Faith answered 14/3, 2018 at 16:24 Comment(3)
Thanks for your answer. In your "you do not have control" you advise to put these vars inside a tasks file?Prana
Not sure what you mean by "tasks file"... Somewhere in the playbook or in included vars_files.Faith
This variables are "global" to the play and not to the playbook. That's means, if a playbook has multiple plays variables declared for a play are not visible to others plays in the same playbookLeatheroid
N
0

There is another workaround for play-level defaults by using the set_fact module. This is handy if you do not want to introduce new variable names but instead use the names already used by the roles you include. Another advantage of using set_fact is that per documentation:

These variables will be available to subsequent plays during an ansible-playbook run via the host they were set on.

So when your playbook has multiple plays it is enough to use the set_fact task only in the first play. Or even add a separate play just for setting the defaults.

Define a set_fact task e.g. in pre_tasks (or tasks if you do not use play-level role includes):

- hosts: all
  vars:
    apache_listen_port: "{{ myapp_port }}"
    green_service_port: "{{ myapp_port }}"
  pre_tasks:
    - set_fact:
        myapp_port: "{{ myapp_port | default('80')}}"

If you like to manage your defaults under vars of a play you can use a dict variable and loop over it:

- hosts: all
  vars:
    apache_listen_port: "{{ myapp_port }}"
    green_service_port: "{{ myapp_port }}"
    defaults:
      myapp_port: 80
  pre_tasks:
    - set_fact: "{{item.key}}={{lookup('vars', item.key, default=item.value)}}"
      with_dict: "{{defaults}}"

With both variants it is now possible to directly set myapp_port in group_ or host_vars to change the value for the _port variables. But if myapp_port is not set the default value defined in the play is used.

But even with this workaround, keep in mind the variable precedence rules: set_fact wins over nearly everything but can be overruled by role/include params and extra vars.

PS: I like this method of setting play defaults because it allows to keep role defaults generic but ship defaults with a play as part of a collection. Imagine a company that wrote roles that are shared with the community (therefore no company internal values allowed within the role) but want to share company specific defaults (eg. URL to a internal service) as part of a collection internally only.

Niemeyer answered 19/9 at 7:59 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.