diff --git a/lib/ansible/modules/cloud/vmware/vmware_host_snmp.py b/lib/ansible/modules/cloud/vmware/vmware_host_snmp.py new file mode 100644 index 00000000000..31cf061a0b1 --- /dev/null +++ b/lib/ansible/modules/cloud/vmware/vmware_host_snmp.py @@ -0,0 +1,491 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Christian Kotte +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: vmware_host_snmp +short_description: Configures SNMP on an ESXi host system +description: +- This module can be used to configure the embedded SNMP agent on an ESXi host. +version_added: 2.8 +author: +- Christian Kotte (@ckotte) +notes: +- Tested on vSphere 6.5 +- You need to reset the agent (to factory defaults) if you want to clear all community strings, trap targets, or filters +- SNMP v3 configuration isn't implemented yet +requirements: +- python >= 2.6 +- PyVmomi +options: + state: + description: + - Enable, disable, or reset the SNMP agent. + type: str + choices: [ enabled, disabled, reset ] + default: 'disabled' + community: + description: + - List of SNMP community strings. + type: list + snmp_port: + description: + - Port used by the SNMP agent. + type: int + default: 161 + trap_targets: + description: + - A list of trap targets. + - You need to use C(hostname), C(port), and C(community) for each trap target. + default: [] + trap_filter: + description: + - Comma separated list of trap oids for traps not to be sent by agent. + - E.g. [ 1.3.6.1.4.1.6876.4.1.1.0, 1.3.6.1.4.1.6876.4.1.1.1 ] + - Use value C(reset) to clear settings. + type: str + send_trap: + description: + - Send a test trap to validate the configuration. + type: bool + default: false + hw_source: + description: + - Source hardware events from IPMI sensors or CIM Indications. + - The embedded SNMP agent receives hardware events either from IPMI sensors C(sensors) or CIM indications C(indications). + type: str + choices: [ indications, sensors ] + default: 'indications' + log_level: + description: + - Syslog logging level. + type: str + choices: [ debug, info, warning, error ] + default: 'info' +extends_documentation_fragment: vmware.documentation +''' + +EXAMPLES = r''' +- name: Enable and configure SNMP community + vmware_host_snmp: + hostname: '{{ esxi_hostname }}' + username: '{{ esxi_username }}' + password: '{{ esxi_password }}' + community: [ test ] + state: enabled + validate_certs: no + delegate_to: localhost + +- name: Configure SNMP traps and filters + vmware_host_snmp: + hostname: '{{ esxi_hostname }}' + username: '{{ esxi_username }}' + password: '{{ esxi_password }}' + community: [ test ] + trap_targets: + - hostname: 192.168.1.100 + port: 162 + community: test123 + - hostname: 192.168.1.101 + port: 162 + community: test1234 + trap_filter: + - 1.3.6.1.4.1.6876.4.1.1.0 + - 1.3.6.1.4.1.6876.4.1.1.1 + state: enabled + validate_certs: no + delegate_to: localhost + +- name: Disable SNMP + vmware_host_snmp: + hostname: '{{ esxi_hostname }}' + username: '{{ esxi_username }}' + password: '{{ esxi_password }}' + state: disabled + validate_certs: no + delegate_to: localhost +''' + +RETURN = r''' +results: + description: metadata about host system's SNMP configuration + returned: always + type: dict + sample: { + "esxi01": { + "changed": false, + "community": ["test"], + "hw_source": "indications", + "msg": "SNMP already configured properly", + "port": 161, + "state": "enabled", + "trap_targets": [] + }, + } +''' + +try: + from pyVmomi import vim +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.vmware import PyVmomi, vmware_argument_spec, find_obj +from ansible.module_utils._text import to_native + + +class VmwareHostSnmp(PyVmomi): + """Manage SNMP configuration for an ESXi host system""" + + def __init__(self, module): + super(VmwareHostSnmp, self).__init__(module) + + if self.is_vcenter(): + self.module.fail_json( + msg="You have to connect directly to the ESXi host. " + "It's not possible to configure SNMP through a vCenter connection." + ) + else: + self.host = find_obj(self.content, [vim.HostSystem], None) + if self.host is None: + self.module.fail_json(msg="Failed to find host system.") + + def ensure(self): + """Manage SNMP configuration for an ESXi host system""" + results = dict(changed=False, result=dict()) + snmp_state = self.params.get('state') + snmp_port = self.params.get('snmp_port') + community = self.params.get('community') + desired_trap_targets = self.params.get("trap_targets") + hw_source = self.params.get("hw_source") + log_level = self.params.get("log_level") + send_trap = self.params.get("send_trap") + trap_filter = self.params.get("trap_filter") + event_filter = None + if trap_filter: + event_filter = ';'.join(trap_filter) + changed = False + reset_hint = None + changed_list = [] + results = dict(msg='') + + snmp_system = self.host.configManager.snmpSystem + if snmp_system: + if snmp_system.configuration: + snmp_config_spec = snmp_system.configuration + else: + self.module.fail_json(msg="SNMP agent configuration isn't supported on the ESXi host") + else: + self.module.fail_json(msg="SNMP system isn't available on the ESXi host") + + # Check state + results['state'] = snmp_state + if snmp_state == 'reset': + changed = True + # Get previous config + if snmp_config_spec.enabled: + results['state_previous'] = 'enabled' + else: + results['state_previous'] = 'disabled' + results['port_previous'] = snmp_config_spec.port + results['community_previous'] = snmp_config_spec.readOnlyCommunities + results['trap_targets_previous'] = self.get_previous_targets(snmp_config_spec.trapTargets) + for option in snmp_config_spec.option: + if option.key == 'EnvEventSource' and option.value != hw_source: + results['hw_source_previous'] = option.value + if option.key == 'loglevel' and option.value != hw_source: + results['log_level_previous'] = option.value + if option.key == 'EventFilter' and option.value != hw_source: + results['trap_filter_previous'] = option.value.split(';') + # Build factory default config + destination = vim.host.SnmpSystem.SnmpConfigSpec.Destination() + destination.hostName = "" + destination.port = 0 + destination.community = "" + options = [] + options.append(self.create_option('EnvEventSource', 'indications')) + options.append(self.create_option('EventFilter', 'reset')) + snmp_config_spec = vim.host.SnmpSystem.SnmpConfigSpec() + # Looks like this value is causing the reset + snmp_config_spec.readOnlyCommunities = [""] + snmp_config_spec.trapTargets = [destination] + snmp_config_spec.port = 161 + snmp_config_spec.enabled = False + snmp_config_spec.option = options + else: + if snmp_state == 'enabled' and not snmp_config_spec.enabled: + changed = True + changed_list.append("state") + results['state_previous'] = 'disabled' + snmp_config_spec.enabled = True + elif snmp_state == 'disabled' and snmp_config_spec.enabled: + changed = True + changed_list.append("state") + results['state_previous'] = 'enabled' + snmp_config_spec.enabled = False + + # Check port + results['port'] = snmp_port + if snmp_config_spec.port != snmp_port: + changed = True + changed_list.append("port") + results['port_previous'] = snmp_config_spec.port + snmp_config_spec.port = snmp_port + + # Check read-only community strings + results['community'] = community + if snmp_config_spec.readOnlyCommunities != community: + changed = True + changed_list.append("community list") + results['community_previous'] = snmp_config_spec.readOnlyCommunities + if community: + snmp_config_spec.readOnlyCommunities = community + else: + # Doesn't work. Need to reset config instead + # snmp_config_spec.readOnlyCommunities = [] + reset_hint = True + + # Check trap targets + results['trap_targets'] = desired_trap_targets + if snmp_config_spec.trapTargets: + if desired_trap_targets: + temp_desired_targets = [] + # Loop through desired targets + for target in desired_trap_targets: + dest_hostname, dest_port, dest_community = self.check_if_options_are_valid(target) + trap_target_found = False + for trap_target in snmp_config_spec.trapTargets: + if trap_target.hostName == dest_hostname: + if trap_target.port != dest_port or trap_target.community != dest_community: + changed = True + changed_list.append("trap target '%s'" % dest_hostname) + trap_target_found = True + break + if not trap_target_found: + changed = True + changed_list.append("trap target '%s'" % dest_hostname) + # Build destination and add to temp target list + destination = self.build_destination(dest_hostname, dest_port, dest_community) + temp_desired_targets.append(destination) + # Loop through existing targets to find targets that need to be deleted + for trap_target in snmp_config_spec.trapTargets: + target_found = False + for target in desired_trap_targets: + if trap_target.hostName == target.get('hostname'): + target_found = True + break + if not target_found: + changed = True + changed_list.append("trap target '%s'" % trap_target.hostName) + # Configure trap targets if something has changed + if changed: + results['trap_targets_previous'] = self.get_previous_targets(snmp_config_spec.trapTargets) + snmp_config_spec.trapTargets = temp_desired_targets + else: + changed = True + changed_list.append("trap targets") + results['trap_targets_previous'] = self.get_previous_targets(snmp_config_spec.trapTargets) + # Doesn't work. Need to reset config instead + # snmp_config_spec.trapTargets = [] + reset_hint = True + else: + if desired_trap_targets: + changed = True + changed_list.append("trap targets") + results['trap_targets_previous'] = None + desired_targets = [] + for target in desired_trap_targets: + dest_hostname, dest_port, dest_community = self.check_if_options_are_valid(target) + destination = self.build_destination(dest_hostname, dest_port, dest_community) + desired_targets.append(destination) + snmp_config_spec.trapTargets = desired_targets + + # Check options + results['hw_source'] = hw_source + results['log_level'] = log_level + results['trap_filter'] = trap_filter + event_filter_found = False + if snmp_config_spec.option: + for option in snmp_config_spec.option: + if option.key == 'EnvEventSource' and option.value != hw_source: + changed = True + changed_list.append("HW source") + results['hw_source_previous'] = option.value + option.value = hw_source + if option.key == 'loglevel' and option.value != log_level: + changed = True + changed_list.append("log level") + results['log_level_previous'] = option.value + option.value = log_level + if option.key == 'EventFilter': + event_filter_found = True + if event_filter and option.value != event_filter: + changed = True + changed_list.append("trap filter") + results['trap_filter_previous'] = option.value.split(';') + option.value = event_filter + if trap_filter and not event_filter_found: + changed = True + changed_list.append("trap filter") + results['trap_filter_previous'] = [] + snmp_config_spec.option.append(self.create_option('EventFilter', event_filter)) + elif not trap_filter and event_filter_found: + changed = True + changed_list.append("trap filter") + # options = [] + for option in snmp_config_spec.option: + if option.key == 'EventFilter': + results['trap_filter_previous'] = option.value.split(';') + # else: + # options.append(option) + # Doesn't work. Need to reset config instead + # snmp_config_spec.option = options + reset_hint = True + + if changed: + if snmp_state == 'reset': + if self.module.check_mode: + message = "SNMP agent would be reset to factory defaults" + else: + message = "SNMP agent config reset to factory defaults" + else: + if self.module.check_mode: + changed_suffix = ' would be changed' + else: + changed_suffix = ' changed' + if len(changed_list) > 2: + message = ', '.join(changed_list[:-1]) + ', and ' + str(changed_list[-1]) + elif len(changed_list) == 2: + message = ' and '.join(changed_list) + elif len(changed_list) == 1: + message = changed_list[0] + message = "SNMP " + message + changed_suffix + if reset_hint: + message += ". Agent reset required!" + if not self.module.check_mode: + try: + snmp_system.ReconfigureSnmpAgent(snmp_config_spec) + except vim.fault.NotFound as not_found: + self.module.fail_json( + msg="Not found : %s" % to_native(not_found) + ) + except vim.fault.InsufficientResourcesFault as insufficient_resources: + self.module.fail_json( + msg="Insufficient resources : %s" % to_native(insufficient_resources) + ) + else: + message = "SNMP already configured properly" + if not snmp_state == 'reset' and send_trap and desired_trap_targets: + # Check if there was a change before + if changed: + message += " and " + else: + message += ", but " + changed = True + if self.module.check_mode: + message = message + "a test trap would be sent" + else: + try: + snmp_system.SendTestNotification() + message = message + "a test trap was sent" + except vim.fault.NotFound as not_found: + self.module.fail_json( + msg="Error during trap test : Not found : %s" % to_native(not_found) + ) + except vim.fault.InsufficientResourcesFault as insufficient_resources: + self.module.fail_json( + msg="Error during trap test : Insufficient resources : %s" % to_native(insufficient_resources) + ) + results['changed'] = changed + results['msg'] = message + + self.module.exit_json(**results) + + @staticmethod + def create_option(key, value): + """Create option""" + option = vim.KeyValue() + option.key = key + option.value = value + return option + + @staticmethod + def get_previous_targets(trap_targets): + """Get target entries from trap targets object""" + previous_targets = [] + for target in trap_targets: + temp = dict() + temp['hostname'] = target.hostName + temp['port'] = target.port + temp['community'] = target.community + previous_targets.append(temp) + return previous_targets + + @staticmethod + def build_destination(dest_hostname, dest_port, dest_community): + """Build destination spec""" + destination = vim.host.SnmpSystem.SnmpConfigSpec.Destination() + destination.hostName = dest_hostname + destination.port = dest_port + destination.community = dest_community + return destination + + def check_if_options_are_valid(self, target): + """Check if options are valid""" + dest_hostname = target.get('hostname', None) + if dest_hostname is None: + self.module.fail_json( + msg="Please specify hostname for the trap target as it's a required parameter" + ) + dest_port = target.get('port', None) + if dest_port is None: + self.module.fail_json( + msg="Please specify port for the trap target as it's a required parameter" + ) + dest_community = target.get('community', None) + if dest_community is None: + self.module.fail_json( + msg="Please specify community for the trap target as it's a required parameter" + ) + return dest_hostname, dest_port, dest_community + + +def main(): + """Main""" + argument_spec = vmware_argument_spec() + argument_spec.update( + state=dict(default='disabled', choices=['enabled', 'disabled', 'reset']), + snmp_port=dict(type='int', default=161), + community=dict(type='list', default=[]), + trap_targets=dict(type='list', default=list(), required=False), + trap_filter=dict(type='list', required=False), + hw_source=dict(default='indications', choices=['indications', 'sensors']), + log_level=dict(default='info', choices=['debug', 'info', 'warning', 'error']), + send_trap=dict(type='bool', default=False), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True + ) + + host_snmp = VmwareHostSnmp(module) + host_snmp.ensure() + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/vmware_host_snmp/aliases b/test/integration/targets/vmware_host_snmp/aliases new file mode 100644 index 00000000000..845e8a6dad5 --- /dev/null +++ b/test/integration/targets/vmware_host_snmp/aliases @@ -0,0 +1,2 @@ +cloud/vcenter +unsupported diff --git a/test/integration/targets/vmware_host_snmp/tasks/main.yml b/test/integration/targets/vmware_host_snmp/tasks/main.yml new file mode 100644 index 00000000000..2c28f2312c2 --- /dev/null +++ b/test/integration/targets/vmware_host_snmp/tasks/main.yml @@ -0,0 +1,70 @@ +# Test code for the vmware_host_snmp. +# Copyright: (c) 2018, Christian Kotte +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: store the vcenter container ip + set_fact: + vcsim: "{{ lookup('env', 'vcenter_host') }}" + +- debug: var=vcsim + +- name: Wait for Flask controller to come up online + wait_for: + host: "{{ vcsim }}" + port: 5000 + state: started + +- name: kill vcsim + uri: + url: http://{{ vcsim }}:5000/killall + +# SNMP works only with standalone ESXi server +- name: start vcsim + uri: + url: http://{{ vcsim }}:5000/spawn?esx=1 + register: vcsim_instance + +- debug: + var: vcsim_instance + +- name: Wait for vcsim server to come up online + wait_for: + host: "{{ vcsim }}" + port: 443 + state: started + +- name: Enable and configure SNMP community in check mode + vmware_host_snmp: + hostname: "{{ vcsim }}" + username: "{{ vcsim_instance.json.username }}" + password: "{{ vcsim_instance.json.password }}" + community: [ test ] + state: enabled + validate_certs: no + register: snmp_enabled_check_mode + check_mode: yes + +- debug: var=snmp_enabled_check_mode + +- assert: + that: + - snmp_enabled_check_mode is defined + - snmp_enabled_check_mode.changed + +- name: Enable and configure SNMP community + vmware_host_snmp: + hostname: "{{ vcsim }}" + username: "{{ vcsim_instance.json.username }}" + password: "{{ vcsim_instance.json.password }}" + community: [ test ] + state: enabled + validate_certs: no + register: snmp_enabled + check_mode: yes + +- debug: var=snmp_enabled + +- assert: + that: + - snmp_enabled is defined + - snmp_enabled.changed