From 89dbb918a08f157c33163b00f1276db721f42306 Mon Sep 17 00:00:00 2001 From: Simon Kelly Date: Wed, 14 Oct 2020 16:52:22 +0200 Subject: [PATCH] refactor and test --- plugins/modules/monitoring/monit.py | 272 ++++++++++-------- .../plugins/modules/monitoring/test_monit.py | 73 +++++ 2 files changed, 229 insertions(+), 116 deletions(-) create mode 100644 tests/unit/plugins/modules/monitoring/test_monit.py diff --git a/plugins/modules/monitoring/monit.py b/plugins/modules/monitoring/monit.py index a7add43b1f..37b5fd650a 100644 --- a/plugins/modules/monitoring/monit.py +++ b/plugins/modules/monitoring/monit.py @@ -46,6 +46,139 @@ import re from ansible.module_utils.basic import AnsibleModule +STATE_COMMAND_MAP = { + 'stopped': 'stop', + 'started': 'start', + 'monitored': 'monitor', + 'unmonitored': 'unmonitor', + 'restarted': 'restart' +} + + +class Monit(object): + def __init__(self, module, monit_bin_path, service_name, timeout): + self.module = module + self.monit_bin_path = monit_bin_path + self.process_name = service_name + self.timeout = timeout + + self._monit_version = None + self._sleep_time = 5 + + def monit_version(self): + if self._monit_version is None: + rc, out, err = self.module.run_command('%s -V' % self.monit_bin_path, check_rc=True) + version_line = out.split('\n')[0] + version = re.search(r"[0-9]+\.[0-9]+", version_line).group().split('.') + # Use only major and minor even if there are more these should be enough + self._monit_version = int(version[0]), int(version[1]) + return self._monit_version + + def is_version_higher_than_5_18(self): + return self.monit_version() > (5, 18) + + @property + def summary_command(self): + return 'summary -B' if self.is_version_higher_than_5_18() else 'summary' + + def parse(self, parts): + if self.is_version_higher_than_5_18(): + return self.parse_current(parts) + else: + return self.parse_older_versions(parts) + + def parse_older_versions(self, parts): + if len(parts) > 2 and parts[0].lower() == 'process' and parts[1] == "'%s'" % self.process_name: + return ' '.join(parts[2:]).lower() + else: + return '' + + def parse_current(self, parts): + if len(parts) > 2 and parts[2].lower() == 'process' and parts[0] == self.process_name: + return ''.join(parts[1]).lower() + else: + return '' + + def get_status(self): + """Return the status of the process in monit, or the empty string if not present.""" + rc, out, err = self.module.run_command('%s %s' % (self.monit_bin_path, self.summary_command), check_rc=True) + for line in out.split('\n'): + # Sample output lines: + # Process 'name' Running + # Process 'name' Running - restart pending + parts = self.parse(line.split()) + if parts != '': + return parts + + return '' + + def is_process_present(self): + return self.get_status() != '' + + def is_process_running(self): + return 'running' in self.get_status() + + def run_command(self, command): + """Runs a monit command, and returns the new status.""" + return self.module.run_command('%s %s %s' % (self.monit_bin_path, command, self.process_name), check_rc=True) + + def wait_for_monit_to_stop_pending(self, state): + """Fails this run if there is no status or it's pending/initializing for timeout""" + timeout_time = time.time() + self.timeout + + running_status = self.get_status() + while running_status == '' or 'pending' in running_status or 'initializing' in running_status: + if time.time() >= timeout_time: + self.module.fail_json( + msg='waited too long for "pending", or "initiating" status to go away ({0})'.format( + running_status + ), + state=state + ) + + if self._sleep_time: + time.sleep(self._sleep_time) + running_status = self.get_status() + + def reload(self): + rc, out, err = self.module.run_command('%s reload' % self.monit_bin_path) + if rc != 0: + self.module.fail_json(msg='monit reload failed', stdout=out, stderr=err) + self.wait_for_monit_to_stop_pending('reloaded') + self.module.exit_json(changed=True, name=self.process_name, state='reloaded') + + def present(self): + self.run_command('reload') + if not self.is_process_present(): + self.wait_for_monit_to_stop_pending('present') + self.module.exit_json(changed=True, name=self.process_name, state='present') + + def change_state(self, state, expected_status, status_contains=None, invert_expected=None): + self.run_command(STATE_COMMAND_MAP[state]) + status = self.get_status() + status_match = status in expected_status + if invert_expected: + status_match = not status_match + if status_match or (status_contains and status_contains in status): + self.module.exit_json(changed=True, name=self.process_name, state=state) + self.module.fail_json(msg='%s process not %s' % (self.process_name, state), status=status) + + def stop(self): + self.change_state('stopped', ['not monitored'], 'stop pending') + + def unmonitor(self): + self.change_state('unmonitored', ['not monitored'], 'unmonitor pending') + + def restart(self): + self.change_state('restarted', ['initializing', 'running'], 'restart pending') + + def start(self): + self.change_state('started', ['initializing', 'running'], 'start pending') + + def monitor(self): + self.change_state('monitored', ['not monitored'], invert_expected=True) + + def main(): arg_spec = dict( name=dict(required=True), @@ -59,145 +192,52 @@ def main(): state = module.params['state'] timeout = module.params['timeout'] - MONIT = module.get_bin_path('monit', True) + monit = Monit(module, module.get_bin_path('monit', True), name, timeout) - def monit_version(): - rc, out, err = module.run_command('%s -V' % MONIT, check_rc=True) - version_line = out.split('\n')[0] - version = re.search(r"[0-9]+\.[0-9]+", version_line).group().split('.') - # Use only major and minor even if there are more these should be enough - return int(version[0]), int(version[1]) - - def is_version_higher_than_5_18(): - return (MONIT_MAJOR_VERSION, MONIT_MINOR_VERSION) > (5, 18) - - def parse(parts): - if is_version_higher_than_5_18(): - return parse_current(parts) - else: - return parse_older_versions(parts) - - def parse_older_versions(parts): - if len(parts) > 2 and parts[0].lower() == 'process' and parts[1] == "'%s'" % name: - return ' '.join(parts[2:]).lower() - else: - return '' - - def parse_current(parts): - if len(parts) > 2 and parts[2].lower() == 'process' and parts[0] == name: - return ''.join(parts[1]).lower() - else: - return '' - - def get_status(): - """Return the status of the process in monit, or the empty string if not present.""" - rc, out, err = module.run_command('%s %s' % (MONIT, SUMMARY_COMMAND), check_rc=True) - for line in out.split('\n'): - # Sample output lines: - # Process 'name' Running - # Process 'name' Running - restart pending - parts = parse(line.split()) - if parts != '': - return parts - - return '' - - def run_command(command): - """Runs a monit command, and returns the new status.""" - module.run_command('%s %s %s' % (MONIT, command, name), check_rc=True) - return get_status() - - def wait_for_monit_to_stop_pending(): - """Fails this run if there is no status or it's pending/initializing for timeout""" - timeout_time = time.time() + timeout - sleep_time = 5 - - running_status = get_status() - while running_status == '' or 'pending' in running_status or 'initializing' in running_status: - if time.time() >= timeout_time: - module.fail_json( - msg='waited too long for "pending", or "initiating" status to go away ({0})'.format( - running_status - ), - state=state - ) - - time.sleep(sleep_time) - running_status = get_status() - - MONIT_MAJOR_VERSION, MONIT_MINOR_VERSION = monit_version() - - SUMMARY_COMMAND = ('summary', 'summary -B')[is_version_higher_than_5_18()] - - if state == 'reloaded': + def exit_if_check_mode(): if module.check_mode: module.exit_json(changed=True) - rc, out, err = module.run_command('%s reload' % MONIT) - if rc != 0: - module.fail_json(msg='monit reload failed', stdout=out, stderr=err) - wait_for_monit_to_stop_pending() - module.exit_json(changed=True, name=name, state=state) - present = get_status() != '' + if state == 'reloaded': + exit_if_check_mode() + monit.reload() + + present = monit.is_process_present() if not present and not state == 'present': module.fail_json(msg='%s process not presently configured with monit' % name, name=name, state=state) if state == 'present': - if not present: - if module.check_mode: - module.exit_json(changed=True) - status = run_command('reload') - if status == '': - wait_for_monit_to_stop_pending() - module.exit_json(changed=True, name=name, state=state) - module.exit_json(changed=False, name=name, state=state) + if present: + module.exit_json(changed=False, name=name, state=state) + exit_if_check_mode() + monit.present() - wait_for_monit_to_stop_pending() - running = 'running' in get_status() + monit.wait_for_monit_to_stop_pending(state) + running = monit.is_process_running() if running and state in ['started', 'monitored']: module.exit_json(changed=False, name=name, state=state) if running and state == 'stopped': - if module.check_mode: - module.exit_json(changed=True) - status = run_command('stop') - if status in ['not monitored'] or 'stop pending' in status: - module.exit_json(changed=True, name=name, state=state) - module.fail_json(msg='%s process not stopped' % name, status=status) + exit_if_check_mode() + monit.stop() if running and state == 'unmonitored': - if module.check_mode: - module.exit_json(changed=True) - status = run_command('unmonitor') - if status in ['not monitored'] or 'unmonitor pending' in status: - module.exit_json(changed=True, name=name, state=state) - module.fail_json(msg='%s process not unmonitored' % name, status=status) + exit_if_check_mode() + monit.unmonitor() elif state == 'restarted': - if module.check_mode: - module.exit_json(changed=True) - status = run_command('restart') - if status in ['initializing', 'running'] or 'restart pending' in status: - module.exit_json(changed=True, name=name, state=state) - module.fail_json(msg='%s process not restarted' % name, status=status) + exit_if_check_mode() + monit.restart() elif not running and state == 'started': - if module.check_mode: - module.exit_json(changed=True) - status = run_command('start') - if status in ['initializing', 'running'] or 'start pending' in status: - module.exit_json(changed=True, name=name, state=state) - module.fail_json(msg='%s process not started' % name, status=status) + exit_if_check_mode() + monit.start() elif not running and state == 'monitored': - if module.check_mode: - module.exit_json(changed=True) - status = run_command('monitor') - if status not in ['not monitored']: - module.exit_json(changed=True, name=name, state=state) - module.fail_json(msg='%s process not monitored' % name, status=status) + exit_if_check_mode() + monit.monitor() module.exit_json(changed=False, name=name, state=state) diff --git a/tests/unit/plugins/modules/monitoring/test_monit.py b/tests/unit/plugins/modules/monitoring/test_monit.py new file mode 100644 index 0000000000..3247133d39 --- /dev/null +++ b/tests/unit/plugins/modules/monitoring/test_monit.py @@ -0,0 +1,73 @@ +# 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 + +from unittest import mock +from unittest.mock import MagicMock + +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.plugins.modules.monitoring import monit + + +class AnsibleExitJson(Exception): + """Exception class to be raised by module.exit_json and caught by the test case""" + pass + + +class AnsibleFailJson(Exception): + """Exception class to be raised by module.fail_json and caught by the test case""" + pass + + +class MonitTest(unittest.TestCase): + def setUp(self): + self.module = MagicMock() + self.module.exit_json.side_effect = AnsibleExitJson(Exception) + self.monit = monit.Monit(self.module, 'monit', 'processX', 1) + self.version_patch = mock.patch.object(self.monit, "is_version_higher_than_5_18", return_value=True) + self.version_patch.start() + + def tearDown(self): + self.version_patch.stop() + + def patch_status(self, side_effect): + return mock.patch.object(self.monit, 'get_status', side_effect=side_effect) + + def test_change_state_success(self): + with self.patch_status(['not monitored']), self.assertRaises(AnsibleExitJson): + self.monit.stop() + self.module.fail_json.assert_not_called() + self.module.run_command.assert_called_with('monit stop processX', check_rc=True) + + def test_change_state_fail(self): + with self.patch_status(['monitored']): + self.monit.stop() + self.module.fail_json.assert_called() + + def test_reload_fail(self): + self.module.run_command.return_value = (1, 'stdout', 'stderr') + self.module.fail_json.side_effect = AnsibleFailJson(Exception) + with self.assertRaises(AnsibleFailJson): + self.monit.reload() + + def test_reload(self): + self.module.run_command.return_value = (0, '', '') + self.monit._sleep_time = 0 + with self.patch_status(['', 'pending', 'running']), self.assertRaises(AnsibleExitJson): + self.monit.reload() + + def test_monitor(self): + with self.patch_status(['not monitored - start pending']), self.assertRaises(AnsibleExitJson): + self.monit.monitor() + + def test_monitor_fail(self): + with self.patch_status(['not monitored']): + self.monit.monitor() + self.module.fail_json.assert_called() + + def test_timeout(self): + self.monit.timeout = 0 + self.module.fail_json.side_effect = AnsibleFailJson(Exception) + with self.patch_status(['stop pending']), self.assertRaises(AnsibleFailJson): + self.monit.wait_for_monit_to_stop_pending('stopped')