Custom Ansible Modules with Python Class

In every serious Network Automation project, we hit the stage where neither built-in Ansible modules nor Ansible Galaxy modules are sufficient to fit our custom requirements. Therefore, we turn to writing our own custom Ansible modules. Thanks to Ansible’s flexible design, this is accomplished very easily by means of Python programming. For many custom Ansible modules, having functional Python code will do the work. In some cases, especially within the context of Network Automation, understanding, maintaining and navigating through functional Python code simply becomes too much of a hassle. In this tutorial, we will demonstrate how to achieve the same module capability with functional style and class (OOP) style coding.

Let’s imagine we are tasked to create a straightforward module which generates Junos CLI from a given data model. We are focused solely on address and address groups. For address name ADDRESS_SRC1 and address subnet 10.10.10.0/24 the following set command should be generated: set security address-book global address ADDRESS_SRC1 10.10.10.0/24. Provided that this address object should be associated with address group named AG_SRC the module will generate the following config line: set security address-book global address-set AG_SRC address ADDRESS_SRC1.

Our data module should look like this:

main.yml

- hosts: localhost
  gather_facts: no

  vars:
    addresses_list_of_dicts:
      - name: ADDRESS_SRC1
        subnet: 10.10.10.0/24
      - name: ADDRESS_SRC2
        subnet: 11.11.11.0/24
      - name: ADDRESS_DST1
        subnet: 200.200.200.0/24
      - name: ADDRESS_DST2
        subnet: 222.222.222.0/24
    address_groups_list_of_dicts:
      - name: AG_SRC
        addresses: 
          - ADDRESS_SRC1
          - ADDRESS_SRC2
      - name: AG_DST
        addresses: 
          - ADDRESS_DST1
          - ADDRESS_DST2

Each item under <strong>addresses_list_of_dicts</strong> should translate into a new address object. Similarly, for each item under <strong>address_groups_list_of_dicts</strong> an address group object is to be created.

We want to name our custom Ansible module create_junos_config1 which means there must be a file create_junos_config1.py in the <strong>library</strong> folder. We want to pass the input data like this:

main.yml

- hosts: localhost
  gather_facts: no

  vars:
    addresses_list_of_dicts:
      - name: ADDRESS_SRC1
        subnet: 10.10.10.0/24
      - name: ADDRESS_SRC2
        subnet: 11.11.11.0/24
      - name: ADDRESS_DST1
        subnet: 200.200.200.0/24
      - name: ADDRESS_DST2
        subnet: 222.222.222.0/24
    address_groups_list_of_dicts:
      - name: AG_SRC
        addresses: 
          - ADDRESS_SRC1
          - ADDRESS_SRC2
      - name: AG_DST
        addresses: 
          - ADDRESS_DST1
          - ADDRESS_DST2

  tasks:
    - name: Generate Junos CLI commands
      create_junos_config1:
        addresses: "{{ addresses_list_of_dicts }}"
        address_groups: "{{ address_groups_list_of_dicts }}"
      register: junos_config1

    - name: Print Junos CLI commands
      debug:
        msg: "{{ junos_config1 }}"

The Python code of the module is as following:

create_junos_config1.py

#!/usr/bin/python

from ansible.module_utils.basic import AnsibleModule

def main():
    # Define input params
    inputparams = {
        "addresses": {"required": True, "type": "list"},
        "address_groups": {"required": True, "type": "list"},
    }

    # Read input params
    module = AnsibleModule(argument_spec=inputparams)

    addresses = module.params["addresses"]
    address_groups = module.params["address_groups"]

    # Outputs
    config = []

    # Business logic
    for addr in addresses:
        cmd = (
            f"set security address-book global address {addr['name']}"
            f" {addr['subnet']}"
        )
        config.append(cmd)

    for ag in address_groups:
        for addr in ag["addresses"]:
            cmd = (
                f"set security address-book global address-set {ag['name']}"
                f" address {addr}"
            )
            config.append(cmd)

    # Exit module, back to playbook
    module.exit_json(
        changed=False,
        config=config,
    )

if __name__ == "__main__":
    main()

There are some well known sections to it:

  • necessary tooling <strong>AnsibleModule</strong> is imported
  • section with inputs, as it should be laid out in the Ansible playbook, is defined
  • section for reading the passed params in Python code from Ansible playbook (<strong>addresses</strong> and address_groups)
  • business logic, which is actually doing what we want the module to do (generation of set commands)
  • exiting (Python) module and returning results back to Ansible playbook

Running the main.yml playbook results in:

run

(ansible) devuser@vmi520322:~/work/075_ansible_modules_with_classes$ ansible-playbook main.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 [localhost] ****************

TASK [Generate Junos CLI commands] ****************
ok: [localhost]

TASK [Print Junos CLI commands] ****************
ok: [localhost] => {
    "msg": {
        "changed": false,
        "config": [
            "set security address-book global address ADDRESS_SRC1 10.10.10.0/24",
            "set security address-book global address ADDRESS_SRC2 11.11.11.0/24",
            "set security address-book global address ADDRESS_DST1 200.200.200.0/24",
            "set security address-book global address ADDRESS_DST2 222.222.222.0/24",
            "set security address-book global address-set AG_SRC address ADDRESS_SRC1",
            "set security address-book global address-set AG_SRC address ADDRESS_SRC2",
            "set security address-book global address-set AG_DST address ADDRESS_DST1",
            "set security address-book global address-set AG_DST address ADDRESS_DST2"
        ],
        "failed": false
    }
}

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

The results look good. The list of returned command can be loaded into Juniper device with either juniper.device.config or junipernetworks.junos.junos_config Ansible modules.

Now, let’s create create_junos_config2 custom Ansible module, which will keep exactly the same functionality but the code style will be different. We will be using Python class.

create_junos_config2.py

#!/usr/bin/python

from ansible.module_utils.basic import AnsibleModule

class JunosConfig:
    def __init__(self, module, addresses, address_groups):
        # inputs
        self.module = module
        self.addresses = addresses
        self.address_groups = address_groups

        # outputs
        self.config = []

    def _generate_address_config(self):
        for addr in self.addresses:
            cmd = (
                f"set security address-book global address {addr['name']}"
                f" {addr['subnet']}"
            )
            self.config.append(cmd)

    def _generate_group_config(self):
        for ag in self.address_groups:
            for addr in ag["addresses"]:
                cmd = (
                    f"set security address-book global address-set {ag['name']}"
                    f" address {addr}"
                )
                self.config.append(cmd)

    def generate_config(self):
        # Business logic
        self._generate_address_config()
        self._generate_group_config()

        # Exit module, back to playbook
        self.module.exit_json(
            changed=False,
            config=self.config,
        )

def main():
    # Define input params
    inputparams = {
        "addresses": {"required": True, "type": "list"},
        "address_groups": {"required": True, "type": "list"},
    }

    module = AnsibleModule(argument_spec=inputparams)

    juniper_config = JunosConfig(
        addresses=module.params["addresses"],
        address_groups=module.params["address_groups"],
        module=module,
    )

    juniper_config.generate_config()

if __name__ == "__main__":
    main()

There are a couple of important things to note here:

  • Business logic is contained in JunosConfig class.
  • The only purpose of main() is to instantiate the class and invoke the generate_config method.
  • We must pass <strong>module</strong> to class instance too as we handle module exit from the class.
  • We conveniently break the business logic of the class into smaller chunks by leveraging two helper methods (_generate_address_config and _generate_group_config).

The full code for main.yml now looks like:

main.yml

- hosts: localhost
  gather_facts: no

  vars:
    addresses_list_of_dicts:
      - name: ADDRESS_SRC1
        subnet: 10.10.10.0/24
      - name: ADDRESS_SRC2
        subnet: 11.11.11.0/24
      - name: ADDRESS_DST1
        subnet: 200.200.200.0/24
      - name: ADDRESS_DST2
        subnet: 222.222.222.0/24
    address_groups_list_of_dicts:
      - name: AG_SRC
        addresses: 
          - ADDRESS_SRC1
          - ADDRESS_SRC2
      - name: AG_DST
        addresses: 
          - ADDRESS_DST1
          - ADDRESS_DST2

  tasks:
    - name: Generate Junos CLI commands
      create_junos_config1:
        addresses: "{{ addresses_list_of_dicts }}"
        address_groups: "{{ address_groups_list_of_dicts }}"
      register: junos_config1

    - name: Print Junos CLI commands
      debug:
        msg: "{{ junos_config1 }}"

    - name: Generate Junos CLI commands
      create_junos_config2:
        addresses: "{{ addresses_list_of_dicts }}"
        address_groups: "{{ address_groups_list_of_dicts }}"
      register: junos_config2

    - name: Print Junos CLI commands
      debug:
        msg: "{{ junos_config2 }}"

When we now run it, we get the exact same result twice:

run

Conclusion:

The internal architecture of custom Ansible module can be as simple as one function or multi-layered with a complex class inheritance. Depending on your needs, you may decide to opt for a more complex class-style rather than breaking business logic into hard to manage functional programming chunks. In this article, we have shown how to achieve the same result with both styles. As a rule of thumb, if you are unsure which style to use, you should start with functional style and convert to class style when you reach a certain level of code complexity.