Maßgeschneiderte Ansible-Module mit Python Class

In jedem ernsten Netzwerkautomatisierungsprojekt kommen wir irgendwann zum Punkt, wo weder eingebaute Ansible-Module noch Ansible Galaxy Module ausreichend sind, um maßgeschneiderte Anforderungen implementieren zu können. Deswegen finden wir die Lösung in der Erstellung von unseren eigenen, maßgeschneiderten Ansible-Modulen. Dank dem flexiblen Design von Ansible kann man das sehr einfach mithilfe von Python-Programmierung schaffen. Ein funktionaler Python-Code sollte für viele Ansible-Module völlig ausreichend sein. In einigen Fällen, vor allem im Zusammenhang mit der Netzwerkautomatisierung, wird Verständnis und Aufrechterhaltung von und Bewegung durch den funktionalen Python-Code gelegentlich zu umständlich. In diesem Tutorium zeigen wir, wie man die gleiche Modulfähigkeit mit funktionalem Stil und Class-Style (OOP) kodieren schaffen kann.

Angenommen, unsere Aufgabe ist es, ein unkompliziertes Modul zu schaffen, der Junos CLI aus einem vorgegebenen Datenmodel erzeugt. Wir konzentrieren uns ausschließlich auf die Adresse und Adressengruppen. Für den Adressennamen ADDRESS_SRC1 und Adressensubnetz 10.10.10.0/24 und Adressensubnetz 10.10.10.0/24 muss man den folgenden Befehlssatz generieren: 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 verbunden sein müsste, wird das Modul folgende Konfigurationszeile generieren: set security address-book global address-set AG_SRC address ADDRESS_SRC1.

Unser Datenmodul soll folgendermaßen aussehen:

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

Jedes Item unter <strong>addresses_list_of_dicts</strong> müsste zu einem neuen Adressenobjekt werden. Ebenso müsste für jedes Item unter <strong>address_groups_list_of_dicts</strong> ein Adressengruppenobjekt erstellt werden.

Wir möchten unser maßgeschneidertes Ansible-Modul create_junos_config1 nennen, was bedeutet, dass die Datei create_junos_config1.py im Ordner <strong>library</strong> sein muss. Wir tragen die Input-Daten folgendermaßen ein:

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 }}"

Hier ist der Python-Code dieses Moduls:

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()

Dazu gehören einige wohlbekannten Abschnitte:

  • Erforderliches Werkzeug <strong>AnsibleModule</strong> ist importiert
  • Der Abschnitt mit Inputs, wie er im Ansible-Playbook aufgebaut werden sollte, ist definiert
  • Der Abschnitt fürs Lesen übergebener Parameter in Python aus dem Ansible-Playbook (<strong>addresses</strong> und address_groups)
  • Geschäftslogik, die eigentlich das macht, was wir wollen, dass das Modul macht (Generierung von Befehlen)
  • Verlassen des (Python) Moduls und Rückgabe der Ergebnisse an das Ansible-Playbook

Ausführen von main.yml Playbook ergibt folgendes:

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  

Die Ergebnisse sehen gut aus. Die Liste von zurückgegebenen Befehlen kann in eine Juniper-Maschine entweder mithilfe von juniper.device.config oder junipernetworks.junos.junos_config Ansible-Modulen aufgeladen werden.

Bauen wir nun das maßgeschneiderte Ansible-Modul create_junos_config2 auf, das genau die gleiche Funktion behält, aber der Codestil wird anders. Wir werden Python Class verwenden.

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()

Hier muss man einige wichtige Details betonen:

  • Die Geschäftslogik ist im JunosConfig Class beinhaltet.
  • Der einzige Zweck von main() ist es, die Class zu realisieren und die generate_config Methode zu aktivieren.
  • Wir müssen auch <strong>module</strong> an Class instance weitergeben, da der Modulaustritt aus Class gesteuert wird.
  • Praktischerweise brechen wir die Geschäftslogik der Class in kleinere Stücke durch Implementierung von zwei Hilfsmethoden (_generate_address_config und _generate_group_config).

Der Gesamtcode für main.yml sieht nun so aus:

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 }}"

Wenn wir ihn jetzt laufen lassen, bekommen wir das identische Ergebnis zweimal:

run

Schlussfolgerung:

Die interne Architektur von maßgeschneiderten Ansible-Modulen kann lediglich durch eine Funktion oder aber durch Mehrschichtsystem mit einer komplexen Class-Vererbung repräsentiert werden. Abhängig von den jeweiligen Bedürfnissen kann man sich für ein komplexeres Class-Stil entscheiden, anstatt die Geschäftslogik in kleinere, schwer steuerbare Teile der funktionalen Programmierung zu brechen. In diesem Artikel haben wir gezeigt, wie man das gleiche Ergebnis mit beiden Arbeitsweisen erreichen kann. Als Faustregel gilt folgendes: wenn man sich nicht sicher ist, welche Arbeitsweise man benutzen soll, soll man mit dem funktionalen Stil anfangen und zum Class-Stil wechseln, wenn man ein bestimmtes Komplexitätsniveau des Codes erreicht hat.