AAP playbook review - how to schedule reporting software inventory compliance and email

I am learning to write playbooks and trying to write a playbook that runs in different release of RHEL servers and report 3 different software installation and send a compliance report in email.

Here is the condition.

Software1 -
RHEL5 - Not Applicable so does not require to check
RHEL6,7,8,9 - Run the command rpm -q software1 to detect the installation

Software2
RHEL6 -Not Applicable so does not require to check
RHEL 5,7,8,9 - Locate the below directory path for each version
RHEL 5 - /opt/software2-base/
RHEL7,8,9 - /opt/software2/base/

Software3
For all versions - run the command rpm -q software3

Here is my playbook.

---
- name: Check Software Installation Status on RHEL servers and Send Email
  hosts: all
  become: yes
  vars_files:
    - cred.yml
  vars:   
    software_list:
      - name: "software1"
        package: "software1"
        check_type: "rpm"
      - name: "software2"
        check_type: "directory"
      - name: "software3"
        package: "software3"
        check_type: "rpm"
    email_to: "emailid@domain"
    email_from: "emailid@domain"
    email_subject: "Software Installation Compliance Report for RHEL Servers"
  tasks:
    - name: Detect Python interpreter
      shell: |
        if command -v python &> /dev/null; then
          echo /usr/bin/python
        elif command -v python2 &> /dev/null; then
          echo /usr/bin/python2
        elif command -v python3 &> /dev/null; then
          echo /usr/bin/python3
        else
          echo "Python not found"
          exit 1
        fi
      register: python_interpreter
      ignore_errors: yes
      changed_when: false
    - name: Set ansible_python_interpreter
      set_fact:
        ansible_python_interpreter: "{{ python_interpreter.stdout }}"
      when: python_interpreter.stdout != "Python not found"

    - name: Gather OS dist and version
      setup:
        filter: "ansible_distribution*"
      register: os_facts

    - name: Debug os_facts
      debug:
        var: os_facts

    - name: Initialize compliance report
      set_fact:
        compliance_report: [ ]

    - name: Initialize software2_installed_rhel5 with default value
      set_fact:
        software2_installed_rhel5:
          stat:
            exists: false

    - name: Check if software2 is installed (Directory - RHEL5)
      stat:
        path: "/opt/software2-base/"
      register: software2_installed_rhel5
      when: >
        os_facts.ansible_facts.ansible_distribution_major_version | int == 5

    - name: Debug software2_installed_rhel5
      debug:
        var: software2_installed_rhel5

    - name: Check if software2 is installed (Directory - RHEL 7/8/9 )
      stat:
        path: "/opt/software2/base"
      register: software2_installed_rhel7
      when: >
        os_facts.ansible_facts.ansible_distribution_major_version | int >= 7

    - name: Debug software2_installed_rhel7
      debug:
        var: software2_installed_rhel7
    - name: Check if software is installed (RPM)
      shell: rpm -q {{ item.package }}
      register: rpm_installed
      ignore_errors: yes
      loop: "{{ software_list }}"
      loop_control:
        loop_var: item
      when: >
        item.check_type == "rpm" and
        (item.name != 'software1' or (item.name == 'software1' and os_facts.ansible_facts.ansible_distribution_major_version | int >= 6))

    - name: Debug rpm_installed
      debug:
        var: rpm_installed
    - name: Add non-compliant software to compliance report (RPM)
      set_fact:
        compliance_report: >
          {{
            compliance_report + [{
              'server': ansible_fqdn,
              'software': item.item.name,
              'status': 'Not Installed'
            }]
          }}
      loop: "{{ rpm_installed.results }}"
      loop_control:
        loop_var: item
      when: >
        item.item.check_type == "rpm" and
        item is failed and
        (rpm_installed is failed) and
        (item.item.name != 'software1' or (item.item.name == 'software1' and os_facts.ansible_facts.ansible_distribution_major_version | int >= 6))

    - name: Add non-compliant software to compliance report (Directory - RHEL 5)
      set_fact:
        compliance_report: >
          {{
            compliance_report + [{
              'server': ansible_fqdn,
              'software': item.name,
              'status': 'Not Installed'
            }]
          }}
      when: >
        software2_installed_rhel5 is defined and
        software2_installed_rhel5.stat is defined and
        software2_installed_rhel5.stat.exists == false and
        os_facts.ansible_facts.ansible_distribution_major_version | int == 5

    - name: Add non-compliant software to compliance report (Directory - RHEL 7/8/9)
      set_fact:
        compliance_report: >
          {{
            compliance_report + [{
              'server': ansible_fqdn,
              'software': item.name,
              'status': 'Not Installed'
            }]
          }}
      when: >
        (software2_installed_rhel7.stat.exists == false) and
        os_facts.ansible_facts.ansible_distribution_major_version | int >= 7

    - name: Debug compliance_report
      debug:
        var: compliance_report

    - name: Collect compliance reports from all servers
      set_fact:
        global_compliance_report: "{{ global_compliance_report | default([]) + compliance_report }}"        
      run_once: yes
      delegate_to: localhost

    - name: debug global_compliance_report after set_fact
      debug:
        var: global_compliance_report
      run_once: yes
      delegate_to: localhost

- name: Send consolidated email report
  hosts: localhost
  become: no
  tasks:
    - name: Debug global_compliance_report
      debug:
        var: global_compliance_report
    - name: Send email with consolidated compliance report
      community.general.mail:
        host: smtp.domain
        port: 25
        from: "{{ email_from }}"
        to: "{{ email_to }}"
        subject: "{{ email_subject }}"
        body: |
          Software Installation Compliance Report:
          {% for entry in global_compliance_report %}
          - Server: {{ entry.server }}
            Software: {{ entry.software }}
            Status: {{ entry.status }}
          {% endfor %}
      when: global_compliance_report is defined and global_compliance_report | length > 0

when I run the playbook against one server for testing. it runs without failing but I get the below in debug output. The server runs RHEL7 where software3 is not installed.

…
…

TASK [Add non-compliant software to compliance report (RPM)] *******************
skipping: [server1.domain] => (item={'changed': True, 'end': '2025-03-12 12:32:11.963910', 'stdout': 'software1.el7.x86_64', 'cmd': 'rpm -q software1', 'start': '2025-03-12 12:32:11.918758', 'delta': '0:00:00.045152', 'stderr': '', 'rc': 0, 'invocation': {'module_args': {'executable': N
one, '_uses_shell': True, 'strip_empty_ends': True, '_raw_params': 'rpm -q software1', 'removes': None, 'argv': None, 'creates': None, 'chdir': None, 'stdin_add_newline': True, 'stdin': None}}, 'msg': '', 'stdout_lines': ['software1.el7.x86_64'], 'stderr_lines': [], 'failed': False, 'item': {'na
me': 'software1', 'package': 'software1', 'check_type': 'rpm'}, 'ansible_loop_var': 'item'})
skipping: [server1.domain] => (item={'changed': False, 'skipped': True, 'skip_reason': 'Conditional result was False', 'item': {'name': 'software2', 'check_type': 'directory'}, 'ansible_loop_var': 'item'})
ok: [server1.domain] => (item={'changed': True, 'end': '2025-03-12 12:32:13.080696', 'stdout': 'package software3 is not installed', 'cmd': 'rpm -q software3', 'failed': True, 'delta': '0:00:00.043627', 'stderr': '', 'rc': 1, 'invocation': {'module_args': {'executable': None, '_uses_shell': True, 's
trip_empty_ends': True, '_raw_params': 'rpm -q open-vm-tools', 'removes': None, 'argv': None, 'creates': None, 'chdir': None, 'stdin_add_newline': True, 'stdin': None}}, 'start': '2025-03-12 12:32:13.037069', 'msg': 'non-zero return code', 'stdout_lines': ['package software3 is not installed'], 'stderr_lines': [
], 'item': {'name': 'software3', 'package': 'software3', 'check_type': 'rpm'}, 'ansible_loop_var': 'item'})

TASK [Add non-compliant software to compliance report (Directory - RHEL 5)] ****
skipping: [server1.domain]

TASK [Add non-compliant software to compliance report (Directory - RHEL 7/8/9)] ***
skipping: [server1.domain]

TASK [Debug compliance_report] *************************************************
ok: [server1.domain] => {
    "compliance_report": [
        {
            "server": "server1.domain",
            "software": "software3",
            "status": "Not Installed"
        }
    ]
}

TASK [Collect compliance reports from all servers] *****************************
ok: [server1.domain -> localhost]

TASK [debug global_compliance_report after set_fact] ***************************
ok: [server1.domain -> localhost] => {
    "global_compliance_report": [
        {
            "server": "server1.domain",
            "software": "software3",
            "status": "Not Installed"
        }
    ]
}



PLAY [Send consolidated email report] ******************************************

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

TASK [Debug global_compliance_report] ******************************************
ok: [localhost] => {
    "global_compliance_report": "VARIABLE IS NOT DEFINED!"
}

TASK [Send email with consolidated compliance report] **************************
skipping: [localhost]
PLAY RECAP *********************************************************************
server1.domain    : ok=16   changed=1    unreachable=0    failed=0    skipped=3    rescued=0    ignored=1
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

@ansnewbie Welcome to the Ansible Forum.

I’ve just edited your post to add code formatting, should make it easier to read.

2 Likes

Hi @ansnewbie

The main issue is that the variable global_compliance_report isn’t available in your email play on localhost.

In your first play you use a task with

- name: Collect compliance reports from all servers
  set_fact:
    global_compliance_report: "{{ global_compliance_report | default([]) + compliance_report }}"        
  run_once: yes
  delegate_to: localhost

This task sets the fact on the delegated host ( localhost ) but only for that play.
Facts set with set_fact are scoped to the host they’re set on and are not automatically persisted across separate plays unless you enable a persistent fact cache or explicitly pass the data.

In your second play (which runs on localhost) the variable global_compliance_report is not defined because it wasn’t saved or retrieved there. That’s why your debug output shows VARIABLE IS NOT DEFINED! and the email task is skipped.

There are also some things in the playbook that should be improved.

  1. You can rely on gathered facts instead of running a shell command to get python interpreter
    When facts are gathered (via the setup module), Ansible provides the Python interpreter information in the ansible_facts.ansible_python dictionary or ansible_facts.discovered_interpreter_python

    Many newer modules (such as package_facts) and recent Ansible versions automatically determine the interpreter based on the host’s configuration.

  2. For package checking, you might explore using the package_facts module.

  3. Consider using pre_tasks and post_tasks, for example, for the setup module or mail.
    Documentation at Ansible playbooks_keywords

  4. Instead of having separate tasks for checking software2’s directory, define a variable early in the play and then use one stat task

    example:

    - name: Set expected software2 directory
      set_fact:
        software2_dir: "{{ (ansible_distribution_major_version | int == 5) | ternary('/opt/software2-base/', '/opt/software2/base/') }}"
    
    - name: Check if software2 is installed (Directory)
      stat:
        path: "{{ software2_dir }}"
      register: software2_stat
    
3 Likes

Thank you so much. I moved the email task inside the main main task but it is not parsing the content properly and I get email for only for the first server but not the second one. The debug output shows though its processing the second server, its only printing the first server output so the does the email I guess.

- name: Collect compliance reports from all servers
  set_fact:
    global_compliance_report: "{{ global_compliance_report | default([]) + compliance_report }}"
  run_once: yes
  delegate_to: localhost

- name: debug global_compliance_report after set_fact
  debug:
    msg: "{{ global_compliance_report }}"

- name: Send consolidated email report
  community.general.mail:
    host: dpssmtp.tle.dps
    port: 25
    from: "{{ email_from }}"
    to: "{{ email_to }}"
    subject: "{{ email_subject }}"
    body: |
      Software Installation Compliance Report:
      {% for entry in global_compliance_report %}
      - Server: {{ entry.server }}
        Software: {{ entry.software }}
        Status: {{ entry.status }}
      {% endfor %}
  when: global_compliance_report | length > 0

Here is the output

TASK [debug global_compliance_report after set_fact] ***************************
ok: [server1] => {
“msg”: [
{
“server”: “server1”,
“software”: “Software3”,
“status”: “Not Installed”
}
]
}
ok: [server2] => {
“msg”: [
{
“server”: “server1”,
“software”: “Software3”,
“status”: “Not Installed”
}
]
}

TASK [Send consolidated email report] ******************************************
ok: [server2]
ok: [server1]

This is not true.

From delegation docs:

Similarly, any facts gathered by a delegated task are assigned by default to the inventory_hostname (the current host), not to the host that produced the facts (the delegated to host)

You can however set delegate_facts: true to set the facts for the delegated host.

Futhermore, the task was run with run_once: true which will set the value for all hosts:

Boolean that will bypass the host loop, forcing the task to attempt to execute on the first host available and afterward apply any results and facts to all active hosts in the same batch.

And third: set_fact will persist across plays if they’re under same execution.
Ie, following playbook will successfully print the delegate_variable set in the first play:

- hosts: db
  gather_facts: false
  tasks:
    - ansible.builtin.set_fact:
        delegate_variable: "localhost"
      delegate_to: localhost

- hosts: db
  gather_facts: false
  tasks:
    - ansible.builtin.debug:
        # Athough separate play and set_fact delegated to localhost, fact will be still present for host db.
        msg: "{{ delegate_variable }}"