diff --git a/changelogs/fragments/1107-monit-fix-status-check.yml b/changelogs/fragments/1107-monit-fix-status-check.yml new file mode 100644 index 0000000000..400b9715d5 --- /dev/null +++ b/changelogs/fragments/1107-monit-fix-status-check.yml @@ -0,0 +1,2 @@ +bugfixes: + - monit - fix modules ability to determine the current state of the monitored process (https://github.com/ansible-collections/community.general/pull/1107). diff --git a/plugins/modules/monitoring/monit.py b/plugins/modules/monitoring/monit.py index a7add43b1f..7936728f04 100644 --- a/plugins/modules/monitoring/monit.py +++ b/plugins/modules/monitoring/monit.py @@ -13,24 +13,29 @@ DOCUMENTATION = ''' module: monit short_description: Manage the state of a program monitored via Monit description: - - Manage the state of a program monitored via I(Monit) + - Manage the state of a program monitored via I(Monit). options: name: description: - - The name of the I(monit) program/process to manage + - The name of the I(monit) program/process to manage. required: true + type: str state: description: - - The state of service + - The state of service. required: true choices: [ "present", "started", "stopped", "restarted", "monitored", "unmonitored", "reloaded" ] + type: str timeout: description: - If there are pending actions for the service monitored by monit, then Ansible will check for up to this many seconds to verify the requested action has been performed. Ansible will sleep for five seconds between each check. default: 300 -author: "Darryl Stoflet (@dstoflet)" + type: int +author: + - Darryl Stoflet (@dstoflet) + - Simon Kelly (@snopoke) ''' EXAMPLES = ''' @@ -43,7 +48,224 @@ EXAMPLES = ''' import time import re +from collections import namedtuple + from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six import python_2_unicode_compatible + + +STATE_COMMAND_MAP = { + 'stopped': 'stop', + 'started': 'start', + 'monitored': 'monitor', + 'unmonitored': 'unmonitor', + 'restarted': 'restart' +} + + +@python_2_unicode_compatible +class StatusValue(namedtuple("Status", "value, is_pending")): + MISSING = 'missing' + OK = 'ok' + NOT_MONITORED = 'not_monitored' + INITIALIZING = 'initializing' + DOES_NOT_EXIST = 'does_not_exist' + EXECUTION_FAILED = 'execution_failed' + ALL_STATUS = [ + MISSING, OK, NOT_MONITORED, INITIALIZING, DOES_NOT_EXIST, EXECUTION_FAILED + ] + + def __new__(cls, value, is_pending=False): + return super(StatusValue, cls).__new__(cls, value, is_pending) + + def pending(self): + return StatusValue(self.value, True) + + def __getattr__(self, item): + if item in ('is_%s' % status for status in self.ALL_STATUS): + return self.value == getattr(self, item[3:].upper()) + raise AttributeError(item) + + def __str__(self): + return "%s%s" % (self.value, " (pending)" if self.is_pending else "") + + +class Status(object): + MISSING = StatusValue(StatusValue.MISSING) + OK = StatusValue(StatusValue.OK) + RUNNING = StatusValue(StatusValue.OK) + NOT_MONITORED = StatusValue(StatusValue.NOT_MONITORED) + INITIALIZING = StatusValue(StatusValue.INITIALIZING) + DOES_NOT_EXIST = StatusValue(StatusValue.DOES_NOT_EXIST) + EXECUTION_FAILED = StatusValue(StatusValue.EXECUTION_FAILED) + + +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._raw_version = None + self._status_change_retry_count = 6 + + def monit_version(self): + if self._monit_version is None: + self._raw_version, version = self._get_monit_version() + # Use only major and minor even if there are more these should be enough + self._monit_version = version[0], version[1] + return self._monit_version + + def _get_monit_version(self): + rc, out, err = self.module.run_command('%s -V' % self.monit_bin_path, check_rc=True) + version_line = out.split('\n')[0] + raw_version = re.search(r"([0-9]+\.){1,2}([0-9]+)?", version_line).group() + return raw_version, tuple(map(int, raw_version.split('.'))) + + def exit_fail(self, msg, status=None, **kwargs): + kwargs.update({ + 'msg': msg, + 'monit_version': self._raw_version, + 'process_status': str(status) if status else None, + }) + self.module.fail_json(**kwargs) + + def exit_success(self, state): + self.module.exit_json(changed=True, name=self.process_name, state=state) + + @property + def command_args(self): + return "-B" if self.monit_version() > (5, 18) else "" + + def get_status(self, validate=False): + """Return the status of the process in monit. + + :@param validate: Force monit to re-check the status of the process + """ + monit_command = "validate" if validate else "status" + check_rc = False if validate else True # 'validate' always has rc = 1 + command = ' '.join([self.monit_bin_path, monit_command, self.command_args, self.process_name]) + rc, out, err = self.module.run_command(command, check_rc=check_rc) + return self._parse_status(out, err) + + def _parse_status(self, output, err): + if "Process '%s'" % self.process_name not in output: + return Status.MISSING + + status_val = re.findall(r"^\s*status\s*([\w\- ]+)", output, re.MULTILINE) + if not status_val: + self.exit_fail("Unable to find process status", stdout=output, stderr=err) + + status_val = status_val[0].strip().upper() + if ' | ' in status_val: + status_val = status_val.split(' | ')[0] + if ' - ' not in status_val: + status_val = status_val.replace(' ', '_') + return getattr(Status, status_val) + else: + status_val, substatus = status_val.split(' - ') + action, state = substatus.split() + if action in ['START', 'INITIALIZING', 'RESTART', 'MONITOR']: + status = Status.OK + else: + status = Status.NOT_MONITORED + + if state == 'pending': + status = status.pending() + return status + + def is_process_present(self): + rc, out, err = self.module.run_command('%s summary %s' % (self.monit_bin_path, self.command_args), check_rc=True) + return bool(re.findall(r'\b%s\b' % self.process_name, out)) + + def is_process_running(self): + return self.get_status().is_ok + + 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_status_change(self, current_status): + running_status = self.get_status() + if running_status.value != current_status.value or current_status.value == StatusValue.EXECUTION_FAILED: + return running_status + + loop_count = 0 + while running_status.value == current_status.value: + if loop_count >= self._status_change_retry_count: + self.exit_fail('waited too long for monit to change state', running_status) + + loop_count += 1 + time.sleep(0.5) + validate = loop_count % 2 == 0 # force recheck of status every second try + running_status = self.get_status(validate) + return running_status + + def wait_for_monit_to_stop_pending(self, current_status=None): + """Fails this run if there is no status or it's pending/initializing for timeout""" + timeout_time = time.time() + self.timeout + + if not current_status: + current_status = self.get_status() + waiting_status = [ + StatusValue.MISSING, + StatusValue.INITIALIZING, + StatusValue.DOES_NOT_EXIST, + ] + while current_status.is_pending or (current_status.value in waiting_status): + if time.time() >= timeout_time: + self.exit_fail('waited too long for "pending", or "initiating" status to go away', current_status) + + time.sleep(5) + current_status = self.get_status(validate=True) + return current_status + + def reload(self): + rc, out, err = self.module.run_command('%s reload' % self.monit_bin_path) + if rc != 0: + self.exit_fail('monit reload failed', stdout=out, stderr=err) + self.exit_success(state='reloaded') + + def present(self): + self.run_command('reload') + + timeout_time = time.time() + self.timeout + while not self.is_process_present(): + if time.time() >= timeout_time: + self.exit_fail('waited too long for process to become "present"') + + time.sleep(5) + + self.exit_success(state='present') + + def change_state(self, state, expected_status, invert_expected=None): + current_status = self.get_status() + self.run_command(STATE_COMMAND_MAP[state]) + status = self.wait_for_status_change(current_status) + status = self.wait_for_monit_to_stop_pending(status) + status_match = status.value == expected_status.value + if invert_expected: + status_match = not status_match + if status_match: + self.exit_success(state=state) + self.exit_fail('%s process not %s' % (self.process_name, state), status) + + def stop(self): + self.change_state('stopped', Status.NOT_MONITORED) + + def unmonitor(self): + self.change_state('unmonitored', Status.NOT_MONITORED) + + def restart(self): + self.change_state('restarted', Status.OK) + + def start(self): + self.change_state('started', Status.OK) + + def monitor(self): + self.change_state('monitored', Status.NOT_MONITORED, invert_expected=True) def main(): @@ -59,145 +281,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) + module.fail_json(msg='%s process not presently configured with monit' % name, name=name) 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() + 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/integration/targets/monit/aliases b/tests/integration/targets/monit/aliases new file mode 100644 index 0000000000..4d4f754987 --- /dev/null +++ b/tests/integration/targets/monit/aliases @@ -0,0 +1,8 @@ +destructive +needs/target/setup_epel +shippable/posix/group2 +skip/osx +skip/macos +skip/freebsd +skip/aix +skip/python2.6 # python-daemon package used in integration tests requires >=2.7 diff --git a/tests/integration/targets/monit/defaults/main.yml b/tests/integration/targets/monit/defaults/main.yml new file mode 100644 index 0000000000..71b22f442e --- /dev/null +++ b/tests/integration/targets/monit/defaults/main.yml @@ -0,0 +1,4 @@ +process_root: /opt/httpd_echo +process_file: "{{ process_root }}/httpd_echo.py" +process_venv: "{{ process_root }}/venv" +process_run_cmd: "{{ process_venv }}/bin/python {{ process_file }}" diff --git a/tests/integration/targets/monit/files/httpd_echo.py b/tests/integration/targets/monit/files/httpd_echo.py new file mode 100644 index 0000000000..561470372d --- /dev/null +++ b/tests/integration/targets/monit/files/httpd_echo.py @@ -0,0 +1,50 @@ +# (c) 2020, Simon Kelly +# 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 + +import daemon + +try: + from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer + + def write_to_output(stream, content): + stream.write(content) +except ImportError: + from http.server import BaseHTTPRequestHandler, HTTPServer + + def write_to_output(stream, content): + stream.write(bytes(content, "utf-8")) + + +hostname = "localhost" +server_port = 8082 + + +class EchoServer(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.end_headers() + write_to_output(self.wfile, self.path) + + +def run_webserver(): + webServer = HTTPServer((hostname, server_port), EchoServer) + print("Server started http://%s:%s" % (hostname, server_port)) + + try: + webServer.serve_forever() + except KeyboardInterrupt: + pass + + webServer.server_close() + print("Server stopped.") + + +if __name__ == "__main__": + context = daemon.DaemonContext() + + with context: + run_webserver() diff --git a/tests/integration/targets/monit/meta/main.yml b/tests/integration/targets/monit/meta/main.yml new file mode 100644 index 0000000000..5438ced5c3 --- /dev/null +++ b/tests/integration/targets/monit/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_pkg_mgr diff --git a/tests/integration/targets/monit/tasks/check_state.yml b/tests/integration/targets/monit/tasks/check_state.yml new file mode 100644 index 0000000000..3fb2e6e929 --- /dev/null +++ b/tests/integration/targets/monit/tasks/check_state.yml @@ -0,0 +1,20 @@ +- name: "{{ reason }} ('up')" + command: "curl -sf http://localhost:8082/hello" + args: + warn: false + when: service_state == 'up' + register: curl_result + until: not curl_result.failed + retries: 5 + delay: 1 + +- name: "{{ reason }} ('down')" + command: "curl -sf http://localhost:8082/hello" + args: + warn: false + register: curl_result + failed_when: curl_result == 0 + when: service_state == 'down' + until: not curl_result.failed + retries: 5 + delay: 1 diff --git a/tests/integration/targets/monit/tasks/main.yml b/tests/integration/targets/monit/tasks/main.yml new file mode 100644 index 0000000000..447140e280 --- /dev/null +++ b/tests/integration/targets/monit/tasks/main.yml @@ -0,0 +1,78 @@ +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- block: + - name: Install EPEL repository (RHEL only) + include_role: + name: setup_epel + + - name: create required directories + become: yes + file: + path: "{{ item }}" + state: directory + loop: + - /var/lib/monit + - /var/run/monit + - "{{ process_root }}" + + - name: install monit + become: yes + package: + name: monit + state: present + + - include_vars: '{{ item }}' + with_first_found: + - files: + - "{{ ansible_facts.distribution }}-{{ ansible_facts.distribution_major_version }}.yml" + - '{{ ansible_os_family }}.yml' + - 'defaults.yml' + + - name: monit config + become: yes + template: + src: "monitrc.j2" + dest: "{{ monitrc }}" + + - name: copy process file + become: yes + copy: + src: httpd_echo.py + dest: "{{ process_file }}" + + - name: install dependencies + pip: + name: "{{ item }}" + virtualenv: "{{ process_venv }}" + loop: + - setuptools==44 + - python-daemon + + - name: restart monit + become: yes + service: + name: monit + state: restarted + + - include_tasks: test.yml + + always: + - name: stop monit + become: yes + service: + name: monit + state: stopped + + - name: uninstall monit + become: yes + package: + name: monit + state: absent + + - name: remove process files + file: + path: "{{ process_root }}" + state: absent diff --git a/tests/integration/targets/monit/tasks/test.yml b/tests/integration/targets/monit/tasks/test.yml new file mode 100644 index 0000000000..c36997fcec --- /dev/null +++ b/tests/integration/targets/monit/tasks/test.yml @@ -0,0 +1,28 @@ +# order is important +- import_tasks: test_reload_present.yml + +- import_tasks: test_state.yml + vars: + state: stopped + initial_state: up + expected_state: down + +- import_tasks: test_state.yml + vars: + state: started + initial_state: down + expected_state: up + +- import_tasks: test_state.yml + vars: + state: unmonitored + initial_state: up + expected_state: down + +- import_tasks: test_state.yml + vars: + state: monitored + initial_state: down + expected_state: up + +- import_tasks: test_errors.yml diff --git a/tests/integration/targets/monit/tasks/test_errors.yml b/tests/integration/targets/monit/tasks/test_errors.yml new file mode 100644 index 0000000000..4520fd8b85 --- /dev/null +++ b/tests/integration/targets/monit/tasks/test_errors.yml @@ -0,0 +1,6 @@ +- name: Check an error occurs when wrong process name is used + monit: + name: missing + state: started + register: result + failed_when: result is not skip and (result is success or result is not failed) diff --git a/tests/integration/targets/monit/tasks/test_reload_present.yml b/tests/integration/targets/monit/tasks/test_reload_present.yml new file mode 100644 index 0000000000..31f37e7476 --- /dev/null +++ b/tests/integration/targets/monit/tasks/test_reload_present.yml @@ -0,0 +1,60 @@ +- name: reload monit when process is missing + monit: + name: httpd_echo + state: reloaded + register: result + +- name: check that state is changed + assert: + that: + - result is success + - result is changed + +- name: test process not present + monit: + name: httpd_echo + state: present + timeout: 5 + register: result + failed_when: result is not skip and result is success + +- name: test monitor missing process + monit: + name: httpd_echo + state: monitored + register: result + failed_when: result is not skip and result is success + +- name: start process + shell: "{{ process_run_cmd }}" + +- import_tasks: check_state.yml + vars: + reason: verify service running + service_state: "up" + +- name: add process config + blockinfile: + path: "{{ monitrc }}" + block: | + check process httpd_echo with matching "httpd_echo" + start program = "{{ process_run_cmd }}" + stop program = "/bin/sh -c 'kill `pgrep -f httpd_echo`'" + if failed host localhost port 8082 then restart + +- name: restart monit + service: + name: monit + state: restarted + +- name: test process present again + monit: + name: httpd_echo + state: present + register: result + +- name: check that state is unchanged + assert: + that: + - result is success + - result is not changed diff --git a/tests/integration/targets/monit/tasks/test_state.yml b/tests/integration/targets/monit/tasks/test_state.yml new file mode 100644 index 0000000000..f78fbc55e7 --- /dev/null +++ b/tests/integration/targets/monit/tasks/test_state.yml @@ -0,0 +1,33 @@ +- import_tasks: check_state.yml + vars: + reason: verify initial service state + service_state: "{{ initial_state }}" + +- name: change httpd_echo process state to {{ state }} + monit: + name: httpd_echo + state: "{{ state }}" + register: result + +- name: check that state changed + assert: + that: + - result is success + - result is changed + +- import_tasks: check_state.yml + vars: + reason: check service state after action + service_state: "{{ expected_state }}" + +- name: try change state again to {{ state }} + monit: + name: httpd_echo + state: "{{ state }}" + register: result + +- name: check that state is not changed + assert: + that: + - result is success + - result is not changed diff --git a/tests/integration/targets/monit/templates/monitrc.j2 b/tests/integration/targets/monit/templates/monitrc.j2 new file mode 100644 index 0000000000..aba574c24d --- /dev/null +++ b/tests/integration/targets/monit/templates/monitrc.j2 @@ -0,0 +1,13 @@ +set daemon 2 +set logfile /var/log/monit.log +set idfile /var/lib/monit/id +set statefile /var/lib/monit/state +set pidfile /var/run/monit.pid + +set eventqueue + basedir /var/lib/monit/events + slots 100 + +set httpd port 2812 and + use address localhost + allow localhost diff --git a/tests/integration/targets/monit/vars/CentOS-6.yml b/tests/integration/targets/monit/vars/CentOS-6.yml new file mode 100644 index 0000000000..7b769cb460 --- /dev/null +++ b/tests/integration/targets/monit/vars/CentOS-6.yml @@ -0,0 +1 @@ +monitrc: "/etc/monit.conf" diff --git a/tests/integration/targets/monit/vars/RedHat.yml b/tests/integration/targets/monit/vars/RedHat.yml new file mode 100644 index 0000000000..cb76bac9e4 --- /dev/null +++ b/tests/integration/targets/monit/vars/RedHat.yml @@ -0,0 +1 @@ +monitrc: "/etc/monitrc" diff --git a/tests/integration/targets/monit/vars/Suse.yml b/tests/integration/targets/monit/vars/Suse.yml new file mode 100644 index 0000000000..cb76bac9e4 --- /dev/null +++ b/tests/integration/targets/monit/vars/Suse.yml @@ -0,0 +1 @@ +monitrc: "/etc/monitrc" diff --git a/tests/integration/targets/monit/vars/defaults.yml b/tests/integration/targets/monit/vars/defaults.yml new file mode 100644 index 0000000000..5254ded926 --- /dev/null +++ b/tests/integration/targets/monit/vars/defaults.yml @@ -0,0 +1 @@ +monitrc: "/etc/monit/monitrc" diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index 5e7d1d0774..a40cb1bf75 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -675,8 +675,6 @@ plugins/modules/monitoring/logentries.py validate-modules:undocumented-parameter plugins/modules/monitoring/logstash_plugin.py validate-modules:doc-missing-type plugins/modules/monitoring/logstash_plugin.py validate-modules:invalid-ansiblemodule-schema plugins/modules/monitoring/logstash_plugin.py validate-modules:parameter-type-not-in-doc -plugins/modules/monitoring/monit.py validate-modules:doc-missing-type -plugins/modules/monitoring/monit.py validate-modules:parameter-type-not-in-doc plugins/modules/monitoring/newrelic_deployment.py validate-modules:doc-missing-type plugins/modules/monitoring/pagerduty.py validate-modules:doc-default-does-not-match-spec plugins/modules/monitoring/pagerduty.py validate-modules:doc-missing-type diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index 5e7d1d0774..a40cb1bf75 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -675,8 +675,6 @@ plugins/modules/monitoring/logentries.py validate-modules:undocumented-parameter plugins/modules/monitoring/logstash_plugin.py validate-modules:doc-missing-type plugins/modules/monitoring/logstash_plugin.py validate-modules:invalid-ansiblemodule-schema plugins/modules/monitoring/logstash_plugin.py validate-modules:parameter-type-not-in-doc -plugins/modules/monitoring/monit.py validate-modules:doc-missing-type -plugins/modules/monitoring/monit.py validate-modules:parameter-type-not-in-doc plugins/modules/monitoring/newrelic_deployment.py validate-modules:doc-missing-type plugins/modules/monitoring/pagerduty.py validate-modules:doc-default-does-not-match-spec plugins/modules/monitoring/pagerduty.py validate-modules:doc-missing-type diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index f521947459..72a5954bed 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -543,8 +543,6 @@ plugins/modules/monitoring/logentries.py validate-modules:parameter-type-not-in- plugins/modules/monitoring/logentries.py validate-modules:undocumented-parameter plugins/modules/monitoring/logstash_plugin.py validate-modules:doc-missing-type plugins/modules/monitoring/logstash_plugin.py validate-modules:parameter-type-not-in-doc -plugins/modules/monitoring/monit.py validate-modules:doc-missing-type -plugins/modules/monitoring/monit.py validate-modules:parameter-type-not-in-doc plugins/modules/monitoring/newrelic_deployment.py validate-modules:doc-missing-type plugins/modules/monitoring/pagerduty.py validate-modules:doc-default-does-not-match-spec plugins/modules/monitoring/pagerduty.py validate-modules:doc-missing-type 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..7781f85a04 --- /dev/null +++ b/tests/unit/plugins/modules/monitoring/test_monit.py @@ -0,0 +1,140 @@ +# 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 + +import mock +import pytest + +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.plugins.modules.monitoring import monit +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson + + +TEST_OUTPUT = """ +Process '%s' + status %s + monitoring status Not monitored + monitoring mode active +""" + + +class MonitTest(unittest.TestCase): + def setUp(self): + self.module = mock.MagicMock() + self.module.exit_json.side_effect = AnsibleExitJson + self.module.fail_json.side_effect = AnsibleFailJson + self.monit = monit.Monit(self.module, 'monit', 'processX', 1) + self.monit._status_change_retry_count = 1 + mock_sleep = mock.patch('time.sleep') + mock_sleep.start() + self.addCleanup(mock_sleep.stop) + + def patch_status(self, side_effect): + if not isinstance(side_effect, list): + side_effect = [side_effect] + return mock.patch.object(self.monit, 'get_status', side_effect=side_effect) + + def test_change_state_success(self): + with self.patch_status([monit.Status.OK, monit.Status.NOT_MONITORED]): + with 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([monit.Status.OK] * 3): + with self.assertRaises(AnsibleFailJson): + self.monit.stop() + + def test_reload_fail(self): + self.module.run_command.return_value = (1, 'stdout', 'stderr') + with self.assertRaises(AnsibleFailJson): + self.monit.reload() + + def test_reload(self): + self.module.run_command.return_value = (0, '', '') + with self.patch_status(monit.Status.OK): + with self.assertRaises(AnsibleExitJson): + self.monit.reload() + + def test_wait_for_status_to_stop_pending(self): + status = [ + monit.Status.MISSING, + monit.Status.DOES_NOT_EXIST, + monit.Status.INITIALIZING, + monit.Status.OK.pending(), + monit.Status.OK + ] + with self.patch_status(status) as get_status: + self.monit.wait_for_monit_to_stop_pending() + self.assertEqual(get_status.call_count, len(status)) + + def test_wait_for_status_change(self): + with self.patch_status([monit.Status.NOT_MONITORED, monit.Status.OK]) as get_status: + self.monit.wait_for_status_change(monit.Status.NOT_MONITORED) + self.assertEqual(get_status.call_count, 2) + + def test_wait_for_status_change_fail(self): + with self.patch_status([monit.Status.OK] * 3): + with self.assertRaises(AnsibleFailJson): + self.monit.wait_for_status_change(monit.Status.OK) + + def test_monitor(self): + with self.patch_status([monit.Status.NOT_MONITORED, monit.Status.OK.pending(), monit.Status.OK]): + with self.assertRaises(AnsibleExitJson): + self.monit.monitor() + + def test_monitor_fail(self): + with self.patch_status([monit.Status.NOT_MONITORED] * 3): + with self.assertRaises(AnsibleFailJson): + self.monit.monitor() + + def test_timeout(self): + self.monit.timeout = 0 + with self.patch_status(monit.Status.NOT_MONITORED.pending()): + with self.assertRaises(AnsibleFailJson): + self.monit.wait_for_monit_to_stop_pending() + + +@pytest.mark.parametrize('status_name', [name for name in monit.StatusValue.ALL_STATUS]) +def test_status_value(status_name): + value = getattr(monit.StatusValue, status_name.upper()) + status = monit.StatusValue(value) + assert getattr(status, 'is_%s' % status_name) + assert not all(getattr(status, 'is_%s' % name) for name in monit.StatusValue.ALL_STATUS if name != status_name) + + +BASIC_OUTPUT_CASES = [ + (TEST_OUTPUT % ('processX', name), getattr(monit.Status, name.upper())) + for name in monit.StatusValue.ALL_STATUS +] + + +@pytest.mark.parametrize('output, expected', BASIC_OUTPUT_CASES + [ + ('', monit.Status.MISSING), + (TEST_OUTPUT % ('processY', 'OK'), monit.Status.MISSING), + (TEST_OUTPUT % ('processX', 'Not Monitored - start pending'), monit.Status.OK), + (TEST_OUTPUT % ('processX', 'Monitored - stop pending'), monit.Status.NOT_MONITORED), + (TEST_OUTPUT % ('processX', 'Monitored - restart pending'), monit.Status.OK), + (TEST_OUTPUT % ('processX', 'Not Monitored - monitor pending'), monit.Status.OK), + (TEST_OUTPUT % ('processX', 'Does not exist'), monit.Status.DOES_NOT_EXIST), + (TEST_OUTPUT % ('processX', 'Not monitored'), monit.Status.NOT_MONITORED), + (TEST_OUTPUT % ('processX', 'Running'), monit.Status.OK), + (TEST_OUTPUT % ('processX', 'Execution failed | Does not exist'), monit.Status.EXECUTION_FAILED), +]) +def test_parse_status(output, expected): + status = monit.Monit(None, '', 'processX', 0)._parse_status(output, '') + assert status == expected + + +@pytest.mark.parametrize('output, expected', [ + ('This is monit version 5.18.1', '5.18.1'), + ('This is monit version 12.18', '12.18'), + ('This is monit version 5.1.12', '5.1.12'), +]) +def test_parse_version(output, expected): + module = mock.MagicMock() + module.run_command.return_value = (0, output, '') + raw_version, version_tuple = monit.Monit(module, '', 'processX', 0)._get_monit_version() + assert raw_version == expected