From 054b87acb851e3dd16231a1741f777a777e54cbb Mon Sep 17 00:00:00 2001 From: Michael Price Date: Tue, 28 Aug 2018 11:40:51 -0500 Subject: [PATCH] Define module for managing E-Series email alerts (#42643) Email alerts can be enabled for an E-Series system to provide information to interested users by email when a warning or critical level event occurs on the system. This module will allow a system owner to configure whether or not system alerts are enabled, and who will receive them. --- .../modules/storage/netapp/netapp_e_alerts.py | 280 ++++++++++++++++++ .../targets/netapp_eseries_alerts/aliases | 10 + .../netapp_eseries_alerts/tasks/main.yml | 1 + .../netapp_eseries_alerts/tasks/run.yml | 39 +++ .../storage/netapp/test_netapp_e_alerts.py | 184 ++++++++++++ 5 files changed, 514 insertions(+) create mode 100644 lib/ansible/modules/storage/netapp/netapp_e_alerts.py create mode 100644 test/integration/targets/netapp_eseries_alerts/aliases create mode 100644 test/integration/targets/netapp_eseries_alerts/tasks/main.yml create mode 100644 test/integration/targets/netapp_eseries_alerts/tasks/run.yml create mode 100644 test/units/modules/storage/netapp/test_netapp_e_alerts.py diff --git a/lib/ansible/modules/storage/netapp/netapp_e_alerts.py b/lib/ansible/modules/storage/netapp/netapp_e_alerts.py new file mode 100644 index 00000000000..beacc12f135 --- /dev/null +++ b/lib/ansible/modules/storage/netapp/netapp_e_alerts.py @@ -0,0 +1,280 @@ +#!/usr/bin/python + +# (c) 2018, NetApp, Inc +# 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: netapp_e_alerts +short_description: NetApp E-Series manage email notification settings +description: + - Certain E-Series systems have the capability to send email notifications on potentially critical events. + - This module will allow the owner of the system to specify email recipients for these messages. +version_added: '2.7' +author: Michael Price (@lmprice) +extends_documentation_fragment: + - netapp.eseries +options: + state: + description: + - Enable/disable the sending of email-based alerts. + default: enabled + required: false + choices: + - enabled + - disabled + server: + description: + - A fully qualified domain name, IPv4 address, or IPv6 address of a mail server. + - To use a fully qualified domain name, you must configure a DNS server on both controllers using + M(netapp_e_mgmt_interface). + - Required when I(state=enabled). + required: no + sender: + description: + - This is the sender that the recipient will see. It doesn't necessarily need to be a valid email account. + - Required when I(state=enabled). + required: no + contact: + description: + - Allows the owner to specify some free-form contact information to be included in the emails. + - This is typically utilized to provide a contact phone number. + required: no + recipients: + description: + - The email addresses that will receive the email notifications. + - Required when I(state=enabled). + required: no + test: + description: + - When a change is detected in the configuration, a test email will be sent. + - This may take a few minutes to process. + - Only applicable if I(state=enabled). + default: no + type: bool + log_path: + description: + - Path to a file on the Ansible control node to be used for debug logging + required: no +notes: + - Check mode is supported. + - Alertable messages are a subset of messages shown by the Major Event Log (MEL), of the storage-system. Examples + of alertable messages include drive failures, failed controllers, loss of redundancy, and other warning/critical + events. + - This API is currently only supported with the Embedded Web Services API v2.0 and higher. +""" + +EXAMPLES = """ + - name: Enable email-based alerting + netapp_e_alerts: + state: enabled + sender: noreply@example.com + server: mail@example.com + contact: "Phone: 1-555-555-5555" + recipients: + - name1@example.com + - name2@example.com + api_url: "10.1.1.1:8443" + api_username: "admin" + api_password: "myPass" + + - name: Disable alerting + netapp_e_alerts: + state: disabled + api_url: "10.1.1.1:8443" + api_username: "admin" + api_password: "myPass" +""" + +RETURN = """ +msg: + description: Success message + returned: on success + type: string + sample: The settings have been updated. +""" + +import json +import logging +from pprint import pformat +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.netapp import request, eseries_host_argument_spec +from ansible.module_utils._text import to_native + +HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + + +class Alerts(object): + def __init__(self): + argument_spec = eseries_host_argument_spec() + argument_spec.update(dict( + state=dict(type='str', required=False, default='enabled', + choices=['enabled', 'disabled']), + server=dict(type='str', required=False, ), + sender=dict(type='str', required=False, ), + contact=dict(type='str', required=False, ), + recipients=dict(type='list', required=False, ), + test=dict(type='bool', required=False, default=False, ), + log_path=dict(type='str', required=False), + )) + + required_if = [ + ['state', 'enabled', ['server', 'sender', 'recipients']] + ] + + self.module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True, required_if=required_if) + args = self.module.params + self.alerts = args['state'] == 'enabled' + self.server = args['server'] + self.sender = args['sender'] + self.contact = args['contact'] + self.recipients = args['recipients'] + self.test = args['test'] + + self.ssid = args['ssid'] + self.url = args['api_url'] + self.creds = dict(url_password=args['api_password'], + validate_certs=args['validate_certs'], + url_username=args['api_username'], ) + + self.check_mode = self.module.check_mode + + log_path = args['log_path'] + + # logging setup + self._logger = logging.getLogger(self.__class__.__name__) + + if log_path: + logging.basicConfig( + level=logging.DEBUG, filename=log_path, filemode='w', + format='%(relativeCreated)dms %(levelname)s %(module)s.%(funcName)s:%(lineno)d\n %(message)s') + + if not self.url.endswith('/'): + self.url += '/' + + # Very basic validation on email addresses: xx@yy.zz + email = re.compile(r"[^@]+@[^@]+\.[^@]+") + + if self.sender and not email.match(self.sender): + self.module.fail_json(msg="The sender (%s) provided is not a valid email address." % self.sender) + + if self.recipients is not None: + for recipient in self.recipients: + if not email.match(recipient): + self.module.fail_json(msg="The recipient (%s) provided is not a valid email address." % recipient) + + if len(self.recipients) < 1: + self.module.fail_json(msg="At least one recipient address must be specified.") + + def get_configuration(self): + try: + (rc, result) = request(self.url + 'storage-systems/%s/device-alerts' % self.ssid, headers=HEADERS, + **self.creds) + self._logger.info("Current config: %s", pformat(result)) + return result + + except Exception as err: + self.module.fail_json(msg="Failed to retrieve the alerts configuration! Array Id [%s]. Error [%s]." + % (self.ssid, to_native(err))) + + def update_configuration(self): + config = self.get_configuration() + update = False + body = dict() + + if self.alerts: + body = dict(alertingEnabled=True) + if not config['alertingEnabled']: + update = True + + body.update(emailServerAddress=self.server) + if config['emailServerAddress'] != self.server: + update = True + + body.update(additionalContactInformation=self.contact, sendAdditionalContactInformation=True) + if self.contact and (self.contact != config['additionalContactInformation'] + or not config['sendAdditionalContactInformation']): + update = True + + body.update(emailSenderAddress=self.sender) + if config['emailSenderAddress'] != self.sender: + update = True + + self.recipients.sort() + if config['recipientEmailAddresses']: + config['recipientEmailAddresses'].sort() + + body.update(recipientEmailAddresses=self.recipients) + if config['recipientEmailAddresses'] != self.recipients: + update = True + + elif config['alertingEnabled']: + body = dict(alertingEnabled=False) + update = True + + self._logger.debug(pformat(body)) + + if update and not self.check_mode: + try: + (rc, result) = request(self.url + 'storage-systems/%s/device-alerts' % self.ssid, method='POST', + data=json.dumps(body), headers=HEADERS, **self.creds) + # This is going to catch cases like a connection failure + except Exception as err: + self.module.fail_json(msg="We failed to set the storage-system name! Array Id [%s]. Error [%s]." + % (self.ssid, to_native(err))) + return update + + def send_test_email(self): + """Send a test email to verify that the provided configuration is valid and functional.""" + if not self.check_mode: + try: + (rc, result) = request(self.url + 'storage-systems/%s/device-alerts/alert-email-test' % self.ssid, + timeout=300, method='POST', headers=HEADERS, **self.creds) + + if result['response'] != 'emailSentOK': + self.module.fail_json(msg="The test email failed with status=[%s]! Array Id [%s]." + % (result['response'], self.ssid)) + + # This is going to catch cases like a connection failure + except Exception as err: + self.module.fail_json(msg="We failed to send the test email! Array Id [%s]. Error [%s]." + % (self.ssid, to_native(err))) + + def update(self): + update = self.update_configuration() + + if self.test and update: + self._logger.info("An update was detected and test=True, running a test.") + self.send_test_email() + + if self.alerts: + msg = 'Alerting has been enabled using server=%s, sender=%s.' % (self.server, self.sender) + else: + msg = 'Alerting has been disabled.' + + self.module.exit_json(msg=msg, changed=update, ) + + def __call__(self, *args, **kwargs): + self.update() + + +def main(): + alerts = Alerts() + alerts() + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/netapp_eseries_alerts/aliases b/test/integration/targets/netapp_eseries_alerts/aliases new file mode 100644 index 00000000000..d314d14a748 --- /dev/null +++ b/test/integration/targets/netapp_eseries_alerts/aliases @@ -0,0 +1,10 @@ +# This test is not enabled by default, but can be utilized by defining required variables in integration_config.yml +# Example integration_config.yml: +# --- +#netapp_e_api_host: 10.113.1.111:8443 +#netapp_e_api_username: admin +#netapp_e_api_password: myPass +#netapp_e_ssid: 1 + +unsupported +netapp/eseries diff --git a/test/integration/targets/netapp_eseries_alerts/tasks/main.yml b/test/integration/targets/netapp_eseries_alerts/tasks/main.yml new file mode 100644 index 00000000000..996354c8860 --- /dev/null +++ b/test/integration/targets/netapp_eseries_alerts/tasks/main.yml @@ -0,0 +1 @@ +- include_tasks: run.yml diff --git a/test/integration/targets/netapp_eseries_alerts/tasks/run.yml b/test/integration/targets/netapp_eseries_alerts/tasks/run.yml new file mode 100644 index 00000000000..a647d628788 --- /dev/null +++ b/test/integration/targets/netapp_eseries_alerts/tasks/run.yml @@ -0,0 +1,39 @@ +# Test code for the netapp_e_iscsi_interface module +# (c) 2018, NetApp, Inc +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: NetApp Test ASUP module + fail: + msg: 'Please define netapp_e_api_username, netapp_e_api_password, netapp_e_api_host, and netapp_e_ssid.' + when: netapp_e_api_username is undefined or netapp_e_api_password is undefined + or netapp_e_api_host is undefined or netapp_e_ssid is undefined + vars: + defaults: &defaults + api_url: "https://{{ netapp_e_api_host }}/devmgr/v2" + api_username: "{{ netapp_e_api_username }}" + api_password: "{{ netapp_e_api_password }}" + ssid: "{{ netapp_e_ssid }}" + validate_certs: no + state: enabled + server: mail.example.com + sender: noreply@example.com + recipients: + - noreply@example.com + +- name: set default vars + set_fact: + vars: *defaults + +- name: Set the initial alerting settings + netapp_e_alerts: + <<: *defaults + register: result + +- name: Validate the idempotency of the module + netapp_e_alerts: + <<: *defaults + register: result + +- name: Ensure we still have the same settings, but had no change + assert: + that: not result.changed diff --git a/test/units/modules/storage/netapp/test_netapp_e_alerts.py b/test/units/modules/storage/netapp/test_netapp_e_alerts.py new file mode 100644 index 00000000000..63b6b236fae --- /dev/null +++ b/test/units/modules/storage/netapp/test_netapp_e_alerts.py @@ -0,0 +1,184 @@ +# (c) 2018, NetApp Inc. +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +import json + +from ansible.modules.storage.netapp.netapp_e_alerts import Alerts +from units.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args + +__metaclass__ = type +from ansible.compat.tests import mock + + +class AlertsTest(ModuleTestCase): + REQUIRED_PARAMS = { + 'api_username': 'rw', + 'api_password': 'password', + 'api_url': 'http://localhost', + 'ssid': '1', + 'state': 'disabled' + } + REQ_FUNC = 'ansible.modules.storage.netapp.netapp_e_alerts.request' + + def _set_args(self, **kwargs): + module_args = self.REQUIRED_PARAMS.copy() + if kwargs is not None: + module_args.update(kwargs) + set_module_args(module_args) + + def _validate_args(self, **kwargs): + self._set_args(**kwargs) + Alerts() + + def test_validation_disable(self): + """Ensure a default configuration succeeds""" + self._validate_args() + + def test_validation_enable(self): + """Ensure a typical, default configuration succeeds""" + self._validate_args(state='enabled', server='localhost', sender='x@y.z', recipients=['a@b.c']) + + def test_validation_fail_required(self): + """Ensure we fail on missing configuration""" + + # Missing recipients + with self.assertRaises(AnsibleFailJson): + self._validate_args(state='enabled', server='localhost', sender='x@y.z') + Alerts() + + # Missing sender + with self.assertRaises(AnsibleFailJson): + self._validate_args(state='enabled', server='localhost', recipients=['a@b.c']) + Alerts() + + # Missing server + with self.assertRaises(AnsibleFailJson): + self._validate_args(state='enabled', sender='x@y.z', recipients=['a@b.c']) + + def test_validation_fail(self): + # Empty recipients + with self.assertRaises(AnsibleFailJson): + self._validate_args(state='enabled', server='localhost', sender='x@y.z', recipients=[]) + + # Bad sender + with self.assertRaises(AnsibleFailJson): + self._validate_args(state='enabled', server='localhost', sender='y.z', recipients=['a@b.c']) + + def test_get_configuration(self): + """Validate retrieving the current configuration""" + self._set_args(state='enabled', server='localhost', sender='x@y.z', recipients=['a@b.c']) + + expected = 'result' + alerts = Alerts() + # Expecting an update + with mock.patch(self.REQ_FUNC, return_value=(200, expected)) as req: + actual = alerts.get_configuration() + self.assertEquals(expected, actual) + self.assertEquals(req.call_count, 1) + + def test_update_configuration(self): + """Validate updating the configuration""" + initial = dict(alertingEnabled=True, + emailServerAddress='localhost', + sendAdditionalContactInformation=True, + additionalContactInformation='None', + emailSenderAddress='x@y.z', + recipientEmailAddresses=['x@y.z'] + ) + + args = dict(state='enabled', server=initial['emailServerAddress'], sender=initial['emailSenderAddress'], + contact=initial['additionalContactInformation'], recipients=initial['recipientEmailAddresses']) + + self._set_args(**args) + + alerts = Alerts() + + # Ensure when trigger updates when each relevant field is changed + with mock.patch(self.REQ_FUNC, return_value=(200, None)) as req: + with mock.patch.object(alerts, 'get_configuration', return_value=initial): + update = alerts.update_configuration() + self.assertFalse(update) + + alerts.sender = 'a@b.c' + update = alerts.update_configuration() + self.assertTrue(update) + self._set_args(**args) + + alerts.recipients = ['a@b.c'] + update = alerts.update_configuration() + self.assertTrue(update) + self._set_args(**args) + + alerts.contact = 'abc' + update = alerts.update_configuration() + self.assertTrue(update) + self._set_args(**args) + + alerts.server = 'abc' + update = alerts.update_configuration() + self.assertTrue(update) + + def test_send_test_email_check(self): + """Ensure we handle check_mode correctly""" + self._set_args(test=True) + alerts = Alerts() + alerts.check_mode = True + with mock.patch(self.REQ_FUNC) as req: + with mock.patch.object(alerts, 'update_configuration', return_value=True): + alerts.send_test_email() + self.assertFalse(req.called) + + def test_send_test_email(self): + """Ensure we send a test email if test=True""" + self._set_args(test=True) + alerts = Alerts() + + with mock.patch(self.REQ_FUNC, return_value=(200, dict(response='emailSentOK'))) as req: + alerts.send_test_email() + self.assertTrue(req.called) + + def test_send_test_email_fail(self): + """Ensure we fail if the test returned a failure status""" + self._set_args(test=True) + alerts = Alerts() + + ret_msg = 'fail' + with self.assertRaisesRegexp(AnsibleFailJson, ret_msg): + with mock.patch(self.REQ_FUNC, return_value=(200, dict(response=ret_msg))) as req: + alerts.send_test_email() + self.assertTrue(req.called) + + def test_send_test_email_fail_connection(self): + """Ensure we fail cleanly if we hit a connection failure""" + self._set_args(test=True) + alerts = Alerts() + + with self.assertRaisesRegexp(AnsibleFailJson, r"failed to send"): + with mock.patch(self.REQ_FUNC, side_effect=Exception) as req: + alerts.send_test_email() + self.assertTrue(req.called) + + def test_update(self): + # Ensure that when test is enabled and alerting is enabled, we run the test + self._set_args(state='enabled', server='localhost', sender='x@y.z', recipients=['a@b.c'], test=True) + alerts = Alerts() + with self.assertRaisesRegexp(AnsibleExitJson, r"enabled"): + with mock.patch.object(alerts, 'update_configuration', return_value=True): + with mock.patch.object(alerts, 'send_test_email') as test: + alerts.update() + self.assertTrue(test.called) + + # Ensure we don't run a test when changed=False + with self.assertRaisesRegexp(AnsibleExitJson, r"enabled"): + with mock.patch.object(alerts, 'update_configuration', return_value=False): + with mock.patch.object(alerts, 'send_test_email') as test: + alerts.update() + self.assertFalse(test.called) + + # Ensure that test is not called when we have alerting disabled + self._set_args(state='disabled') + alerts = Alerts() + with self.assertRaisesRegexp(AnsibleExitJson, r"disabled"): + with mock.patch.object(alerts, 'update_configuration', return_value=True): + with mock.patch.object(alerts, 'send_test_email') as test: + alerts.update() + self.assertFalse(test.called)