12.6 Tracking State in a Custom Module

In this lab, you will extend the schroedinger_cat example module to track the cat’s state on every host by writing it to a file in /tmp/.cat_state.txt. You will also add support for check mode and ensure that the module always provides a diff when run with --diff.

Task 1 - Write Cat State to a file

Modify the module so that it writes the current cat state (“alive” or “dead”) to /tmp/.cat_state whenever it runs (we will handle the check mode later).

Verify this behaviour in the integration tests from the previous lab.

Solution Task 1

Let’s have a look at one possible solution. We can start by having a look at the run_module function and see how the new program flow could look like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from typing import Optional


def run_module() -> None:
    module_args_spec: dict = dict(
        force_box_open=dict(
            type="bool",
            default=False,
            required=False,
        )
    )

    module = AnsibleModule(argument_spec=module_args_spec)
    force_box_open: bool = module.params["force_box_open"]

    if not force_box_open:
        # If the box is not opened, the cat is in a superposition state
        module.exit_json(cat_state="dead and alive")

    previous_cat_state: Optional[str] = _get_previous_cat_state()
    new_cat_state: str = random.choice(["alive", "dead"])
    changed: bool = False

    if previous_cat_state != new_cat_state or previous_cat_state is None:
        changed = True
        _write_cat_state(new_cat_state)

    module.exit_json(cat_state=new_cat_state, changed=changed)

Now given this top level function, let’s have a look at how the _write_cat_state and _get_previous_cat_state function could look like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from typing import Optional


CAT_STATE_FILE_PATH: str = "/tmp/.cat_state.txt"


def _get_previous_cat_state() -> Optional[str]:
    """
    Get the previous cat state from the file.
    """
    try:
        with open(CAT_STATE_FILE_PATH, "r") as f:
            return f.read().strip()
    except (IOError, FileNotFoundError):
        return None


def _write_cat_state(cat_state: str) -> None:
    """
    Write the cat state to the file.
    """
    with open(CAT_STATE_FILE_PATH, "w") as f:
        f.write(cat_state)

Your integration tests could look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
---

- name: Test schroedingers_cat without force open box
  block:
    - name: Test schroedingers_cat without force open box
      training.labs.schroedingers_cat:
      register: no_force_result

    - name: Assert that the cat state is 'dead and alive' and not changed
      ansible.builtin.assert:
        that:
          - no_force_result.cat_state == 'dead and alive'
          - not no_force_result.changed

    - name: Get cat state file stats
      ansible.builtin.stat:
        path: "/tmp/.cat_state.txt"
      register: file_result

    - name: Assert that the file has not been created since the box has not been opened
      ansible.builtin.assert:
        that:
          - not file_result.stat.exists

- name: Test schroedingers_cat with force open box
  block:
    - name: Test schroedingers_cat with force open box
      training.labs.schroedingers_cat:
        force_box_open: true
      register: force_result

    - name: Assert that the cat state is 'dead' or 'alive'
      ansible.builtin.assert:
        that:
          - force_result.cat_state in ['dead', 'alive']

    - name: Get cat state file stats
      ansible.builtin.stat:
        path: "/tmp/.cat_state.txt"
      register: file_result

    - name: Assert that the file has been created since the box has been opened
      ansible.builtin.assert:
        that:
          - file_result.stat.exists

Task 2 - Implement a molecule cleanup playbook

Running the previous integration tests will create a file in the user’s home directory. So when re-running the tests, they will fail. Let’s write a molecule cleanup playbook to remove it.

Can you figure out how to add a playbook in the extensions/molecule/ directory that removes the file before and after each run?

Solution Task 2

For instance, you could create a playbook in the extensions/molecule/integration_schroedingers_cat scenario called cleanup_cat_state.yml. The playbook could look like this:

1
2
3
4
5
---
- name: Remove cat state file
  file:
    path: "/tmp/.cat_state.txt"
    state: absent

Now configure it in the molecule.yml file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
---
...
provisioner:
  ...
  playbooks:
    cleanup: ./cleanup_cat_state.yml
    converge: ../utils/playbooks/converge.yml
    destroy: ./cleanup_cat_state.yml
    prepare: ./cleanup_cat_state.yml
...

Now re-run the integration tests and make sure they pass.

Task 3 - Implement Check Mode Support

The next feature we want to support in our module is the ansible check mode.

Can you figure out how to implement it in our existing module?

Don’t forget to test it!

Solution Task 3

To support the check mode in our module we need to enable it when instantiating the module:

1
2
3
    ...
    module = AnsibleModule(argument_spec=module_args_spec, supports_check_mode=True)
    ...

Next we need to prevent creating a file or writing to it in check mode. One solution could be in the run_module function to not call the _write_cat_state function in check mode:

1
2
3
4
5
6
    ...
    if previous_cat_state != new_cat_state or previous_cat_state is None:
        changed = True
        if not module.check_mode:
            _write_cat_state(new_cat_state)
    ...

Now to test it we can add a new block to the integration tests. To ensure the file is not created by any other block, go ahead and add the following block before the Test schroedingers_cat with force open box block:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

- name: Test schroedingers_cat with force open box in check mode
  block:
    - name: Test schroedingers_cat with force open box
      training.labs.schroedingers_cat:
        force_box_open: true
      check_mode: true
      register: force_result

    - name: Assert that the cat state is 'dead' or 'alive'
      ansible.builtin.assert:
        that:
          - force_result.cat_state in ['dead', 'alive']
  
    - name: Get cat state file stats
      ansible.builtin.stat:
        path: "/tmp/.cat_state.txt"
      register: file_result

    - name: Assert that the file has not been created since it ran in check mode
      ansible.builtin.assert:
        that:
          - not file_result.stat.exists

Task 4 - Always Return a Diff Value

Ensure the module always returns a diff value in the result, showing the previous and new state whenever a change occurs. This enables --diff mode support.

To do so we need to add a diff key to the result, containing a dictionary with a before and after key.

Go ahead, implement and test it!

Solution Task 3

We could implement it like this in the run_module function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    ...
    changed: bool = False
    diff: dict = {}
    
    if previous_cat_state != new_cat_state or previous_cat_state is None:
        changed = True
        diff = {
            "before": previous_cat_state ,
            "after": new_cat_state,
        }
        if not module.check_mode:
            _write_cat_state(new_cat_state)

    module.exit_json(cat_state=new_cat_state, changed=changed, diff=diff)

...

Test it by adding a few checks to the assertions in the integration tests. For example, we expect the diff to be present when the state of the cat is changed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

- name: Test schroedingers_cat with force open box
  block:
   ...
    
    - name: Assert a diff exists when the cat state is changed
      ansible.builtin.assert:
        that:
          - force_result.diff
      when: force_result.changed

All done?

  • Can you make the state file path configurable via a module parameter?