Ansible is a Lisp

More specifically, Ansible is homoiconic and has syntactic macros

ansible lisp macros

The Lisp family of languages is unique because code and data have the exact same form, and code is data, and data is code. This property is called homoiconicity.

Consider the following Racket program (from The Racket Guide):

(define (extract str)
  (substring str 4 7))

(extract "the cat out of the bag")

When evaluated by Racket, it will output “cat”. However, you can also think of it as a pure piece of data – a nested list of strings and numbers and other objects. If you aren’t familiar with Lisp syntax, here’s a JSON equivalent (though Racket has symbols and JSON doesn’t so we’ll turn symbols into strings):

[["define", ["extract", "str"],
  ["substring", "str", 4, 7]],

["extract", "the cat out of the bag"]]

However, Ansible, the IaC automation tool, is also homoiconic. It executes YAML files, and can treat YAML as data. Here’s an example playbook as a hello world, pulled from Ansible’s own introduction page:

- name: My first play
  hosts: myhosts
  tasks:
    - name: Ping my hosts
      ansible.builtin.ping:

    - name: Print message
      ansible.builtin.debug:
        msg: Hello world

Ansible also has syntactic JSON templating, as seen in this StackOverflow answer:

- copy:
    dest: kube-controller-manager-csr.json
    content: "{{ certificate | to_json }}"
  vars:
    certificate:
      CN: system:kube-controller-manager
      key:
        algo: rsa
        size: 2048
      names:
        - C: US
          L: Portland
          O: system:kube-controller-manager
          OU: Kubernetes The Hard Way
          ST: Oregon

This writes the following file:

{
  "CN": "system:kube-controller-manager",
  "key": { "algo": "rsa", "size": 2048 },
  "names": [
    {
      "C": "US",
      "L": "Portland",
      "O": "system:kube-controller-manager",
      "OU": "Kubernetes The Hard Way",
      "ST": "Oregon"
    }
  ]
}

Syntactic Macros

One of the killer features of Lisp is that it has syntactic macros. However, Ansible also has those. Consider the following playbook:

- name: ansible has syntactic macros ehe :3
  hosts: ::1
  connection: local
  tasks:
    - name: Run a shell command and register its output to foo_result
      ansible.builtin.shell: echo myfoo first
      register: foo_result

    - name: Syntactically generate some tasks
      copy:
        dest: intermediate.generated.yml
        content: "{{ tasks | to_json }}"
      vars:
        tasks:
          - name: Print something silly
            ansible.builtin.debug:
              msg: "foo_result was templated in as: {{ foo_result.stdout }}"

    - name: Set foo_result to something else to test scoping
      ansible.builtin.shell: echo myfoo second
      register: foo_result

    - name: Execute the generated tasks
      ansible.builtin.include_tasks: intermediate.generated.yml

Output:

$ ansible-playbook playbook.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that
the implicit localhost does not match 'all'

PLAY [ansible has syntactic macros ehe :3] *************************************

TASK [Gathering Facts] *********************************************************
ok: [::1]

TASK [Run a shell command and register its output to foo_result] ***************
changed: [::1]

TASK [Syntactically generate some tasks] ***************************************
ok: [::1]

TASK [Set foo_result to something else to test scoping] ************************
changed: [::1]

TASK [Execute the generated tasks] *********************************************
included: /home/astrid/Documents/lispible/intermediate.generated.yml for ::1

TASK [Print something silly] ***************************************************
ok: [::1] => {
    "msg": "foo_result was templated in as: myfoo first"
}

PLAY RECAP *********************************************************************
::1                        : ok=6    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

What’s going on here?

Here’s the part where we generate the syntax:

- name: Syntactically generate some tasks
  copy:
    dest: intermediate.generated.yml
    content: "{{ tasks | to_yaml }}"
  vars:
    tasks:
      - name: Print something silly
        ansible.builtin.debug:
          msg: "foo_result was templated in as: {{ foo_result.stdout }}"

Essentially, we are syntactically constructing a task inside the copy task, and writing it to a file called intermediate.generated.yml. Here’s what it looks like:

- ansible.builtin.debug: { msg: "foo_result was templated in as: myfoo first" }
  name: Print something silly

You’ll notice that we string-templated in the first evaluation of foo_result, so that’s the one captured here

Here’s where we execute the generated syntax:

- name: Execute the generated tasks
  ansible.builtin.include_tasks: intermediate.generated.yml

This is actually done when the task is evaluated, rather than when the whole program is loaded, so this will be able to find the generated YAML file.

Unfortunately, these macros are non-hygenic, so if we did something like this instead:

- name: ansible has syntactic macros ehe :3
  hosts: ::1
  connection: local
  tasks:
    - name: Run a shell command and register its output to foo_result
      ansible.builtin.shell: echo myfoo first
      register: foo_result

    - name: Syntactically generate some tasks
      copy:
        dest: intermediate.generated.yml
        content: "{{ tasks | to_yaml }}"
      vars:
        tasks:
          - name: Print something silly
            ansible.builtin.debug:
              msg:
                # Note this line here!
                "foo_result was templated in as: {{ '{{ foo_result.stdout }}' }}"

    - name: Set foo_result to something else to test scoping
      ansible.builtin.shell: echo myfoo second
      register: foo_result

    - name: Execute the generated tasks
      ansible.builtin.include_tasks: intermediate.generated.yml

we would get the following intermediate file:

- ansible.builtin.debug:
    { msg: "foo_result was templated in as: {{ foo_result.stdout }}" }
  name: Print something silly

which will capture the second evaluation of foo_result, as seen in this output:

> ansible-playbook playbook2.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does
not match 'all'

PLAY [ansible has syntactic macros ehe :3] *****************************************************************

TASK [Gathering Facts] *************************************************************************************
ok: [::1]

TASK [Run a shell command and register its output to foo_result] *******************************************
changed: [::1]

TASK [Syntactically generate some tasks] *******************************************************************
ok: [::1]

TASK [Set foo_result to something else to test scoping] ****************************************************
changed: [::1]

TASK [Execute the generated tasks] *************************************************************************
included: /home/astrid/Documents/lispible/intermediate.generated.yml for ::1

TASK [Print something silly] *******************************************************************************
ok: [::1] => {
    "msg": "foo_result was templated in as: myfoo second"
}

PLAY RECAP *************************************************************************************************
::1                        : ok=6    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Where are the parens, though?

“But wait,” you, the rhetorical Lisp purist, might ask. “Ansible doesn’t have all those funny parens that Lisp has! You can’t call it a Lisp!”

But it spiritually is a Lisp! In Ansible, code is literal YAML data, and literal YAML data is interpreted as code. This is because Ansible is what happens when you because you wanted to reduce the amount of code you write, so you write YAML and pretend it isn’t code, but then your YAML becomes so sufficiently complex that you accidentally horseshoe-theory yourself into Lisp again.

But fine. If you really object to the lack of parens, I can turn it into a “proper Lisp” for you.

I’m too lazy to write my own json-to-sexp-to-json converter so I’ll just use this random one I found on the internet.

$ yq < ../playbook.yml > playbook.json
$ python json_sexpr.py playbook.json -s
(list (dict "name" "ansible has syntactic macros ehe :3" "hosts" "::1" "connection" "local" "tasks" (list (dict "name" "Run a shell command and register its output to foo_result" "ansible.builtin.shell" "echo myfoo first" "register" "foo_result") (dict "name" "Syntactically generate some tasks" "copy" (dict "dest" "intermediate.generated.yml" "content" "{{ tasks | to_yaml }}") "vars" (dict "tasks" (list (dict "name" "Print something silly" "ansible.builtin.debug" (dict "msg" "foo_result was templated in as: {{ foo_result.stdout }}"))))) (dict "name" "Set foo_result to something else to test scoping" "ansible.builtin.shell" "echo myfoo second" "register" "foo_result") (dict "name" "Execute the generated tasks" "ansible.builtin.include_tasks" "intermediate.generated.yml"))))

Because I love you so much, rhetorical Lisp purist, I even formatted it for you! I’ve made Lispible!

(list
 (dict "name" "ansible has syntactic macros ehe :3"
       "hosts" "::1"
       "connection" "local"
       "tasks"
       (list (dict "name" "Run a shell command and register its output to foo_result"
                   "ansible.builtin.shell" "echo myfoo first"
                   "register" "foo_result")
             (dict "name" "Syntactically generate some tasks" "copy"
                   (dict "dest" "intermediate.generated.yml"
                         "content" "{{ tasks | to_yaml }}")
                   "vars" (dict
                           "tasks"
                           (list (dict
                                  "name" "Print something silly"
                                  "ansible.builtin.debug" (dict "msg" "foo_result was templated in as: {{ foo_result.stdout }}")))))
             (dict "name" "Set foo_result to something else to test scoping"
                   "ansible.builtin.shell" "echo myfoo second"
                   "register" "foo_result")

             (dict "name" "Execute the generated tasks"
                   "ansible.builtin.include_tasks" "intermediate.generated.yml"))))

So now, in order to execute, you just transpile it to YAML like this:

$ python json_sexpr.py playbook.sexp -j > playbook.yml
$ ansible-playbook playbook.yml

Please don’t do this in production.