Fallible 2025.12.5 released with Register Projections and new Action API previews

We’re happy to announce a new drop of the fallible experimental Ansible build, and are looking for your feedback! This one contains previews of a couple of features currently slated for release in Ansible Core 2.21:

  • Register Projections allows the register keyword to set multiple vars using the result of arbitrary Jinja expressions. It adds a new implicit variable (_task) for the register keyword and task conditionals (e.g., failed_when, changed_when, break_when, until) to directly access the most recent task/loop results from Jinja expressions, as well as access to all previously-executed loop results (not just the most recent). This feature allows for more concise playbooks by collapsing the common “run a task, set_fact to stuff the interesting bits of the result into new vars” pattern into a single task.
  • Action APIs for dynamic creation of hosts/groups and registration of top-level variables without ansible_facts (basically “write your own add_host, group_by, and set_fact”).

Huge props to @mattclay and @pkingstonxyz for the numerous hours spent with me solidifying the features to this point, to @sivel for bashing out the first couple iterations that we’d been handwaving about for so long, and to @mkrizek for the initial draft of the new Action API.

As with all fallible releases, this is completely unsupported, not a promise to ship anything in particular, might kick your dog, etc., but we’d love to hear your feedback on these features, since it’s still early enough to make changes. Some minimal examples and explanation can be found on the PR - a bit sparse at the moment, but hopefully enough to get the idea.

Get it from PyPI with pip install fallible if you want a side-by-side installation with existing Ansible Core (where the CLI tools are all s/ansible/fallible/, e.g. fallible-playbook ...), or pip install fallible[compat] if you want it to answer on the normal ansible-* CLI entrypoints.

Many of us are taking time off for the holidays, but feel free to discuss here or leave feedback on the PR if you’ve tried it and love it, hate it, it broke your world, or if you’ve got constructive feedback to share. We’ll follow up in January (if not before).

On behalf of the Ansible Core Team, thanks for being such an engaging community, and happy holidays!

8 Likes

To be honest I am not sure I like this additional option to register / set variables. I feel it is closer to an antipattern rather than a pattern. But of course it is not clear at the moment due to missing usage practice.
Let me explain. Let’s consider examples from Register projections and action plugin dynamic host/group/var API by nitzmahone · Pull Request #86241 · ansible/ansible · GitHub.

Registering multiple variables from a task

Before [1]:

- shell: echo hello
  failed_when: echo_result.stdout is contains "goodbye"
  register: echo_result

- set_fact:
    echo_duration: "{{ echo_result.delta }}"
    capitalized_result: "{{ echo_result.stdout | capitalize }}"

After [2]:

- shell: echo hello
  failed_when: _task.result.stdout is contains "goodbye"
  register:
    echo_result: _task.result  # The old register behavior
    echo_duration: _task.result.delta
    capitalized_result: _task.result.stdout | capitalize

Putting “pure” functions like “{{ echo_result.stdout | capitalize }}” and actions with side effects like “shell: echo hello” (one might argue about if this particular action has side effects, but this is just an simple example, in a broader case I assume actual action with side effect here) creates a problem for testing. In order to test that there are correct values in echo_duration and capitalized_result one has to run actions, because action and data manipulation are inseparable. Compare that for instance with this already existing approach of defining ansible role variables in vars/main.yml for for the role.

Using vars file [3]

# vars/mail.yml file
echo_duration: _task.result.delta
capitalized_result: _task.result.stdout | capitalize

You do not have to have managed host to event be present - you can load var file, set _task to what ever you want or expect and ensure that echo_duration and capitalized_result are set to expected values. So you can test this data transformation on each and every OS/ platform without even having this platform available. Imagine instead of echo hello there would be cat /etc/os-release and you want to transform data you got from this command to fit your needs. In listing [2] you have to have all the os versions available each and everytime you run tests, in listing [3] you only need data from cat /etc/os-release once so you can reuse it in any number of tests.
Also by registering new variables (like echo_duration and capitalized_result) you are polluting variables in your playbook / role execution - because registered variables are available in the “global” scope. Vars file variables are available only in the role there they are defined.

Similar argument applies to example ** Advanced templating and chaining variable manipulation**. I would like to add here comment to the following
"# Notice that start_hour was just defined and can be used! The order that they’re declared does matter. "
start_hour variable can be defined anywhere - ansible lazily evaluates variables - it can be defined in vars section in playbooks, defaults/main.yml file in the role, in the inventory. Literally in any place where you can define the variable for this code to work. Only caveat is that you need variable to be available in the scope you will be using them and take into account variable precedence (Using variables — Ansible Community Documentation).

Example ** Using failed_when without a register** I like, and not for the reason it is less to type, but because it allows not to pollute “global” variables scope with registers if they are only needed for failed_when, changed_when and similar single task attributes.

There is an interesting suggestion from @felixfontein in regards to ansible variables scope. More ways to set variables for a scope
Maybe some day there will be option to register and load variables only to the certain scope (host, host_group, block, task, role, etc) not to pollute “global” scope.

I really like the new features. As a plugin developer I was interested a lot in being able to set variables from plugins, and as a user I really like to avoid unnecessary temporary variables. I don’t think anyone will argue that having a public API (that’s also used by ansible-core plugins) for plugins to set variables is a bad idea, so I’ll concentrate on the register projections for the remainder of this post.

It simplifies a common pattern, and avoids polluting the global namespace with temporary variables. While I would prefer to combine this with scopes (like being able to say that the result of one projection is only valid for the remainder of the task file / block / role / …, instead of being globally available), this is already a lot better than status quo. I’ve seen a lot of places in roles (both collection integration tests and actual production roles and playbooks) that can be simplified with this, and I’m looking forward to be able to use this!

3 Likes

isn’t is possible already with action plugins?

No, you can only set facts (though with data tagging you can now set facts whose value is lazily evaluated), but not variables. (And facts are affected by the INJECT_FACTS_AS_VARS’s default value deprecation, see https://forum.ansible.com/t/44886.)

I was always certain that facts are variables with different precedence. Isn’t facts and registered variables are essentially one thing? They are mentioned in one line in variable precedence docs, so you can overwrite one with another.
“19. Registered vars and set_facts” ( Using variables — Ansible Community Documentation )

What is the subtle difference between facts and registered variables I do not see?

This is not what I was exactly referring to in my initial comment, I was referring to extended register logic for tasks not new public API inside AnsibleBase to set variables. If I understand correctly you are referring to following

class VariableLayer(enum.Enum):
    CACHEABLE_FACT = enum.auto()
    EPHEMERAL_FACT = enum.auto()
    INCLUDE_VARS = enum.auto()
    REGISTER_VARS = enum.auto()

# ...

    def register_host_variables(self, variables: dict[str, object], layer: VariableLayer = VariableLayer.REGISTER_VARS) -> None:
        """
        Register the given variables for the current host at the specified variable precedence layer.
        Calling this method more than once for the same layer will override any previous value for the currently executing action.
        """

As I understand this will allow module developers not only return module result (for it to be registered with old or new register functionality) and create another side effect for the controller - set variables.
And in my mind this is equivalent to following python code

b = f(a)
print(c)

a - module parameters
f - module
b - module registered variable
c - variable that is set explicitly by module (function f)
And from logical and language perspective it does not make any sense to me. I do not know any language where it is encouraged to set “global” state (variables) from within function (module).

How to avoid race condition where include_vars are set from one module running on several hosts and all the values that are set will be different - meaning value of variable set this way will depend on the order in which modules are executed on the hosts. include_vars are scoped globally - they are available even on another host.
How to handle loop situation where same (presumably) variable will be set by module several times for each loop iteration (so it will be overwritten).

Also 3 out of 4 variable precedence layers can be used explicitly already today: CACHEABLE_FACT, EPHEMERAL_FACT with set_facts, REGISTER_VARS with register keyword for tasks. I bet we already can even leverage include_vars right after the module call and include variable explicitly and get similar (if not the same result). For instance

- shell: echo hello
  register: echo_result
- include_vars:
  file: echo_result.yml

# cat vars/echo_result.yml
echo_result_include_var: "{{ echo_result }}" # using echo_result for variable here is not very useful, because include_vars has lower precedence than registered variables

And this is done explicitly (zen of python says that explicit is better than implicit, although it was not included into zen of Ansible).

I agree that is more verbose than in the PR, but on the other hand it is more explicit (better from my point of view). Also it becomes evident that include_vars might benefit from parameter to set variables directly (not read from the file, similar to set_facts). And set_facts might benefit from file parameter to make them uniform.

I was almost certain that I saw similar thought experiment somewhere. Initially I thought it was zen of Ansible, but it is different presentation https://youtu.be/JlrkizEBjXk?si=F_cKtRyGpBJoWPUN&t=1169.

There are some compelling arguments to not having _task (they call it res in presentation)

The difference is that they are completely different things. We were talking about what plugins can do, which currently is only to create facts (11 on the list.) set_facts is a built-in action that does not create facts, it creates variables (except when cacheable is true and it creates both a fact and a variable.) As stated in the PR description, this ability to create variables relies on special handling by the engine that other action plugins do not receive.

Note that injecting facts (untrusted data returned from remote module execution) into the top-level variable namespace is a misfeature that has hung around for backwards compatibility reasons. There’s been a switch that turns it off for ages, and starting in 2.20 people are warned if they access injected facts without explicitly requesting that the injection be done.

There are many cases (like community.sops.load_vars) where it’s useful for controller-side plugins (local execution of trusted code) to create variables, and in the old injected-facts world they were usually able to get close enough to the desired functionality by creating facts. In order to feasibly get rid of fact injection entirely, an alternative needs to be provided.

1 Like

When I started answersing some of the questions I didn’t yet see @flowerysong’s answer which provides most of these answers already, so I’ve removed them from this post. I’ll just keep the extra things :slight_smile:

Some background on why the action is called ansible.builtin.set_fact and not ansible.builtin.set_var etc. can be found here: INJECT_FACTS_AS_VARS is defaulting to False in 2.24 - #5 by bcoca

In my first post I was not replying to your post, but to this thread, which is about both new features.

Just to make this clear: modules cannot use this API. The only plugins which can use this API are action plugins (that always run on the controller). These can run modules and upgrade their return values to variables, if someone desires so and thinks it is a good idea - which usually it is not, for security reasons.

If anyone is interested in how/where that happens: ansible/lib/ansible/plugins/strategy/__init__.py at e25cc345feda25dc8a05e25b06c09c1c426d011b · ansible/ansible · GitHub

I don’t have much to add, but I really like the register projections – thank you for the work there!

1 Like