Ansible code review

I am looking for some feedback/advice/suggestions for my ansible playbook[s]. I included them below in a hopefully useful format. Here is also a directory structure of the files. These playbooks dynamically manage firewall rules in production, so I’m looking for another set of eyes before I push this into production.

.
├── ansible.cfg~
├── docs
│   └── fem_ansible.png
├── fwmgmt.yml
├── inventory
├── README.md
├── roles
│   ├── fortinet_fw
│   │   ├── defaults
│   │   │   └── main.yml
│   │   ├── files
│   │   │   └── iana_reversed.csv
│   │   ├── handlers
│   │   │   └── main.yml
│   │   ├── meta
│   │   │   └── main.yml
│   │   ├── README.md
│   │   ├── tasks
│   │   │   ├── address.yml
│   │   │   ├── main.yml
│   │   │   ├── policy.yml
│   │   │   ├── servicegroup.yml
│   │   │   └── service.yml
│   │   ├── templates
│   │   ├── tests
│   │   │   └── inventory
│   │   └── vars
│   │       └── main.yml
│   └── nb_graphql
│       ├── defaults
│       │   └── main.yml
│       ├── files
│       ├── filter_plugins
│       │   ├── custom_filters.py
│       │   └── __pycache__
│       ├── handlers
│       │   └── main.yml
│       ├── meta
│       │   └── main.yml
│       ├── README.md
│       ├── tasks
│       │   └── main.yml
│       ├── templates
│       ├── tests
│       │   └── inventory
│       └── vars
│           └── main.yml
File: ./fwmgmt.yml
---
- name: Get data from nb
  hosts: firewalls
  gather_facts: false
  vars:
    slack_token: "{{ lookup('file', '/opt/firewall/slackkey') }}"
  roles:
    - role: nb_graphql
    - role: fortinet_fw
      policy: "{{ rule.json.data.policy_rules[0] }}"
      before: "{{ rule.json.data.policy_rules[0].policies[0].policy_rules |
                  community.general.json_query('[].index')|
                  find_nearest_elements(target=rule.json.data.policy_rules[0].index) }}"

File: ./roles/nb_graphql/tasks/main.yml
---
# Tasks file for roles/nb_graphql

- name: Policy from Nautobot
  check_mode: false
  diff: false
  ansible.builtin.uri:
    validate_certs: false
    url: "https://{{ policy_data }}"
    method: POST
    follow_redirects: all
    headers:
      Authorization: "Token {{ api_key }}"
      Accept: "application/json; indent=4"
    body_format: json
    body:
      variables:
        policyname: "{{ policy_name }}"
  delegate_to: localhost
  register: rule

- name: To Slack
  check_mode: false
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#netops"
    msg: "{{ policy_name }}"
  register: slack_response
  delegate_to: localhost

- name: Details to Slack
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#netops"
    thread_id: "{{ slack_response['ts'] }}"
    msg: "Nautobot Object: ```{{ rule.json.data.policy_rules[0] | to_nice_json }}```"
  delegate_to: localhost

File: ./roles/nb_graphql/defaults/main.yml
---
# defaults file for roles/nb_graphql
nautobot_host: nautobot.cc.lehigh.edu
# corresponds with the saved query in nautobot
policy_data: "{{ nautobot_host }}/api/extras/graphql-queries/bee72043-be67-4c58-8930-c08f536c9bd5/run/"
api_key: "{{ lookup('file', '/opt/firewall/nbkey') }}"

File: ./roles/nb_graphql/handlers/main.yml
---
# handlers file for roles/nb_graphql

File: ./roles/nb_graphql/vars/main.yml
---
# vars file for roles/nb_graphql

File: ./roles/fortinet_fw/tasks/servicegroup.yml
---
# Task to bundle services together into a service group

- name: Set firewall service group
  vars:
    members: "{{ item.service_objects | community.general.json_query('[].{name: name}') }}"
  fortinet.fortios.fortios_firewall_service_group:
    vdom: "{{ vdom }}"
    state: "{{ item.status.name | regex_search('Active|Disabled') and 'present' or 'absent' }}"
    access_token: "{{ access_token }}"
    firewall_service_group:
      comment: "{{ item.comment | default('') }}"
      name: "{{ item.name }}"
      member: "{{ members }}"
  with_items: "{{ servicegroup }}"
  register: msg

- name: Details to Slack
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#netops"
    thread_id: "{{ slack_response['ts'] }}"
    msg: "ServiceGroup Update: ``` - {{ msg.msg }}\n - Changed: {{ msg.changed }}```"
  delegate_to: localhost
File: ./roles/fortinet_fw/tasks/service.yml
---
- name: Set firewall service
  vars:
    # Hack to lookup the IP protocol number since nb doesn't provide yet
    # https://github.com/nautobot/nautobot-plugin-firewall-models/issues/169
    ip_protocol_number: "{{ lookup('ansible.builtin.csvfile', item.ip_protocol + ' file=../files/iana_reversed.csv delimiter=,') | default('') }}"
  fortinet.fortios.fortios_firewall_service_custom:
    vdom: "{{ vdom }}"
    state: "{{ item.status.name | regex_search('Active|Disabled') and 'present' or 'absent' }}"
    access_token: "{{ access_token }}"
    firewall_service_custom:
      comment: "{{ item.comment | default('No Comment') }}"
      name: "{{ item.name }}"
      # Weird syntax for matching incoming text and setting protocol field based on it.
      # I don't totally understand how it works to be honest
      protocol: "{{ item.ip_protocol | regex_search('TCP|UDP') and 'TCP/UDP/SCTP' or item.ip_protocol | regex_search('ICMP') and 'ICMP' or 'IP' }}"
      protocol_number: "{{ ip_protocol_number }}"
      tcp_portrange: "{{ item.ip_protocol | replace('TCP', item.port) | default('') }}"
      udp_portrange: "{{ item.ip_protocol | replace('UDP', item.port) | default('') }}"
  with_items: "{{ services }}"
  register: msg

- name: Details to Slack
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#netops"
    thread_id: "{{ slack_response['ts'] }}"
    msg: "Service Update: ``` - {{ msg.msg }}\n - Changed: {{ msg.changed }} ```"
  delegate_to: localhost
File: ./roles/fortinet_fw/tasks/main.yml
---
# Ordered list of playbooks to execute.

- name: Create address task
  collections:
   - fortinet.fortios
  connection: httpapi
  vars:
   addresses:
   # Doesn't support looping multiple addresses... yet
    - "{{ policy.destination_addresses[0] }}"
    - "{{ policy.source_addresses[0] }}"
  ansible.builtin.import_tasks: address.yml

- name: Create services
  collections:
   - fortinet.fortios
  connection: httpapi
  vars:
   # Doeesn't support looping multiple service_groups... yet
   services: "{{ policy.destination_service_groups[0].service_objects }}"
  ansible.builtin.import_tasks: service.yml

- name: Create service groups
  collections:
   - fortinet.fortios
  connection: httpapi
  vars:
   servicegroup: "{{ policy.destination_service_groups }}"
  ansible.builtin.import_tasks: servicegroup.yml

- name: Create policy
  collections:
   - fortinet.fortios
  connection: httpapi
  ansible.builtin.import_tasks: policy.yml

File: ./roles/fortinet_fw/tasks/address.yml
---
# Ansible Playbook to create or delete firewall addresses on a Fortinet device

- name: Address management
  fortinet.fortios.fortios_firewall_address:
    vdom: "{{ vdom }}"
    # If the state of the object is set to anything other than 'Active' or 'Disabled'
    # the object will be deleted.
    state: "{{ item.status.name | regex_search('Active|Disabled') and 'present' or 'absent' }}"
    access_token: "{{ access_token }}"
    # Define the firewall address parameters
    firewall_address:
      # Use the comment field from the item or provide a default if not specified
      comment: "{{ item.comment | default('') }}"
      name: "{{ item.name }}"
      # Set the CIDR for the firewall address using the IP address from the item
      subnet: "{{ item.ip_address.address }}"
      type: ipmask
  # Loop through all of the items in the addresses object.
  with_items: "{{ addresses }}"
  register: msg

- name: Details to Slack
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#netops"
    thread_id: "{{ slack_response['ts'] }}"
    msg: "Address Update: ``` - {{ msg.msg }}\n - Changed: {{msg.changed}}```"
  delegate_to: localhost

File: ./roles/fortinet_fw/tasks/policy.yml
---
# Task to set a new firewall policy rule based on a given data structure

- name: Firewall policy
  vars:
    # Policy ID is based on the index value set in data
    policyid: "{{ policy.index }}"
  fortinet.fortios.fortios_firewall_policy:
    vdom: "{{ vdom }}"
    # If status is either Active or Disabled, state = present, otherwise it is absent
    state: "{{ policy.status.name | regex_search('Active|Disabled') and 'present' or 'absent' }}"
    access_token: "{{ access_token }}"
    firewall_policy:
      policyid: "{{ policyid }}"
      # Mapping ALLOW to accept and DENY to deny
      action: "{{ policy.action | replace('ALLOW', 'accept') | replace('DENY', 'deny') }}"
      comments: "{{ policy.comment | default('') }}"
      name: "{{ policy.name }}"
      # Fun json query to extract the name fields from complex objects
      dstaddr: "{{ policy.destination_addresses | community.general.json_query('[].{name: name}') }}"
      srcaddr: "{{ policy.source_addresses | community.general.json_query('[].{name: name}') }}"
      service: "{{ policy.destination_service_groups | community.general.json_query('[].{name: name}') }}"
      dstintf:
        - "{{ policy.destination_zone.name | community.general.dict_kv('name') }}"
      srcintf:
        - "{{ policy.source_zone.name | community.general.dict_kv('name') }}"
      schedule: "always"
      # Using status field again here to map Disabled to disable
      status: "{{ policy.status.name | regex_search('Disabled|Expired') and 'disable' or 'enable' }}"
  register: msg

- name: Details to Slack
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#netops"
    thread_id: "{{ slack_response['ts'] }}"
    msg: "Policy Invocation: ```Changed: {{ msg.changed }}```"
  delegate_to: localhost


- name: Debug
  ansible.builtin.debug:
    var: policy.action

- name: Move rule
  # Task to move a rule to the top of the list if its action is 'Deny'
  # Only runs when action is set to Deny
  fortinet.fortios.fortios_firewall_policy:
    access_token: "{{ access_token }}"
    vdom: "{{ vdom }}"
    action: move
    self: "{{ policy.index }}"
    before: "{{ before }}"
  when: policy.action == 'DENY'
  register: status

- name: Summary to Slack
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#netops"
    thread_id: "{{ slack_response['ts'] }}"
    msg: "Move Details: ``` Changed: {{ status.changed }}```"
  delegate_to: localhost


File: ./roles/fortinet_fw/defaults/main.yml
---
# defaults file for roles/fortinet_fw
ansible_network_os: fortinet.fortios.fortios
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
ansible_httpapi_port: 443
vdom: FG-traffic

File: ./roles/fortinet_fw/handlers/main.yml
---
# handlers file for roles/fortinet_fw

File: ./roles/fortinet_fw/vars/main.yml
---
# vars file for roles/fortinet_fw
access_token: "{{ lookup('file', policy.policies[0].assigned_devices[0].secrets_group.secrets[0].parameters.path) }}"

I had some serious doubts about that line myself. But a little testing made it clear.

{{ true  and 'WOW'   }} ⇒ 'WOW'
{{ false and 'WOW'   }} ⇒ False
{{ true  or  'WOOPS' }} ⇒ True
{{ false or  'WOOPS' }} ⇒ 'WOOPS'

That combined with the fact that “|” has higher precedence than “and”, and “and” has higher precedence than “or” lets you work out how it works. (Although, apparently, Jinja operator precedence is not actually defined.)

That’s the only thing that jumped out and me, and it turns out to be right. :white_check_mark:

1 Like

Yea, that solution was provided to me by my trusty intern: ChatGPT. The two items that I specifically don’t like about my code is:

  1. the duplication of the Details to Slack play in almost every playbook. I kinda would have preferred to call a “function” of some sort rather than copying and pasting the same play all over the place. But I couldn’t figure out how to do that especially since the plays are in different roles
  2. in the Create address task, I couldn’t figure out how to loop through the array so I had to hardcode the first element and only support a single source and destination address

Your terminology is, er, rather loose. You keep talking about all these playbooks, but you only have one playbook: fwmgmt.yml. Rule of thumb: if it doesn’t have a hosts: key at the top level, it isn’t a playbook.

All those other files you’re calling “playbooks” are “task files,” and the things within them aren’t “plays,” they’re “tasks.” I don’t mean to hit you with the pedantic hammer, but you really want to get these terms right. Otherwise you’ll have a hard time interpreting the docs or communicating with the rest of us.

A quote, with edits:

That would be a problem if it were multiple tasks. The fix for that would be to make it into its own role, and you’d invoke it with ansible.builtin.include_role with appropriate variables etc. But that’s still a task invocation just to include the role. Since it’s already a single task, there’s no way (that I see anyway) to reduce the duplication or overall footprint of those Slack notifications.

Just as a refresher (so folks won’t have to scroll around to follow the discussion, including me), here’s that task:

- name: Create address task
  collections:
   - fortinet.fortios
  connection: httpapi
  vars:
   addresses:
   # Doesn't support looping multiple addresses... yet
    - "{{ policy.destination_addresses[0] }}"
    - "{{ policy.source_addresses[0] }}"
  ansible.builtin.import_tasks: address.yml

You can loop over policy.destination_addresses paired with corresponding policy.source_addresses like this:

- name: Create address task
  collections:
    - fortinet.fortios
  connection: httpapi
  ansible.builtin.import_tasks: address.yml
  loop: "{{ policy.destination_addresses | zip(policy.source_addresses) }}"
  loop_control:
    loop_var: addresses

The zip() filter will create paired destination and source addresses. By changing the loop_var from the default of item to addresses, that conveniently creates the variable that your address.yml task file expects. Also, by not using item as the loop_var, it keeps item free for use by tasks in the tasks file.

I also changed your 1- and 2-space indentation to a consistent 2-space indentation. You should run ansible-lint on your project; it’ll suggest the same thing. And a whole lot more I’m sure. It has opinions about Ansible code.

Pedantry in our field is very important. So thank you for helping me clarify. Part of wanting to reduce duplication is to make it a little easier to, for instance, change the destination channel. I will look into moving it into its own role, or maybe pulling pertinent information out of the ‘task’ and into variables to be referenced.

And again thank you for your tips with the looping. I am going to have to dissect that a bit to wrap my brain around it.