For the last few weeks I’ve been using Ansible quite extensively, and I realized for the first time that facts set in roles are not locally scoped. It so happens I like to name my variables as generically as the context permits, so instead of opting for prefixing all my role variables with company and role name, I tried something else.
I’ve documented a proof of concept on GitHub that aims to provide roles with a locally scoped fact named local, allowing one to create simply named facts without worry of them being overwritten by including roles.
I’ve been looking around for similar approaches, but did not find them, which leaves me to wonder whether I’m missing something about Ansible that makes the proposal unnecessary.
My question is basically whether the proposal is viable as a solution, or, alternatively, there are important reasons not to go down this path.
Edit: I realized that it might be very desirable to allow local scope without changing the way the role is consumed, so I added a set of roles that demonstrate this.
This doesn’t help directly, and also has the disadvantage that the caller needs to enable it (by setting the config, or using the new public parameter), but it might still be interesting - in case you haven’t already seen it: Ansible Configuration Settings — Ansible Community Documentation
I turned it into a collection so that it’s easier for people to try out; it’s available at the GitHub repo link in the original post.
It enables usage like the following:
- name: Set some locally scoped facts
pieterjanv.localscope.set:
updates:
some_key: some value
- name: Setup call to nested role
pieterjanv.localscope.set:
updates:
call_args:
name: some_one.some_collection.some_role
tasks_from: main
# Now call the role through the `call` role
- name: Include a nested role
ansible.builtin.include_role:
name: pieterjanv.localscope.call
- name: Use the locally scoped fact knowing it has not been overwritten
debug:
var: local.some_key
You have 2 main scopes in Ansible, one associated to the playbook objects, that works as you expect (mostly) and one associated to the host, set_fact sets variables in ‘host scope’ and is independent of the playbook objects. If you want to set variables to stay local to the role use vars:.
vars: take you a long way, but given they are visible to all included roles, they are not local in the sense that variables defined in a function in typical programming languages are. This is a hindrance to naming things generically, as there seems to be no way to distinguish between vars passed directly to your role, and vars by the same name that happened to be set higher up the inclusion chain, and the more generic your name, the likelier someone used it in a conflicting context.
undef() can help
I did just find out there is a cumbersome and error-prone way to work around this particular issue - the undef() function - that the caller can use to unset any settable vars if he wishes to not pass them. I actually found a use for it improve the api of my collection.
other vars limitations
I think I found a somewhat clean api to accomplish setting local facts that behave as I expect, but I don’t see a way to do this for vars:
I see no way to automatically undef() all vars; I can’t iterate over them.
Vars are evaluated lazily, so by the time they are evaluated, vars defined in terms of other vars may have values assigned to them in a conflicting context.
Only facts can be made local at the moment
I think the proposal you linked covers these issues, as you mention locally scoped vars and eager evaluation. I hope at least those parts can be integrated!
Until then, I see no other option for local definitions besides setting facts.
I think I found a way to do eager evaluation of role arguments. @bcoca, I would love your thoughts on the way I went about wrapping ansible.builtin.include_role as it feels quite uncomfortable, but I saw no other way:
When calling pieterjanv.localscope.include_role in a play, the plugin creates a new play using the Ansible Python api that involves some tasks before and after calling ansible.builtin.include_role, and executes it using a new TaskQueueManager.
It was the only way I could find of wrapping the built-in include_role. I find it unnerving as I’m unsure whether nested plays break Ansible.
The api has now changed to:
--- # Calling a role
- name: Include some role with eagerly evaled args and local facts
pieterjanv.localscope.include_role:
name: some_one.some_collection.some_role
input: # all members will be available as plain variables
some_var: some value
eagerly_evaluated: "{{ my_var }}"
# will default to a dictionary with only the `changed` and `failed` keys
register: some_role_output
--- # using local facts
- name: Set local fact
pieterjanv.localscope.set:
updates:
my:
key: value
recursive: yes
- name: Include a nested role
pieterjanv.localscope.include_role: "{{ ... }}"
- name: Use local fact
debug:
var: local.my.key # unchanged
- name: Return a value
pieterjanv.localscope.return:
key1: value1
key2: value2
Yes, while vars: declared on a playbook object are scooped ‘local’ to that object, there is no isolation to vars declared more globally. This is the first time I see the request to have a role ONLY receive specific/explicit variables passed in vs it having access to all the variables and overriding specific ones for the role, though my variables proposal could do that since all variables are ‘namespaced’ at that point, a ‘current role/tree’ namespace(s) could be added.
Your custom modules might work now, but they are injecting themselves into very complex code in Ansible’s core engine, I would expect many unplanned side effects. Also the code you interface with can change at any time in very incompatible ways, it has done so many times in the past and I know at least 2-3 planned changes in the future.
I think explicit role input reduces cognitive overhead to a minimum at the cost of having to pass everything in. I suppose it’s not unintuitive to expose non-local scope. I think most languages offer non-local scope as an option in the form of a closure.
But, supposing I have a role var and this variable is defined upon checking inside my role, I cannot know whether it was actually passed as an input, or just happens to have been set in some enclosing scope without intending it as a role var.
Features for more robust plugins
Indeed, I realize my module is not future proof, making use of variables that start with an underscore, which I take are not part of any public api, and using the nested play structure mentioned before.
I think the following features would allow for a lot of flexibility for plugins:
Public access to a Templar object to allow plugins to eagerly evaluate vars; I used ActionBase#_templar, which I take is private.
A public api for injection of task data at the point of execution. Something similar to Play#load() that looks up any internal dependencies from the task context would allow plugins to use the api as used in plays, which is unlikely to break unexpectedly. It could look something like:
A public api for registering output to a fact from within a module. After some digging I found setting Task#_register works, but this is far from obvious from the code.