diff --git a/playbooks/verify.yml b/playbooks/verify.yml new file mode 100644 index 00000000..88d75166 --- /dev/null +++ b/playbooks/verify.yml @@ -0,0 +1,150 @@ +--- +- name: Gather System Facts + hosts: all + gather_facts: true + + vars: + # These are production specs for Itential P6 + hardware_specs: + mongodb: + cpu_count: 16 + ram_size: 128 + disk_size: 1000 + platform: + cpu_count: 16 + ram_size: 64 + disk_size: 250 + redis: + cpu_count: 16 + ram_size: 32 + disk_size: 100 + + tasks: + + - name: Gather host information + itential.deployer.gather_host_information: + register: host_info + + - name: Extract OS information + ansible.builtin.set_fact: + os: "{{ host_info.os }}" + + # OS and Architecture validation + - name: Check OS compatibility + ansible.builtin.set_fact: + os_valid: >- + {{ + (os.distribution == 'RedHat' and ansible_distribution_major_version in ['8', '9']) or + (os.distribution == 'Rocky' and ansible_distribution_major_version in ['8', '9']) or + (os.distribution == 'OracleLinux' and ansible_distribution_major_version in ['8', '9']) or + (os.distribution == 'Amazon' and ansible_distribution_major_version == '2023') + }} + + - name: Assert that this is a supported OS + ansible.builtin.assert: + that: "{{ os_valid }} == true" + fail_msg: "{{ os.distribution }} {{ os.distribution_version }} is not a supported OS!" + success_msg: "OS validation passed!" + quiet: true + + - name: Check architecture compatibility + ansible.builtin.set_fact: + arch_valid: "{{ os.architecture in ['x86_64', 'aarch64'] }}" + + - name: Assert that this is a supported Architecture + ansible.builtin.assert: + that: "{{ arch_valid }} == true" + fail_msg: "{{ os.architecture }} is not a supported architecture!" + success_msg: "Architecture validation passed!" + quiet: true + + # Hardware spec validation + - name: Determine which hardware spec applies to this host + ansible.builtin.set_fact: + applicable_spec: >- + {%- if 'mongodb' in group_names -%} + mongodb + {%- elif 'platform' in group_names -%} + platform + {%- elif 'redis' in group_names -%} + redis + {%- else -%} + none + {%- endif -%} + + - name: Get root partition size + ansible.builtin.set_fact: + root_disk_size_gb: "{{ (ansible_mounts | selectattr('mount', 'equalto', '/') | map(attribute='size_total') | first / 1024 / 1024 / 1024) | round(2) }}" + when: ansible_mounts | selectattr('mount', 'equalto', '/') | list | length > 0 + + - name: Validate hardware specs against requirements + ansible.builtin.set_fact: + hardware_validation: + applicable_spec: "{{ applicable_spec }}" + required: + cpu_count: "{{ hardware_specs[applicable_spec].cpu_count if applicable_spec != 'none' else 'N/A' }}" + ram_size_gb: "{{ hardware_specs[applicable_spec].ram_size if applicable_spec != 'none' else 'N/A' }}" + disk_size_gb: "{{ hardware_specs[applicable_spec].disk_size if applicable_spec != 'none' else 'N/A' }}" + actual: + cpu_count: "{{ ansible_processor_vcpus }}" + ram_size_gb: "{{ (ansible_memtotal_mb / 1024) | round(2) }}" + disk_size_gb: "{{ root_disk_size_gb | default('N/A') }}" + validation: + cpu_valid: "{{ (applicable_spec == 'none') or (ansible_processor_vcpus >= hardware_specs[applicable_spec].cpu_count) }}" + ram_valid: "{{ (applicable_spec == 'none') or ((ansible_memtotal_mb / 1024) >= hardware_specs[applicable_spec].ram_size) }}" + disk_valid: "{{ (applicable_spec == 'none') or ((root_disk_size_gb | default(0) | float) >= hardware_specs[applicable_spec].disk_size) }}" + all_valid: "{{ (applicable_spec == 'none') or ((ansible_processor_vcpus >= hardware_specs[applicable_spec].cpu_count) and ((ansible_memtotal_mb / 1024) >= hardware_specs[applicable_spec].ram_size) and ((root_disk_size_gb | default(0) | float) >= hardware_specs[applicable_spec].disk_size)) }}" + + - name: Validate CPU Count + ansible.builtin.assert: + that: hardware_validation.validation.cpu_valid | bool + fail_msg: "CPU validation failed" + quiet: true + ignore_errors: true + register: cpu_validation + + - name: Validate Memory Amount + ansible.builtin.assert: + that: hardware_validation.validation.memory_valid | bool + fail_msg: "Memory validation failed" + quiet: true + ignore_errors: true + register: memory_validation + + - name: Validate Disk Size + ansible.builtin.assert: + that: hardware_validation.validation.disk_valid | bool + fail_msg: "Disk validation failed" + quiet: true + ignore_errors: true + register: disk_validation + + # Fail at the end if any validation failed + - name: Check if any validations failed + ansible.builtin.fail: + msg: | + Hardware validation failures detected: + {% if cpu_validation is failed %} + - CPU: {{ hardware_validation.required.cpu_count }} required, {{ hardware_validation.actual.cpu_count }} found + {% endif %} + {% if memory_validation is failed %} + - Memory: {{ hardware_validation.required.ram_size_gb }}GB required, {{ hardware_validation.actual.ram_size_gb }}GB found + {% endif %} + {% if disk_validation is failed %} + - Disk: {{ hardware_validation.required.disk_size_gb }}GB required, {{ hardware_validation.actual.disk_size_gb }}GB found + {% endif %} + when: cpu_validation is failed or memory_validation is failed or disk_validation is failed + +- name: Aggregate Results + hosts: localhost + gather_facts: false + + tasks: + - name: Collect all host information + ansible.builtin.set_fact: + all_systems_info: "{{ all_systems_info | default([]) + [hostvars[item].host_info] }}" + loop: "{{ groups['all'] }}" + + - name: Display aggregated information + ansible.builtin.debug: + var: all_systems_info diff --git a/plugins/modules/gather_host_information.py b/plugins/modules/gather_host_information.py new file mode 100644 index 00000000..e9828b2a --- /dev/null +++ b/plugins/modules/gather_host_information.py @@ -0,0 +1,154 @@ +#!/usr/bin/python + +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' +--- +module: gather_host_information + +short_description: Inspect facts and gather interesting data + +version_added: "3.0.0" + +description: This module will inspect the host facts and gather interesting data to be used in the + verification and certification of environments. + +author: + - Steven Schattenberg (@steven-schattenberg-itential) +''' + +EXAMPLES = r''' +- name: Gather standard facts + itential.deployer.gather_host_information: +''' + +RETURN = r''' +details: + description: Details from the host + type: object + returned: always + sample: false +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.facts.compat import ansible_facts + +def build_disk_list(ansible_mounts): + """Build simplified disk list from ansible_mounts data""" + disk_list = [] + + for item in ansible_mounts: + if 'size_total' in item: + disk_list.append({ + 'mount': item['mount'], + 'size_gb': round(item['size_total'] / 1024 / 1024 / 1024, 2) + }) + + return disk_list + +def build_interface_list(facts): + """Build simplified interface information""" + interfaces = [] + + # Get list of all interfaces + interface_names = facts.get('interfaces', []) + + for iface_name in interface_names: + # Skip loopback + if iface_name == 'lo': + continue + + # Get the interface details + iface_data = facts.get(iface_name, {}) + + if not iface_data or not isinstance(iface_data, dict): + continue + + interface_info = { + 'name': iface_name, + 'active': iface_data.get('active', False), + 'type': iface_data.get('type', 'unknown'), + 'ipv4': iface_data.get('ipv4', {}), + 'ipv6': iface_data.get('ipv6', []) + } + + interfaces.append(interface_info) + + return interfaces + +def run_module(): + # define available arguments/parameters a user can pass to the module + module_args = dict() + + # seed the result dict in the object + result = dict( + changed=False, + details=False, + ) + + # the AnsibleModule object will be our abstraction working with Ansible + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True + ) + + # if the user is working with this module in only check mode we do not + # want to make any changes to the environment, just return the current + # state with no modifications + if module.check_mode: + module.exit_json(**result) + + # Get the facts from the host + facts = ansible_facts(module) + + # Gather OS information... + result["os"] = {} + result["os"]["distribution"] = facts.get("distribution", "unknown") + result["os"]["distribution_version"] = facts.get("distribution_version", "unknown") + result["os"]["os_family"] = facts.get("os_family", "unknown") + result["os"]["kernel"] = facts.get("kernel", "unknown") + result["os"]["architecture"] = facts.get("architecture", "unknown") + result["os"]["hostname"] = facts.get("hostname", "unknown") + result["os"]["fqdn"] = facts.get("fqdn", "unknown") + + # Gather hardware information... + result["hardware"] = {} + result["hardware"]["cpu"] = {} + result["hardware"]["cpu"]["processor_count"] = facts.get("processor_count", 0) + result["hardware"]["cpu"]["processor_cores"] = facts.get("processor_cores", 0) + result["hardware"]["cpu"]["processor_vcpus"] = facts.get("processor_vcpus", 0) + result["hardware"]["cpu"]["processor_threads_per_core"] = facts.get("processor_threads_per_core", 0) + result["hardware"]["cpu"]["processor"] = facts.get("processor", []) + result["hardware"]["memory"] = {} + result["hardware"]["memory"]["memtotal_mb"] = facts.get("memtotal_mb", 0) + result["hardware"]["memory"]["memfree_mb"] = facts.get("memfree_mb", 0) + result["hardware"]["memory"]["swaptotal_mb"] = facts.get("swaptotal_mb", 0) + result["hardware"]["disk"] = build_disk_list(facts.get("mounts", [])) + + # Gather security information... + result["security"] = {} + result["security"]["selinux"] = facts.get("selinux", {"status": "not available"}) + + # Is firewalld running? + firewalld = facts.get('services', {}).get('firewalld.service') + if firewalld: + result["security"]["firewalld"] = firewalld + + # Gather networking information... + result["networking"] = {} + result["networking"]["interfaces"] = build_interface_list(facts) + result["networking"]["default_ipv4"] = facts.get("default_ipv4", {}) + result["networking"]["default_ipv6"] = facts.get("default_ipv6", {}) + + # in the event of a successful module execution, you will want to + # simple AnsibleModule.exit_json(), passing the key/value results + module.exit_json(**result) + +def main(): + run_module() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/roles/mongodb/defaults/main/install.yml b/roles/mongodb/defaults/main/install.yml index d8b46c46..6444e3d0 100644 --- a/roles/mongodb/defaults/main/install.yml +++ b/roles/mongodb/defaults/main/install.yml @@ -24,3 +24,7 @@ mongodb_mongod_service_delay: 10 # MongoDB status settings mongodb_status_poll: 3 mongodb_status_interval: 10 + +# The name and location of the certification report +mongodb_report_dir: "/tmp/itential-reports" +mongodb_report_file: "{{ mongodb_report_dir }}/mongodb_report_{{ inventory_hostname }}.md" diff --git a/roles/mongodb/tasks/certify-mongodb.yml b/roles/mongodb/tasks/certify-mongodb.yml new file mode 100644 index 00000000..30a72f6c --- /dev/null +++ b/roles/mongodb/tasks/certify-mongodb.yml @@ -0,0 +1,382 @@ +--- +# MongoDB Validation Tasks +# This playbook gathers information to validate MongoDB installation + +- name: Check if MongoDB service exists + ansible.builtin.systemd: + name: mongod + register: mongodb_service_status + ignore_errors: true + +- name: Check if MongoDB process is running + ansible.builtin.shell: ps aux | grep -v grep | grep mongod + register: mongodb_process + ignore_errors: true + changed_when: false + +- name: Check MongoDB listening ports + ansible.builtin.shell: ss -tulpn | grep mongod + register: mongodb_ports + ignore_errors: true + changed_when: false + +- name: Get MongoDB version + ansible.builtin.shell: mongod --version | head -n 1 + register: mongodb_version + ignore_errors: true + changed_when: false + +- name: Check MongoDB config file exists + ansible.builtin.stat: + path: /etc/mongod.conf + register: mongodb_conf_file + +- name: Get MongoDB config file permissions + ansible.builtin.command: ls -la /etc/mongod.conf + register: mongodb_conf_permissions + ignore_errors: true + changed_when: false + when: mongodb_conf_file.stat.exists + +- name: Read MongoDB configuration file + ansible.builtin.slurp: + src: /etc/mongod.conf + register: mongodb_config_content + when: mongodb_conf_file.stat.exists + ignore_errors: true + +- name: Parse MongoDB config for data directory + ansible.builtin.shell: grep -E '^\s*dbPath:' /etc/mongod.conf | awk '{print $2}' + register: mongodb_data_dir + ignore_errors: true + changed_when: false + when: mongodb_conf_file.stat.exists + +- name: Parse MongoDB config for log path + ansible.builtin.shell: grep -E '^\s*path:' /etc/mongod.conf | awk '{print $2}' + register: mongodb_log_path + ignore_errors: true + changed_when: false + when: mongodb_conf_file.stat.exists + +- name: Check MongoDB systemd unit file + ansible.builtin.stat: + path: /usr/lib/systemd/system/mongod.service + register: mongodb_systemd_file + +- name: Check data directory exists and permissions + ansible.builtin.stat: + path: "{{ mongodb_data_dir.stdout }}" + register: mongodb_data_dir_stat + when: mongodb_data_dir.stdout is defined and mongodb_data_dir.stdout != "" + ignore_errors: true + +- name: Get data directory size + ansible.builtin.command: du -sh {{ mongodb_data_dir.stdout }} + register: mongodb_data_size + ignore_errors: true + changed_when: false + when: mongodb_data_dir.stdout is defined and mongodb_data_dir.stdout != "" + +# ============================================================================ +# CONNECTIVITY TESTS +# ============================================================================ + +- name: Test basic MongoDB connection (no auth) + ansible.builtin.shell: | + mongosh --quiet --eval "db.adminCommand('ping')" 2>&1 + register: mongodb_ping_noauth + ignore_errors: true + changed_when: false + +- name: Test MongoDB connection with admin user + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "db.adminCommand('ping')" + register: mongodb_ping_admin + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +# ============================================================================ +# TLS/SSL CHECKS +# ============================================================================ + +- name: Check if TLS is enabled in config + ansible.builtin.shell: | + grep -E '^\s*mode:\s*requireTLS|^\s*mode:\s*preferTLS|^\s*mode:\s*allowTLS' /etc/mongod.conf + register: mongodb_tls_mode + ignore_errors: true + changed_when: false + when: mongodb_conf_file.stat.exists + +- name: Get TLS certificate path from config + ansible.builtin.shell: | + grep -E '^\s*certificateKeyFile:' /etc/mongod.conf | awk '{print $2}' + register: mongodb_tls_cert_path + ignore_errors: true + changed_when: false + when: mongodb_conf_file.stat.exists + +- name: Check TLS certificate file exists + ansible.builtin.stat: + path: "{{ mongodb_tls_cert_path.stdout }}" + register: mongodb_tls_cert_file + when: mongodb_tls_cert_path.stdout is defined and mongodb_tls_cert_path.stdout != "" + ignore_errors: true + +- name: Get TLS certificate expiration + ansible.builtin.shell: | + openssl x509 -in {{ mongodb_tls_cert_path.stdout }} -noout -enddate + register: mongodb_tls_cert_expiry + ignore_errors: true + changed_when: false + when: + - mongodb_tls_cert_path.stdout is defined + - mongodb_tls_cert_path.stdout != "" + - mongodb_tls_cert_file.stat.exists | default(false) + +- name: Test MongoDB connection with TLS + ansible.builtin.shell: | + mongosh --tls --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "db.adminCommand('ping')" + register: mongodb_ping_tls + ignore_errors: true + changed_when: false + no_log: true + when: + - mongodb_user_admin is defined + - mongodb_user_admin_password is defined + - mongodb_tls_mode.rc == 0 + +# ============================================================================ +# SERVER STATUS AND METRICS +# ============================================================================ + +- name: Get MongoDB server status + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(db.serverStatus())" + register: mongodb_server_status + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +- name: Get MongoDB build info + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(db.serverBuildInfo())" + register: mongodb_build_info + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +# ============================================================================ +# REPLICA SET STATUS +# ============================================================================ + +- name: Check if replica set is configured + ansible.builtin.shell: | + grep -E '^\s*replSetName:' /etc/mongod.conf | awk '{print $2}' + register: mongodb_replset_name + ignore_errors: true + changed_when: false + when: mongodb_conf_file.stat.exists + +- name: Get replica set status + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(rs.status())" + register: mongodb_replset_status + ignore_errors: true + changed_when: false + no_log: true + when: + - mongodb_user_admin is defined + - mongodb_user_admin_password is defined + - mongodb_replset_name.stdout is defined + - mongodb_replset_name.stdout != "" + +- name: Get replica set configuration + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(rs.conf())" + register: mongodb_replset_config + ignore_errors: true + changed_when: false + no_log: true + when: + - mongodb_user_admin is defined + - mongodb_user_admin_password is defined + - mongodb_replset_name.stdout is defined + - mongodb_replset_name.stdout != "" + +- name: Get replica set member details + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(rs.isMaster())" + register: mongodb_replset_ismaster + ignore_errors: true + changed_when: false + no_log: true + when: + - mongodb_user_admin is defined + - mongodb_user_admin_password is defined + - mongodb_replset_name.stdout is defined + - mongodb_replset_name.stdout != "" + +# ============================================================================ +# USER AUTHENTICATION TESTS +# ============================================================================ + +- name: Get list of MongoDB users + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin admin --eval "JSON.stringify(db.getUsers())" + register: mongodb_users_list + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +- name: Parse MongoDB users + ansible.builtin.set_fact: + mongodb_users: "{{ (mongodb_users_list.stdout | from_json).users | default([]) }}" + when: + - mongodb_users_list is defined + - mongodb_users_list.rc == 0 + - mongodb_users_list.stdout is defined + failed_when: false + +# Test individual users +- name: Test connection with itential user + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_itential }} -p {{ mongodb_user_itential_password }} \ + --authenticationDatabase "itential" --eval "db.adminCommand('ping')" + register: mongodb_user_itential_ping + ignore_errors: true + changed_when: false + # no_log: true + when: + - mongodb_user_itential is defined + - mongodb_user_itential_password is defined + +# ============================================================================ +# DATABASE INFORMATION +# ============================================================================ + +- name: List all databases + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(db.adminCommand('listDatabases'))" + register: mongodb_databases + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +- name: Get database stats + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(db.stats())" + register: mongodb_db_stats + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +# ============================================================================ +# LOGS +# ============================================================================ + +- name: Get recent MongoDB log entries + ansible.builtin.shell: | + if [ -f "{{ mongodb_log_path.stdout }}" ]; then + tail -n 100 {{ mongodb_log_path.stdout }} + else + journalctl -u mongod -n 100 --no-pager + fi + register: mongodb_logs + ignore_errors: true + changed_when: false + when: mongodb_log_path.stdout is defined or mongodb_service_status.status is defined + +# ============================================================================ +# SECURITY CHECKS +# ============================================================================ + +- name: Check if authentication is enabled + ansible.builtin.shell: | + grep -E '^\s*authorization:\s*enabled' /etc/mongod.conf + register: mongodb_auth_enabled + ignore_errors: true + changed_when: false + when: mongodb_conf_file.stat.exists + +- name: Check security settings + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(db.adminCommand({getCmdLineOpts: 1}))" + register: mongodb_security_settings + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +# ============================================================================ +# PERFORMANCE METRICS +# ============================================================================ + +- name: Get current operations + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "JSON.stringify(db.currentOp())" + register: mongodb_current_ops + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +- name: Get connection count + ansible.builtin.shell: | + mongosh --quiet -u {{ mongodb_user_admin }} -p {{ mongodb_user_admin_password }} \ + --authenticationDatabase admin --eval "db.serverStatus().connections" + register: mongodb_connections + ignore_errors: true + changed_when: false + no_log: true + when: mongodb_user_admin is defined and mongodb_user_admin_password is defined + +# ============================================================================ +# GATHER HOST INFORMATION +# ============================================================================ + +- name: Gather host information + itential.deployer.gather_host_information: + register: host_details + +# ============================================================================ +# GENERATE REPORT +# ============================================================================ +- name: Ensure report directory exists + ansible.builtin.file: + path: "/tmp/itential-reports" + state: directory + owner: "{{ mongodb_owner }}" + group: "{{ mongodb_group }}" + mode: "0755" + +- name: Generate MongoDB validation report + ansible.builtin.template: + src: mongodb-validation-report.md.j2 + dest: "{{ mongodb_report_file }}" + mode: '0644' + owner: "{{ mongodb_owner }}" + group: "{{ mongodb_group }}" + +- name: Display report location + ansible.builtin.debug: + msg: "MongoDB validation report generated at: {{ mongodb_report_file }}" diff --git a/roles/mongodb/templates/mongodb-validation-report.md.j2 b/roles/mongodb/templates/mongodb-validation-report.md.j2 new file mode 100644 index 00000000..bfddc215 --- /dev/null +++ b/roles/mongodb/templates/mongodb-validation-report.md.j2 @@ -0,0 +1,353 @@ +# MongoDB Installation Validation Report + +- **Generated:** {{ ansible_date_time.iso8601 | default('Unknown') }} +- **Hostname:** {{ inventory_hostname | default('Unknown') }} +- **IP Address:** {{ ansible_default_ipv4.address | default('N/A') }} +- **OS:** {{ ansible_distribution | default('Unknown') }} {{ ansible_distribution_version | default('') }} + +--- + +## Host Details + +{% if host_details is defined %} +### Operating System +- **Distribution:** {{ host_details.os.distribution | default('Unknown') }} {{ host_details.os.distribution_version | default('') }} +- **OS Family:** {{ host_details.os.os_family | default('Unknown') }} +- **Kernel:** {{ host_details.os.kernel | default('Unknown') }} +- **Architecture:** {{ host_details.os.architecture | default('Unknown') }} +- **Hostname:** {{ host_details.os.hostname | default('Unknown') }} +- **FQDN:** {{ host_details.os.fqdn | default('Unknown') }} + +### Hardware +- **CPU Count:** {{ host_details.hardware.cpu.processor_count | default('Unknown') }} +- **CPU Cores:** {{ host_details.hardware.cpu.processor_cores | default('Unknown') }} +- **CPU vCPUs:** {{ host_details.hardware.cpu.processor_vcpus | default('Unknown') }} +- **Threads per Core:** {{ host_details.hardware.cpu.processor_threads_per_core | default('Unknown') }} +- **Total Memory:** {{ host_details.hardware.memory.memtotal_mb | default('Unknown') }} MB +- **Free Memory:** {{ host_details.hardware.memory.memfree_mb | default('Unknown') }} MB +- **Swap Total:** {{ host_details.hardware.memory.swaptotal_mb | default('Unknown') }} MB + +### Disk Mounts +{% if host_details.hardware.disk is defined and host_details.hardware.disk | length > 0 %} +{% for disk in host_details.hardware.disk %} +- **{{ disk.mount }}:** {{ disk.size_gb }} GB +{% endfor %} +{% else %} +- No disk information available +{% endif %} + +### Networking +- **Primary IP:** {{ host_details.networking.default_ipv4.address | default('N/A') }} +- **Gateway:** {{ host_details.networking.default_ipv4.gateway | default('N/A') }} +- **Interface:** {{ host_details.networking.default_ipv4.interface | default('N/A') }} +- **MAC Address:** {{ host_details.networking.default_ipv4.macaddress | default('N/A') }} + +### Security +{% if host_details.security.selinux is defined %} +- **SELinux Status:** {{ host_details.security.selinux.status | default('Unknown') }} +- **SELinux Mode:** {{ host_details.security.selinux.mode | default('Unknown') }} +- **SELinux Type:** {{ host_details.security.selinux.type | default('Unknown') }} +{% endif %} +{% if host_details.security.firewalld is defined %} +- **Firewalld:** {{ host_details.security.firewalld.state | default('Unknown') }} +{% endif %} +{% else %} +Host details not available +{% endif %} + +--- + +## Service Status + +{% if mongodb_service_status is defined and mongodb_service_status.status is defined %} +- **Service Name:** {{ mongodb_service_status.name | default('Unknown') }} +- **Service State:** {{ mongodb_service_status.status.ActiveState | default('Unknown') }} +- **Service SubState:** {{ mongodb_service_status.status.SubState | default('Unknown') }} +- **Service Enabled:** {{ mongodb_service_status.status.UnitFileState | default('Unknown') }} +{% else %} +- **Service Status:** Could not determine (service may not exist) +{% endif %} + +**Process Running:** {{ 'YES ✓' if (mongodb_process is defined and mongodb_process.rc == 0) else 'NO ✗' }} + +{% if mongodb_process is defined and mongodb_process.rc == 0 %} +**Process Details:** +``` +{{ mongodb_process.stdout | default('N/A') }} +``` +{% endif %} + +--- + +## Connectivity + +**Connection Tests:** +- **No Auth:** {{ 'SUCCESS ✓' if (mongodb_ping_noauth is defined and mongodb_ping_noauth.rc == 0) else 'FAILED ✗' }} +- **Admin User:** {{ 'SUCCESS ✓' if (mongodb_ping_admin is defined and mongodb_ping_admin.rc == 0) else 'FAILED ✗' }} +{% if mongodb_tls_mode.rc == 0 %} +- **TLS Connection:** {{ 'SUCCESS ✓' if (mongodb_ping_tls is defined and mongodb_ping_tls.rc == 0) else 'FAILED ✗' }} +{% endif %} + +**Listening Ports:** +``` +{{ mongodb_ports.stdout | default('Could not determine') if mongodb_ports is defined else 'Could not determine' }} +``` + +--- + +## Version Information + +``` +{{ mongodb_version.stdout | default('Version information not available') if mongodb_version is defined else 'Version information not available' }} +``` + +{% if mongodb_build_info is defined and mongodb_build_info.rc == 0 %} +**Build Details:** +{% set build = mongodb_build_info.stdout | from_json %} +- **Version:** {{ build.version | default('Unknown') }} +- **Git Version:** {{ build.gitVersion | default('Unknown') }} +- **OpenSSL Version:** {{ build.openssl.running | default('Unknown') }} +{% endif %} + +--- + +## Configuration Files + +- **Config File Exists:** {{ 'YES ✓' if (mongodb_conf_file is defined and mongodb_conf_file.stat.exists) else 'NO ✗' }} +{% if mongodb_conf_file is defined and mongodb_conf_file.stat.exists %} +- **Config File Path:** `/etc/mongod.conf` +- **Permissions:** {{ mongodb_conf_permissions.stdout | default('Unknown') if mongodb_conf_permissions is defined else 'Unknown' }} +{% endif %} + +- **Systemd Unit File:** {{ 'YES ✓' if (mongodb_systemd_file is defined and mongodb_systemd_file.stat.exists) else 'NO ✗' }} +{% if mongodb_systemd_file is defined and mongodb_systemd_file.stat.exists %} +- **Unit File Path:** `/etc/systemd/system/mongod.service` +{% endif %} + +--- + +## Data Directory + +{% if mongodb_data_dir.stdout is defined and mongodb_data_dir.stdout != "" %} +- **Data Directory:** `{{ mongodb_data_dir.stdout }}` +- **Directory Exists:** {{ 'YES ✓' if (mongodb_data_dir_stat is defined and mongodb_data_dir_stat.stat.exists) else 'NO ✗' }} +{% if mongodb_data_size is defined and mongodb_data_size.rc == 0 %} +- **Data Size:** {{ mongodb_data_size.stdout }} +{% endif %} +{% else %} +Data directory not configured or could not be determined +{% endif %} + +--- + +## Log Configuration + +{% if mongodb_log_path.stdout is defined and mongodb_log_path.stdout != "" %} +- **Log Path:** `{{ mongodb_log_path.stdout }}` +{% else %} +Log path not configured or could not be determined (likely using journald) +{% endif %} + +--- + +## Security Configuration + +{% if mongodb_auth_enabled is defined and mongodb_auth_enabled.rc == 0 %} +- **Authentication:** ENABLED ✓ +{% else %} +- **Authentication:** DISABLED ✗ +{% endif %} + +### TLS/SSL Configuration + +{% if mongodb_tls_mode is defined and mongodb_tls_mode.rc == 0 %} +- **TLS Mode:** {{ mongodb_tls_mode.stdout | default('Not configured') }} +{% if mongodb_tls_cert_path.stdout is defined and mongodb_tls_cert_path.stdout != "" %} +- **Certificate Path:** `{{ mongodb_tls_cert_path.stdout }}` +- **Certificate Exists:** {{ 'YES ✓' if (mongodb_tls_cert_file is defined and mongodb_tls_cert_file.stat.exists) else 'NO' }} +{% if mongodb_tls_cert_expiry is defined and mongodb_tls_cert_expiry.rc == 0 %} +- **Certificate Expiration:** {{ mongodb_tls_cert_expiry.stdout }} +{% endif %} +{% endif %} +{% else %} +- **TLS:** Not configured ✗ +{% endif %} + +--- + +## User Authentication Tests + +{% if mongodb_users is defined and mongodb_users | length > 0 %} +**Configured Users:** +{% for user in mongodb_users %} +### User: {{ user.user | default('Unknown') }} +- **Database:** {{ user.db | default('Unknown') }} +- **Roles:** {{ user.roles | map(attribute='role') | list | join(', ') if user.roles is defined else 'None' }} +{% endfor %} + +**Connection Test Results:** +- **admin:** {{ 'PASSED ✓' if (mongodb_ping_admin is defined and mongodb_ping_admin.rc == 0) else 'FAILED ✗' }} +- **itential:** {{ 'PASSED ✓' if (mongodb_user_itential_ping is defined and mongodb_user_itential_ping.rc == 0) else 'FAILED ✗' }} +{% else %} +No users found or unable to retrieve user list +{% endif %} + +--- + +## Replica Set Configuration + +{% if mongodb_replset_name.stdout is defined and mongodb_replset_name.stdout != "" %} +**Replica Set Name:** {{ mongodb_replset_name.stdout }} + +{% if mongodb_replset_status is defined and mongodb_replset_status.rc == 0 %} +{% set rs_status = mongodb_replset_status.stdout | from_json %} +### Replica Set Status +- **Set Name:** {{ rs_status.set | default('Unknown') }} +{% if rs_status.date is defined %} +- **Date:** {{ rs_status.date }} +{% endif %} +- **My State:** {{ rs_status.myState | default('Unknown') }} + +### Members: +{% if rs_status.members is defined %} +{% for member in rs_status.members %} +#### {{ member.name | default('Unknown') }} +- **State:** {{ member.stateStr | default('Unknown') }} +- **Health:** {{ member.health | default('Unknown') }} +- **Uptime:** {{ member.uptime | default('Unknown') }} seconds +{% if member.optime is defined and member.optime.ts is defined %} +- **Optime:** {{ member.optime.ts }} +{% endif %} +{% if member.stateStr == 'PRIMARY' %} +- **Role:** PRIMARY ⭐ +{% elif member.stateStr == 'SECONDARY' %} +- **Role:** SECONDARY +{% endif %} + +{% endfor %} +{% endif %} +{% endif %} + +{% if mongodb_replset_config is defined and mongodb_replset_config.rc == 0 %} +{% set rs_config = mongodb_replset_config.stdout | from_json %} +### Replica Set Configuration +- **Config Version:** {{ rs_config.version | default('Unknown') }} +- **Protocol Version:** {{ rs_config.protocolVersion | default('Unknown') }} + +**Settings:** +- **Heartbeat Timeout:** {{ rs_config.settings.heartbeatTimeoutSecs | default('Unknown') }} seconds +- **Election Timeout:** {{ rs_config.settings.electionTimeoutMillis | default('Unknown') }} ms +- **Catchup Timeout:** {{ rs_config.settings.catchUpTimeoutMillis | default('Unknown') }} ms +{% if rs_config.members is defined %} +{% for member in rs_config.members %} +#### {{ member.host | default('Unknown') }} +- **arbiterOnly:** {{ member.arbiterOnly | default(false) }} +- **priority:** {{ member.priority | default(0) }} +- **hidden:** {{ member.hidden | default(false) }} +- **votes:** {{ member.votes | default(1) }} +{% endfor %} +{% endif %} +{% endif %} + +{% else %} +Replica set not configured +{% endif %} + +--- + +## Database Information + +{% if mongodb_databases is defined and mongodb_databases.rc == 0 %} +{% set db_list = mongodb_databases.stdout | from_json %} +**Total Databases:** {{ db_list.databases | length if db_list.databases is defined else 0 }} +{% if db_list.totalSize is defined %} +{% if db_list.totalSize is number %} +**Total Size:** {{ (db_list.totalSize / 1024 / 1024 / 1024) | round(2) }} GB +{% else %} +**Total Size:** Unknown +{% endif %} +{% endif %} + +### Databases: +{% if db_list.databases is defined %} +{% for db in db_list.databases %} +{% if db.sizeOnDisk is defined and db.sizeOnDisk is number %} +- **{{ db.name }}:** {{ (db.sizeOnDisk / 1024 / 1024) | round(2) }} MB +{% else %} +- **{{ db.name }}:** Size unknown +{% endif %} +{% endfor %} +{% endif %} +{% endif %} + +--- + +## Server Metrics + +{% if mongodb_server_status is defined and mongodb_server_status.rc == 0 %} +{% set server_status = mongodb_server_status.stdout | from_json %} + +### Connections +- **Current:** {{ server_status.connections.current | default('Unknown') }} +- **Available:** {{ server_status.connections.available | default('Unknown') }} +- **Total Created:** {{ server_status.connections.totalCreated | default('Unknown') }} + +### Memory +- **Resident:** {{ (server_status.mem.resident | default(0)) }} MB +- **Virtual:** {{ (server_status.mem.virtual | default(0)) }} MB + +### Network +{% if server_status.network.bytesIn is defined and server_status.network.bytesIn is number %} +- **Bytes In:** {{ (server_status.network.bytesIn / 1024 / 1024) | round(2) }} MB +{% else %} +- **Bytes In:** Unknown +{% endif %} +{% if server_status.network.bytesOut is defined and server_status.network.bytesOut is number %} +- **Bytes Out:** {{ (server_status.network.bytesOut / 1024 / 1024) | round(2) }} MB +{% else %} +- **Bytes Out:** Unknown +{% endif %} +{% endif %} + +--- + +## Recent Log Entries (Last 100 lines) + +``` +{{ mongodb_logs.stdout | default('Log entries not available') if mongodb_logs is defined else 'Log entries not available' }} +``` + +--- + +## Validation Summary + +**Overall Status:** {{ 'PASSED ✓' if (mongodb_ping_admin is defined and mongodb_ping_admin.rc == 0 and mongodb_process is defined and mongodb_process.rc == 0) else 'FAILED ✗' }} + +### Checks: + +{% if mongodb_service_status is defined and mongodb_service_status.status is defined %} +- **MongoDB Service Exists:** YES ✓ +- **MongoDB Service Active:** {{ mongodb_service_status.status.ActiveState | default('unknown') | upper }} {{ '✓' if mongodb_service_status.status.ActiveState == 'active' else '✗' }} +{% else %} +- **MongoDB Service Exists:** NO ✗ +{% endif %} +- **MongoDB Process Running:** {{ 'YES ✓' if (mongodb_process is defined and mongodb_process.rc == 0) else 'NO ✗' }} +- **MongoDB Responding:** {{ 'YES ✓' if (mongodb_ping_admin is defined and mongodb_ping_admin.rc == 0) else 'NO ✗' }} +- **Config File Present:** {{ 'YES ✓' if (mongodb_conf_file is defined and mongodb_conf_file.stat.exists) else 'NO ✗' }} +- **Authentication Enabled:** {{ 'YES ✓' if (mongodb_auth_enabled is defined and mongodb_auth_enabled.rc == 0) else 'NO ✗' }} +{% if mongodb_tls_mode is defined and mongodb_tls_mode.rc == 0 %} +- **TLS Configured:** YES ✓ +- **TLS Connection:** {{ 'SUCCESS ✓' if (mongodb_ping_tls is defined and mongodb_ping_tls.rc == 0) else 'FAILED ✗' }} +{% else %} +- **TLS Configured:** NO ✗ +{% endif %} +{% if mongodb_replset_name.stdout is defined and mongodb_replset_name.stdout != "" %} +- **Replica Set Configured:** YES ✓ +- **Replica Set Status:** {{ 'OK ✓' if (mongodb_replset_status is defined and mongodb_replset_status.rc == 0) else 'FAILED ✗' }} +{% else %} +- **Replica Set Configured:** NO ✗ +{% endif %} + +--- + +**End of Report** \ No newline at end of file diff --git a/roles/redis/defaults/main/install.yml b/roles/redis/defaults/main/install.yml index ac62dd8b..894a6afd 100644 --- a/roles/redis/defaults/main/install.yml +++ b/roles/redis/defaults/main/install.yml @@ -27,3 +27,7 @@ redis_remi_repo_url: "http://rpms.remirepo.net/enterprise/remi-release-\ {{ ansible_distribution_version }}.rpm" redis_epel_repo_url: "https://dl.fedoraproject.org/pub/epel/epel-release-latest-\ {{ ansible_distribution_major_version }}.noarch.rpm" + +# The name and location of the certification report +redis_report_dir: "/tmp/itential-reports" +redis_report_file: "{{ redis_report_dir }}/redis_report_{{ inventory_hostname }}.md" diff --git a/roles/redis/tasks/certify-redis.yml b/roles/redis/tasks/certify-redis.yml new file mode 100644 index 00000000..1e1b70bc --- /dev/null +++ b/roles/redis/tasks/certify-redis.yml @@ -0,0 +1,541 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- + +- name: Ensure report directory exists + ansible.builtin.file: + path: "{{ redis_report_dir }}" + state: directory + owner: "{{ redis_owner }}" + group: "{{ redis_group }}" + mode: "0755" + +- name: Gather host information + itential.deployer.gather_host_information: + register: host_details + +- name: Check if Redis service exists + ansible.builtin.systemd: + name: redis + register: redis_service_check + failed_when: false + changed_when: false + +- name: Get Redis service status + ansible.builtin.systemd: + name: redis + register: redis_service_status + when: redis_service_check.status is defined + +- name: Check Redis process + ansible.builtin.shell: set -o pipefail && ps aux | grep redis-server | grep -v grep + register: redis_process + failed_when: false + changed_when: false + +- name: Test Redis connectivity + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --user admin \ + -p {{ redis_port }} \ + -a "{{ redis_user_admin_password }}" \ + --no-auth-warning \ + PING + register: redis_ping + failed_when: false + changed_when: false + +- name: Get Redis version + ansible.builtin.shell: set -o pipefail && redis-server --version + register: redis_version + changed_when: false + +- name: Get Redis INFO + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --user admin \ + -p {{ redis_port }} \ + -a "{{ redis_user_admin_password }}" \ + --no-auth-warning \ + INFO + register: redis_info + when: redis_ping.rc == 0 + failed_when: false + changed_when: false + +- name: Get Redis configuration + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_port }} \ + -a "{{ redis_user_admin_password }}" \ + --no-auth-warning \ + CONFIG GET '*' + register: redis_config + when: redis_ping.rc == 0 + failed_when: false + changed_when: false + +- name: Check Redis configuration file + ansible.builtin.stat: + path: /etc/redis/redis.conf + register: redis_conf_file + +- name: Get Redis configuration file permissions + ansible.builtin.shell: set -o pipefail && ls -lh /etc/redis/redis.conf + register: redis_conf_permissions + when: redis_conf_file.stat.exists + changed_when: false + +- name: Check if Redis is using systemd + ansible.builtin.stat: + path: /usr/lib/systemd/system/redis.service + register: redis_systemd_file + +- name: Get listening ports + ansible.builtin.shell: set -o pipefail && netstat -tlnp | grep redis | ss -tlnp | grep redis + register: redis_ports + failed_when: false + changed_when: false + +- name: Check Redis log file + ansible.builtin.shell: | + set -o pipefail && + if [ -f /var/log/redis/redis.log ]; then + tail -50 /var/log/redis/redis.log + else + echo "Log file not found in standard location" + fi + register: redis_logs + changed_when: false + +- name: Parse Redis INFO for key metrics + ansible.builtin.set_fact: + redis_metrics: + version: "{{ redis_info.stdout | regex_search('redis_version:([^\\r\\n]+)', '\\1') }}" + os: "{{ redis_info.stdout | regex_search('os:([^\\r\\n]+)', '\\1') }}" + executable: "{{ redis_info.stdout | regex_search('executable:([^\\r\\n]+)', '\\1') }}" + config_file: "{{ redis_info.stdout | regex_search('config_file:([^\\r\\n]+)', '\\1') }}" + port: "{{ redis_info.stdout | regex_search('tcp_port:([^\\r\\n]+)', '\\1') }}" + role: "{{ redis_info.stdout | regex_search('role:([^\\r\\n]+)', '\\1') }}" + mode: "{{ redis_info.stdout | regex_search('redis_mode:([^\\r\\n]+)', '\\1') }}" + slaves: "{{ redis_info.stdout | regex_search('connected_slaves:([^\\r\\n]+)', '\\1') }}" + master_host: "{{ redis_info.stdout | regex_search('master_host:([^\\r\\n]+)', '\\1') }}" + master_port: "{{ redis_info.stdout | regex_search('master_port:([^\\r\\n]+)', '\\1') }}" + master_link: "{{ redis_info.stdout | regex_search('master_link_status:([^\\r\\n]+)', '\\1') }}" + clients: "{{ redis_info.stdout | regex_search('connected_clients:([^\\r\\n]+)', '\\1') }}" + bind_address: "{{ redis_config.results[2].stdout_lines[1] | default(['0.0.0.0'], true) }}" + users: "{{ redis_config.results[0].stdout_lines | default(['N/A'], true) }}" + when: redis_info.rc is defined and redis_info.rc == 0 + +- name: Get list of configured Redis users + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_port }} \ + -a "{{ redis_user_admin_password }}" \ + --no-auth-warning \ + ACL LIST + when: redis_info.rc is defined and redis_info.rc == 0 + register: redis_acl_list + no_log: false + failed_when: false + changed_when: false + +- name: Parse Redis ACL list into structured format + ansible.builtin.set_fact: + redis_users: >- + {%- set result = [] -%} + {%- for acl_entry in (redis_acl_list.stdout | from_json) -%} + {%- set parts = acl_entry.split() -%} + {%- if parts | length >= 3 and parts[0] == 'user' -%} + {%- set user_obj = {'user': parts[1], 'enabled': (parts[2] == 'on')} -%} + {%- set _ = result.append(user_obj) -%} + {%- endif -%} + {%- endfor -%} + {{ result }} + when: redis_info.rc is defined and redis_info.rc == 0 + +- name: Confirm "itential" user login + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --user itential \ + -p {{ redis_port }} \ + -a "{{ redis_user_itential_password }}" \ + --no-auth-warning \ + PING + register: redis_itential_user_ping + failed_when: false + changed_when: false + +- name: Confirm "repluser" user login + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --user repluser \ + -p {{ redis_port }} \ + -a "{{ redis_user_repluser_password }}" \ + --no-auth-warning \ + PING + register: redis_repl_user_ping + failed_when: false + changed_when: false + +- name: Confirm "sentineluser" user login + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --user sentineluser \ + -p {{ redis_port }} \ + -a "{{ redis_user_sentineluser_password }}" \ + --no-auth-warning \ + PING + register: redis_sentineluser_user_ping + failed_when: false + changed_when: false + +- name: Confirm "prometheus" user login + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --user prometheus \ + -p {{ redis_port }} \ + -a "{{ redis_user_prometheus_password }}" \ + --no-auth-warning \ + PING + register: redis_prometheus_user_ping + failed_when: false + changed_when: false + +# ========================================================================= +# SENTINEL DETECTION +# ========================================================================= + +- name: Check if Sentinel service exists + ansible.builtin.systemd: + name: redis-sentinel + register: sentinel_service_check + failed_when: false + changed_when: false + +- name: Check Sentinel process + ansible.builtin.shell: set -o pipefail && ps aux | grep redis-sentinel | grep -v grep + register: sentinel_process + failed_when: false + changed_when: false + +- name: Test Sentinel connectivity + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + PING + register: sentinel_ping + failed_when: false + changed_when: false + +- name: Set Sentinel detection fact + ansible.builtin.set_fact: + sentinel_is_running: "{{ sentinel_ping.rc == 0 and sentinel_process.rc == 0 }}" + +- name: Display Sentinel detection status + ansible.builtin.debug: + msg: "Sentinel detected: {{ sentinel_is_running }}" + +# ========================================================================= +# SENTINEL-SPECIFIC TASKS (Only run if Sentinel is detected) +# ========================================================================= + +- name: Get Sentinel service status + ansible.builtin.systemd: + name: redis-sentinel + register: sentinel_service_status + when: + - sentinel_is_running | bool + - sentinel_service_check.status is defined + failed_when: false + +- name: Get Sentinel INFO + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + INFO + register: sentinel_info + when: sentinel_is_running | bool + changed_when: false + +- name: Get Sentinel masters + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + SENTINEL MASTERS + register: sentinel_masters + when: sentinel_is_running | bool + changed_when: false + +- name: Capture itentialmaster + ansible.builtin.set_fact: + itential_master: "{{ (sentinel_masters.stdout | from_json)[0] }}" + +- name: Get details for each monitored master + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + SENTINEL MASTER {{ itential_master.name }} + register: monitored_master + # loop: "{{ master_names.stdout_lines | default([]) }}" + when: sentinel_is_running | bool + changed_when: false + +- name: Capture monitored master details + ansible.builtin.set_fact: + monitored_master_details: "{{ (monitored_master.stdout | from_json) }}" + +- name: Get known sentinels for each master + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + SENTINEL SENTINELS {{ itential_master.name }} + register: known_sentinels + when: sentinel_is_running | bool + failed_when: false + changed_when: false + +- name: Capture known sentinel details + ansible.builtin.set_fact: + known_sentinel_details: "{{ (known_sentinels.stdout | from_json) }}" + +- name: Get known replicas for each master + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + SENTINEL REPLICAS {{ itential_master.name }} + register: known_replicas + when: sentinel_is_running | bool + failed_when: false + changed_when: false + +- name: Capture known replica details + ansible.builtin.set_fact: + known_replica_details: "{{ (known_replicas.stdout | from_json) }}" + +- name: Check master status + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + SENTINEL CKQUORUM {{ itential_master.name }} + register: quorum_check + when: sentinel_is_running | bool + failed_when: false + changed_when: false + +- name: Capture known replica details + ansible.builtin.set_fact: + quorum_check_details: "{{ (quorum_check.stdout | from_json) }}" + +- name: Get Sentinel configuration + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + CONFIG GET '*' + register: sentinel_config + when: sentinel_is_running | bool + failed_when: false + changed_when: false + +- name: Check Sentinel configuration file + ansible.builtin.stat: + path: /etc/redis/sentinel.conf + register: sentinel_conf_file + when: sentinel_is_running | bool + +- name: Get Sentinel configuration file permissions + ansible.builtin.shell: set -o pipefail && ls -lh /etc/redis/sentinel.conf + register: sentinel_conf_permissions + when: + - sentinel_is_running | bool + - sentinel_conf_file.stat.exists | default(false) + changed_when: false + +- name: Check if Sentinel is using systemd + ansible.builtin.stat: + path: /usr/lib/systemd/system/redis-sentinel.service + register: sentinel_systemd_file + when: sentinel_is_running | bool + +- name: Get Sentinel listening ports + ansible.builtin.shell: | + set -o pipefail && netstat -tlnp | grep sentinel | ss -tlnp | grep sentinel + register: redis_sentinel_ports + when: sentinel_is_running | bool + failed_when: false + changed_when: false + +- name: Check Sentinel log file + ansible.builtin.shell: | + set -o pipefail && + if [ -f /var/log/redis/sentinel.log ]; then + tail -50 /var/log/redis/sentinel.log + else + echo "Log file not found in standard location" + fi + register: sentinel_logs + when: sentinel_is_running | bool + changed_when: false + +# For unknown reasons there are control characters (^M) at the end of the +# SENTINEL INFO values. This task will remove those characters. +- name: Remove control characters from output + ansible.builtin.set_fact: + sentinel_info_clean: "{{ sentinel_info.stdout | regex_replace('\\r', '') }}" + when: + - sentinel_is_running | bool + - sentinel_info.rc is defined + - sentinel_info.rc == 0 + +- name: Parse Sentinel INFO for key metrics + ansible.builtin.set_fact: + sentinel_metrics: + version: "{{ sentinel_info_clean | regex_search('redis_version:(.+)', '\\1') }}" + mode: "{{ sentinel_info_clean | regex_search('redis_mode:(.+)', '\\1') }}" + masters: "{{ sentinel_info_clean | regex_search('sentinel_masters:(.+)', '\\1') }}" + when: + - sentinel_is_running | bool + - sentinel_info.rc is defined + - sentinel_info.rc == 0 + +# ========================================================================= +# Confirm all expected Sentinel users +# ========================================================================= +- name: Get list of configured Sentinel users + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --json \ + --user admin \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineladmin_password }}" \ + --no-auth-warning \ + ACL LIST + when: + - sentinel_is_running | bool + - sentinel_info.rc is defined + - sentinel_info.rc == 0 + register: sentinel_acl_list + no_log: true + failed_when: false + changed_when: false + +- name: Parse Sentinel ACL list into structured format + ansible.builtin.set_fact: + sentinel_users: >- + {%- set result = [] -%} + {%- for acl_entry in (sentinel_acl_list.stdout | from_json) -%} + {%- set parts = acl_entry.split() -%} + {%- if parts | length >= 3 and parts[0] == 'user' -%} + {%- set user_obj = {'user': parts[1], 'enabled': (parts[2] == 'on')} -%} + {%- set _ = result.append(user_obj) -%} + {%- endif -%} + {%- endfor -%} + {{ result }} + when: + - sentinel_is_running | bool + - sentinel_acl_list.rc is defined + - sentinel_acl_list.rc == 0 + +- name: Verify the Sentinel user can login (not admin) + ansible.builtin.shell: | + set -o pipefail && + redis-cli \ + --user sentineluser \ + -p {{ redis_sentinel_port }} \ + -h "{{ inventory_hostname }}" \ + -a "{{ redis_user_sentineluser_password }}" \ + --no-auth-warning \ + PING + register: sentinel_user_ping + no_log: true + failed_when: false + changed_when: false + when: + - sentinel_is_running | bool + - sentinel_acl_list.rc is defined + - sentinel_acl_list.rc == 0 + +# ========================================================================= +# Generate the report +# ========================================================================= +- name: Generate validation report + ansible.builtin.template: + backup: true + dest: "{{ redis_report_file }}" + group: "{{ redis_group }}" + mode: "0665" + owner: "{{ redis_owner }}" + src: redis-validation-report.md.j2 + +- name: Display report summary + ansible.builtin.debug: + msg: + - "Redis validation complete for {{ inventory_hostname }}" + - "Overall Status: {{ 'PASSED ✓' if (redis_ping.rc == 0 and redis_process.rc == 0) else 'FAILED ✗' }}" + - "Report saved to: {{ redis_report_file }}" + +- name: Display report location + ansible.builtin.debug: + msg: "Full report available at: {{ redis_report_file }}" + # run_once: no diff --git a/roles/redis/templates/redis-validation-report.md.j2 b/roles/redis/templates/redis-validation-report.md.j2 new file mode 100644 index 00000000..665cce92 --- /dev/null +++ b/roles/redis/templates/redis-validation-report.md.j2 @@ -0,0 +1,355 @@ +# Redis Installation Validation Report + +- **Generated:** {{ ansible_date_time.iso8601 | default('Unknown') }} +- **Hostname:** {{ inventory_hostname | default('Unknown') }} +- **IP Address:** {{ ansible_default_ipv4.address | default('N/A') }} +- **OS:** {{ ansible_distribution | default('Unknown') }} {{ ansible_distribution_version | default('') }} + +--- + +## Host Details + +{% if host_details is defined %} +### Operating System +- **Distribution:** {{ host_details.os.distribution | default('Unknown') }} {{ host_details.os.distribution_version | default('') }} +- **OS Family:** {{ host_details.os.os_family | default('Unknown') }} +- **Kernel:** {{ host_details.os.kernel | default('Unknown') }} +- **Architecture:** {{ host_details.os.architecture | default('Unknown') }} +- **Hostname:** {{ host_details.os.hostname | default('Unknown') }} +- **FQDN:** {{ host_details.os.fqdn | default('Unknown') }} + +### Hardware +- **CPU Count:** {{ host_details.hardware.cpu.processor_count | default('Unknown') }} +- **CPU Cores:** {{ host_details.hardware.cpu.processor_cores | default('Unknown') }} +- **CPU vCPUs:** {{ host_details.hardware.cpu.processor_vcpus | default('Unknown') }} +- **Threads per Core:** {{ host_details.hardware.cpu.processor_threads_per_core | default('Unknown') }} +- **Total Memory:** {{ host_details.hardware.memory.memtotal_mb | default('Unknown') }} MB +- **Free Memory:** {{ host_details.hardware.memory.memfree_mb | default('Unknown') }} MB +- **Swap Total:** {{ host_details.hardware.memory.swaptotal_mb | default('Unknown') }} MB + +### Disk Mounts +{% if host_details.hardware.disk is defined and host_details.hardware.disk | length > 0 %} +{% for disk in host_details.hardware.disk %} +- **{{ disk.mount }}:** {{ disk.size_gb }} GB +{% endfor %} +{% else %} +- No disk information available +{% endif %} + +### Networking +- **Primary IP:** {{ host_details.networking.default_ipv4.address | default('N/A') }} +- **Gateway:** {{ host_details.networking.default_ipv4.gateway | default('N/A') }} +- **Interface:** {{ host_details.networking.default_ipv4.interface | default('N/A') }} +- **MAC Address:** {{ host_details.networking.default_ipv4.macaddress | default('N/A') }} + +### Security +{% if host_details.security.selinux is defined %} +- **SELinux Status:** {{ host_details.security.selinux.status | default('Unknown') }} +- **SELinux Mode:** {{ host_details.security.selinux.mode | default('Unknown') }} +- **SELinux Type:** {{ host_details.security.selinux.type | default('Unknown') }} +{% endif %} +{% if host_details.security.firewalld is defined %} +- **Firewalld:** {{ host_details.security.firewalld.state | default('Unknown') }} +{% endif %} +{% else %} +Host details not available +{% endif %} + +--- + +## Service Status + +{% if redis_service_status is defined and redis_service_status.status is defined %} +- **Service Name:** {{ redis_service_status.name | default('Unknown') }} +- **Service State:** {{ redis_service_status.status.ActiveState | default('Unknown') }} +- **Service SubState:** {{ redis_service_status.status.SubState | default('Unknown') }} +- **Service Enabled:** {{ redis_service_status.status.UnitFileState | default('Unknown') }} +{% else %} +- **Service Status:** Could not determine (service may not exist) +{% endif %} + +**Process Running:** {{ 'YES ✓' if (redis_process is defined and redis_process.rc == 0) else 'NO' }} + +{% if redis_process is defined and redis_process.rc == 0 %} +**Process Details:** +``` +{{ redis_process.stdout | default('N/A') }} +``` +{% endif %} + +--- + +## Connectivity + +- **Redis Port:** {{ redis_port | default('6379') }} +- **Ping Response:** {{ redis_ping.stdout | default('FAILED') if redis_ping is defined else 'FAILED' }} +- **Connection Status:** {{ 'SUCCESS ✓' if (redis_ping is defined and redis_ping.rc == 0) else 'FAILED' }} + +**Listening Ports:** +``` +{{ redis_ports.stdout | default('Could not determine') if redis_ports is defined else 'Could not determine' }} +``` + +--- + +## Version Information + +``` +{{ redis_version.stdout | default('Version information not available') if redis_version is defined else 'Version information not available' }} +``` + +--- + +## Redis Metrics (from INFO) + +{% if redis_metrics is defined %} +- **OS:** {{ redis_metrics.os | default(['N/A'], true) | first }} +- **Redis Version:** {{ redis_metrics.version | default(['N/A'], true) | first }} +- **Executable:** {{ redis_metrics.executable | default(['N/A'], true) | first }} +- **Redis Mode:** {{ redis_metrics.mode | default(['N/A'], true) | first }} +- **Role:** {{ redis_metrics.role | default(['N/A'], true) | first }} +- **Connected Clients:** {{ redis_metrics.clients | default(['N/A'], true) | first }} +- **Connected Slaves:** {{ redis_metrics.slaves | default(['0'], true) | first }} +- **Master Host:** {{ redis_metrics.master_host | default(['N/A'], true) | first }} +- **Master Port:** {{ redis_metrics.master_port | default(['N/A'], true) | first }} +- **Master Connection:** {{ redis_metrics.master_link | default(['N/A'], true) | first }} +{% else %} +Redis INFO not available - check connectivity and authentication +{% endif %} + +--- + +## Configuration File + +- **Config File Exists:** {{ redis_conf_file.stat.exists | default('Unknown') if redis_conf_file is defined else 'Unknown' }} +{% if redis_conf_file is defined and redis_conf_file.stat.exists %} +- **Config File Path:** `/etc/redis/redis.conf` +- **Permissions:** {{ redis_conf_permissions.stdout | default('Unknown') if redis_conf_permissions is defined else 'Unknown' }} +{% endif %} + +- **Systemd Unit File:** {{ redis_systemd_file.stat.exists | default('Unknown') if redis_systemd_file is defined else 'Unknown' }} +{% if redis_systemd_file is defined and redis_systemd_file.stat.exists %} +- **Unit File Path:** `/etc/systemd/system/redis.service` +{% endif %} + +--- + +## Redis User Auth Tests + +**The following users were found:** + +{% if redis_users is defined and redis_users | length > 0 %} +{% for redis in redis_users %} +### User: {{ redis.user | default('Unknown') }} +- **Enabled:** {{ redis.enabled | default(false) }} +{% endfor %} + +### User Connection Test Results: +- **admin:** {{ 'PASSED ✓' if (redis_ping is defined and redis_ping.rc == 0) else 'FAILED ✗' }} +- **itential:** {{ 'PASSED ✓' if (redis_itential_user_ping is defined and redis_itential_user_ping.rc == 0) else 'FAILED ✗' }} +- **repluser:** {{ 'PASSED ✓' if (redis_repl_user_ping is defined and redis_repl_user_ping.rc == 0) else 'FAILED ✗' }} +- **sentineluser:** {{ 'PASSED ✓' if (redis_sentineluser_user_ping is defined and redis_sentineluser_user_ping.rc == 0) else 'FAILED ✗' }} +- **prometheus:** {{ 'PASSED ✓' if (redis_prometheus_user_ping is defined and redis_prometheus_user_ping.rc == 0) else 'FAILED ✗' }} +{% else %} +No Redis users were found! +{% endif %} + +--- + +## Recent Log Entries (Last 50 lines) + +``` +{{ redis_logs.stdout | default('Log entries not available') if redis_logs is defined else 'Log entries not available' }} +``` + +{% if sentinel_service_status is defined and sentinel_service_status.status is defined %} +--- + +## Sentinel Service Status + +- **Service Name:** {{ sentinel_service_status.name | default('Unknown') }} +- **Service State:** {{ sentinel_service_status.status.ActiveState | default('Unknown') }} +- **Service SubState:** {{ sentinel_service_status.status.SubState | default('Unknown') }} +- **Service Enabled:** {{ sentinel_service_status.status.UnitFileState | default('Unknown') }} + +**Process Running:** {{ 'YES ✓' if (sentinel_process is defined and sentinel_process.rc == 0) else 'NO' }} + +{% if sentinel_process is defined and sentinel_process.rc == 0 %} +**Process Details:** +``` +{{ sentinel_process.stdout | default('N/A') }} +``` +{% endif %} + +--- + +## Sentinel Connectivity + +- **Sentinel Port:** {{ redis_sentinel_port | default('26379') }} +- **Ping Response:** {{ sentinel_ping.stdout | default('FAILED') if sentinel_ping is defined else 'FAILED' }} +- **Connection Status:** {{ 'SUCCESS ✓' if (sentinel_ping is defined and sentinel_ping.rc == 0) else 'FAILED' }} + +**Listening Ports:** +``` +{{ sentinel_ports.stdout | default('Could not determine') if sentinel_ports is defined else 'Could not determine' }} +``` + +--- + +## Sentinel Metrics (from INFO) + +{% if sentinel_metrics is defined %} +- **Redis Version:** {{ sentinel_metrics.version | default(['N/A'], true) | first }} +- **Redis Mode:** {{ sentinel_metrics.mode | default(['N/A'], true) | first }} +- **Monitored Masters:** {{ sentinel_metrics.masters | default(['N/A'], true) | first }} +{% else %} +Sentinel INFO not available +{% endif %} + +--- + +## Sentinel Master + +{% if itential_master is defined and itential_master.name is defined and itential_master.name == "itentialmaster" %} +**Number of Masters:** 1 +**Master Name:** {{ itential_master.name }} + +{% if monitored_master_details is defined %} +### Master Details: +- **IP:** {{ monitored_master_details.ip | default('N/A') }} +- **Connected Slaves:** {{ monitored_master_details["num-slaves"] | default('N/A') }} +- **Port:** {{ monitored_master_details.port | default('N/A') }} +- **Quorum:** {{ monitored_master_details.quorum | default('N/A') }} +- **Down After (ms):** {{ monitored_master_details["down-after-milliseconds"] | default('N/A') }} +- **Failover Timeout:** {{ monitored_master_details["failover-timeout"] | default('N/A') }} +- **Parallel Syncs:** {{ monitored_master_details["parallel-syncs"] | default('N/A') }} +{% endif %} +{% else %} +No masters are being monitored +{% endif %} + +--- + +## Sentinel Quorum Status + +{% if quorum_check_details is defined and quorum_check_details %} +- **Master:** {{ itential_master.name | default('Unknown') if itential_master is defined else 'Unknown' }} +- **Status:** {{ quorum_check_details }} +{% else %} +No quorum was found! +{% endif %} + +--- + +## Sentinel Known Sentinels + +**This Sentinel is aware of the following other Sentinels:** + +{% if known_sentinel_details is defined and known_sentinel_details | length > 0 %} +{% for sentinel in known_sentinel_details %} +### Sentinel: {{ sentinel.name | default('N/A') }} +- **IP:** {{ sentinel.ip | default('N/A') }} +- **Runid:** {{ sentinel.runid | default('N/A') }} +- **Port:** {{ sentinel.port | default('N/A') }} +- **Flags:** {{ sentinel.flags | default('N/A') }} + +{% endfor %} +{% else %} +No other Sentinels were found! +{% endif %} + +--- + +## Sentinel Known Replicas + +**This Sentinel is aware of the following Replicas:** + +{% if known_replica_details is defined and known_replica_details | length > 0 %} +{% for replica in known_replica_details %} +### Replica: {{ replica.name | default('N/A') }} +- **Master:** {{ replica["master-host"] | default('N/A') }}:{{ replica["master-port"] | default('N/A') }} +- **Master Connectivity:** {{ replica["master-link-status"] | default('N/A') }} +- **Replica Role:** {{ replica["role-reported"] | default('N/A') }} +- **Flags:** {{ replica.flags | default('N/A') }} + +{% endfor %} +{% else %} +No other Replicas were found! +{% endif %} + +--- + +## Sentinel Configuration File + +- **Config File Exists:** {{ sentinel_conf_file.stat.exists | default('Unknown') if sentinel_conf_file is defined else 'Unknown' }} +{% if sentinel_conf_file is defined and sentinel_conf_file.stat.exists | default(false) %} +- **Config File Path:** `/etc/redis/sentinel.conf` +- **Permissions:** {{ sentinel_conf_permissions.stdout | default('Unknown') if sentinel_conf_permissions is defined else 'Unknown' }} +{% endif %} + +- **Systemd Unit File:** {{ sentinel_systemd_file.stat.exists | default('Unknown') if sentinel_systemd_file is defined else 'Unknown' }} +{% if sentinel_systemd_file is defined and sentinel_systemd_file.stat.exists | default(false) %} +- **Unit File Path:** `/etc/systemd/system/redis-sentinel.service` +{% endif %} + +--- + +## Sentinel User Auth Tests + +**The following users were found:** + +{% if sentinel_users is defined and sentinel_users | length > 0 %} +{% for sentinel in sentinel_users %} +### User: {{ sentinel.user | default('Unknown') }} +- **Enabled:** {{ sentinel.enabled | default(false) }} +{% endfor %} + +### Connection Test Results: +- **admin:** {{ 'PASSED ✓' if (sentinel_ping is defined and sentinel_ping.rc == 0) else 'FAILED ✗' }} +- **sentineluser:** {{ 'PASSED ✓' if (sentinel_user_ping is defined and sentinel_user_ping.rc == 0) else 'FAILED ✗' }} +{% else %} +No Sentinel users were found! +{% endif %} + +--- + +## Sentinel Recent Log Entries (Last 50 lines) + +``` +{{ sentinel_logs.stdout | default('Log entries not available') if sentinel_logs is defined else 'Log entries not available' }} +``` + +{% endif %} + +--- + +## Validation Summary + +**Overall Status:** {{ 'PASSED ✓' if (redis_ping is defined and redis_ping.rc == 0 and redis_process is defined and redis_process.rc == 0) else 'FAILED' }} + +### Checks: + +{% if redis_service_status is defined and redis_service_status.status is defined %} +- **Redis Service Exists:** YES ✓ +- **Redis Service Active:** {{ redis_service_status.status.ActiveState | default('unknown') | upper }} {{ '✓' if redis_service_status.status.ActiveState == 'active' else '✗' }} +{% else %} +- **Redis Service Exists:** NO ✗ +{% endif %} +- **Redis Process Running:** {{ 'YES' if (redis_process is defined and redis_process.rc == 0) else 'NO' }} {{ '✓' if (redis_process is defined and redis_process.rc == 0) else '✗' }} +- **Redis Responding:** {{ 'YES' if (redis_ping is defined and redis_ping.rc == 0) else 'NO' }} {{ '✓' if (redis_ping is defined and redis_ping.rc == 0) else '✗' }} +- **Redis Config File Present:** {{ 'YES' if (redis_conf_file is defined and redis_conf_file.stat.exists) else 'NO' }} {{ '✓' if (redis_conf_file is defined and redis_conf_file.stat.exists) else '✗' }} +{% if sentinel_service_status is defined and sentinel_service_status.status is defined %} +- **Sentinel Service Exists:** YES ✓ +- **Sentinel Service Active:** {{ sentinel_service_status.status.ActiveState | default('unknown') | upper }} {{ '✓' if sentinel_service_status.status.ActiveState == 'active' else '✗' }} +- **Sentinel Process Running:** {{ 'YES' if (sentinel_process is defined and sentinel_process.rc == 0) else 'NO' }} {{ '✓' if (sentinel_process is defined and sentinel_process.rc == 0) else '✗' }} +- **Sentinel Responding:** {{ 'YES' if (sentinel_ping is defined and sentinel_ping.rc == 0) else 'NO' }} {{ '✓' if (sentinel_ping is defined and sentinel_ping.rc == 0) else '✗' }} +- **Sentinel Config File Present:** {{ 'YES' if (sentinel_conf_file is defined and sentinel_conf_file.stat.exists | default(false)) else 'NO' }} {{ '✓' if (sentinel_conf_file is defined and sentinel_conf_file.stat.exists | default(false)) else '✗' }} +- **Sentinel Monitoring:** {{ ([itential_master.name] if itential_master is defined and itential_master.name is defined else []) | length }} master(s) {{ '✓' if (itential_master is defined and itential_master.name is defined) else '✗' }} +- **Sentinel Quorum:** {{ 'OK' if (quorum_check_details is defined and 'OK' in quorum_check_details) else 'FAILED' }} {{ '✓' if (quorum_check_details is defined and 'OK' in quorum_check_details) else '✗' }} +{% else %} +- **Sentinel Service:** NOT RUNNING OR NOT DETECTED +{% endif %} + +--- + +**End of Report** \ No newline at end of file