zncb.

code:dsp:infosec:sounds | ams:txl:nrt:yul

Sneaky Ansible Pitfalls

While writing some new roles for Ansible 2.0, I stumbled upon some oddities with the way the set_fact module deals with variables.

Let’s consider this simple playbook:

---
- hosts: node0
  vars:
    foo: ''
  tasks:
    - set_fact: foo='foo'
    - debug: var=foo

At first glance one would expect the foo variable to be set to the string value ‘foo’, but we get some strange result instead:

ok: [node0] => {
    "foo": "VARIABLE IS NOT DEFINED!"
}

Did ansible interpret the quoted foo as the foo variable and decided to unset the variable because it couldn’t deal with the recursion? In some other cases like setting a variable to itself in a vars: dictionary, an infinite loop error is thrown, not here.

Commenting out the set_fact task, we get the expected result:

ok: [node0] => {
    "foo": ""
}

Replacing ‘foo’ with ‘bar’ as a value in the set_fact task we get the following, which makes more sense:

ok: [node0] => {
    "foo": "bar"
}

But let’s say bar is also a variable, like this:

---
- hosts: node0
  vars:
    foo: ''
    bar: 'baz'
  tasks:
    - set_fact: foo='bar'
    - debug: var=foo

What should we expect here? ‘bar’ is not evaluated as a variable and the string value is used as is, so we get the same result as when bar was not defined.

When using double quotes, bar is expanded and the resulting value is ‘baz’, in the ‘foo’ case, we still get the same undefined message.

I couldn’t find a definitive statement anywhere in the documentation that would clarify the expansion rule for variables inside quoted statements. Are single quoted string supposed to be expanded or not? If so, is it now impossible to assign a variable it’s own name as a string?

I very much like Ansible, but this is a good example of how permissive syntax can shoot you right back in the foot. There are many ambiguities and tricky corner cases, sometimes due to functionalities delegated to some other piece of software involved (eg: yaml syntax limitations, reserved python keywords, jinja2 oddities, …).

In general, Ansible’s scoping model is perhaps too complex, with 16(!) levels of precedence and specificity, irregular definition mechanisms (eg: tasks can define vars, but not blocks? rly?), and subtle behavior oddities like the one highlighted here only amplifies that complexity. In simple cases this is not much of a problem, but when trying to create generic, truly reusable roles, it can cause many headaches, and in the long term can even be a thorn in Ansible’s side, as once this model gets adopted it will be hard to replace it with a clean design. This seems to be affecting pretty much any declaritive systems that start adding partial scripting capabilities. At first it’s just a bonus, then it becomes the reason you use it, and features are piled in to fill gaps as the need comes. At some point you have to wonder if it wouldn’t be a better idea to define a proper programming language from the ground up. An alternative starting point would be to use a language that is in itself it’s own input data, like Lisp or Scheme.

Here are a few other favorites:

Type mapping

Ansible gives you freedom of boole: yes,no,true,false,True,False! Not quite. Well, it depends…

---
- hosts: node0
  vars:
    foo: no
  tasks:
    - debug: msg='this is not the boolean you're looking for'
      when: foo == no

If we forget the fact that the when conditionals are actually jinja2 expressions (and the syntax helps us forget this so well), then we’d expect this playbook to work just fine, but no is undefined in jinja2, where booleans are true/false (although it now permits capitalized versions too).

Escaping strings

{{ }} can be used to expand a variable. What if you need a string with {{ }} in it?

---
- hosts: node0
  vars:
    foo: '{{ bar }}'
  tasks:
    - debug: var=foo

ok: [node0] => {
    "foo": "VARIABLE IS NOT DEFINED!"
}

Oups! As said above it seems that single quote statements are expanded (in some cases, not others, not by the same rules as double quoted string). The jinja2 syntax for escaping this is actually: "{% raw %}{{ bar }}{% endraw %}". Probably the most cumbersome escape syntax ever designed. (The liquid templates used in this blog too exhibit the same nonsense)

Oh, but wait, can I write this?

---
- hosts: node0
  vars:
    foo: "{% raw %}{% raw %}{{ bar }}{% endraw %}{% endraw %}"
  tasks:
    - debug: var=foo

fatal: [node0]: FAILED! => {"failed": true, "msg": "template error while templating string: Encountered unknown tag 'endraw'.. String: {% raw %}{% raw %}{{ bar }}{% endraw %}{% endraw %}"}

Of course that first endraw is in the way, but that’s going to make things complicated.

This post here explain how to escape the escape sequence. And we could avoid those unbalanced-looking braces by using this awful workaround:

{% assign oTag = '{%' %}
{% assign cP = '%' %}
{% assign cB = '}' %}
{{  oTag  }} raw {{ cP }}{{ cB }}{% raw %}{{ bar }}{% endraw %}{{  oTag  }} endraw {{ cP }}{{ cB }}

In all honesty, this is absolutely horrific. And you do not want to see the markdown for this bit. Another alternative in Liquid’s case is to use html entities, but that’s assuming your target is html at all.

Note that ‘%}’ had to be split into two variables because the parser captures the first ‘%}’ it sees no matter what context it encounters it in. Which causes the need to escape the escape sequence’s delimiter. This points to a rather inapropriate lexer being used, which perhaps was not intended for this type of work.

Blocks

The block documentation makes the following claim: Most of what you can apply to a single task can be applied at the block level. Apart from notable exceptions like one of the most desirable features for organizing your code, namely vars:, that statement holds up, but with a slight twist.

---
- hosts: node0
  vars:
    foo: true # let's use j2 compatible syntax :P
  tasks:
    - block:
      when: foo
      - debug: msg='not so fast mate...'

That should work right? Sadly, no, it doesn’t. Tasks can have when statements at the beginning, but not blocks. Which leads to some unfortunate situations:

---
- hosts: node0
  tasks:
    -block:
      - shell: /bin/do --some=stuff
        args:
          many='args'
          such='details'
          very='real world use case'
      - ...
      - ...
      - ...
      - ...
      - ...
      - ...
    when: very_lonely_down_here and not_quite_sure_im_aligned_right  

When reading a long list of tasks and blocks of tasks, the condition for which that item will play ought to be one of the first things on your mind.

No hard feelings here though, combining different tools together allows you to build impressive systems very quickly, albeit with some coherence collateral damage…