From 8de1c0c205bf7f94a47ce95033319777097ab31c Mon Sep 17 00:00:00 2001
From: Simon Kelly <skelly@dimagi.com>
Date: Fri, 23 Oct 2020 12:26:23 +0200
Subject: [PATCH] monit: fix module detection of monitored process state
 (#1107)

* refactor and test

* require version >= 5.21.0

Prior to this version the status output was different

* python version compatability

* use exception classes from utils

* modify monit to use 'status' output instead of 'summary' output

The summary output is a fixed width table which truncates the
contents and prevents us from parsing the actual status of the
program.

* add integration tests + fixes

* remove unused handlers in monit integration test

* fix lint

* add '__metaclass__ = type' to integration python files

* raise AttributeError

* simplify status

* lint: add type to parameter docs

* remove lint ignore

* move monit process config into main file

* specify path to monit PID file

* set config location based on os_family

* create required directories

* update aliases to set group and skips

* add changelog

* add author

* add types to docs

* add EPEL repo

* custom vars for centos-6

* uninstall EPEL

* support older versions

* wait for status to change before exiting

* use 'validate' to force status updates

* handle 'execution failed'

* better status output for errors

* add more context to failure + standardize

* don't check rc for validate

* legacy string format support

* add integration test for 'reloaded' and 'present'

* don't wait after reload

* lint

* Revert "uninstall EPEL"

This reverts commit 4d548718d0fd6d93a06119f556cac01b74bf634a.

* make 'present' more robust

* Apply suggestions from code review

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

* add license header

* drop daemon.py and use python-daemon instead

* skip python2.6 which is not supported by python-daemon

* refactor test tasks for reuse

* cleanup files after test

* lint

* start process before enabling monit

This shouldn't be necessary but I'm adding it in the hopes
it will make tests more robust.

* retry task

* attempt to rescue the task on failure

* fix indentation

* ignore check if rescue ran

* restart monit instead of reload

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>
---
 .../fragments/1107-monit-fix-status-check.yml |   2 +
 plugins/modules/monitoring/monit.py           | 371 ++++++++++++------
 tests/integration/targets/monit/aliases       |   8 +
 .../targets/monit/defaults/main.yml           |   4 +
 .../targets/monit/files/httpd_echo.py         |  50 +++
 tests/integration/targets/monit/meta/main.yml |   2 +
 .../targets/monit/tasks/check_state.yml       |  20 +
 .../integration/targets/monit/tasks/main.yml  |  78 ++++
 .../integration/targets/monit/tasks/test.yml  |  28 ++
 .../targets/monit/tasks/test_errors.yml       |   6 +
 .../monit/tasks/test_reload_present.yml       |  60 +++
 .../targets/monit/tasks/test_state.yml        |  33 ++
 .../targets/monit/templates/monitrc.j2        |  13 +
 .../targets/monit/vars/CentOS-6.yml           |   1 +
 .../integration/targets/monit/vars/RedHat.yml |   1 +
 tests/integration/targets/monit/vars/Suse.yml |   1 +
 .../targets/monit/vars/defaults.yml           |   1 +
 tests/sanity/ignore-2.10.txt                  |   2 -
 tests/sanity/ignore-2.11.txt                  |   2 -
 tests/sanity/ignore-2.9.txt                   |   2 -
 .../plugins/modules/monitoring/test_monit.py  | 140 +++++++
 21 files changed, 698 insertions(+), 127 deletions(-)
 create mode 100644 changelogs/fragments/1107-monit-fix-status-check.yml
 create mode 100644 tests/integration/targets/monit/aliases
 create mode 100644 tests/integration/targets/monit/defaults/main.yml
 create mode 100644 tests/integration/targets/monit/files/httpd_echo.py
 create mode 100644 tests/integration/targets/monit/meta/main.yml
 create mode 100644 tests/integration/targets/monit/tasks/check_state.yml
 create mode 100644 tests/integration/targets/monit/tasks/main.yml
 create mode 100644 tests/integration/targets/monit/tasks/test.yml
 create mode 100644 tests/integration/targets/monit/tasks/test_errors.yml
 create mode 100644 tests/integration/targets/monit/tasks/test_reload_present.yml
 create mode 100644 tests/integration/targets/monit/tasks/test_state.yml
 create mode 100644 tests/integration/targets/monit/templates/monitrc.j2
 create mode 100644 tests/integration/targets/monit/vars/CentOS-6.yml
 create mode 100644 tests/integration/targets/monit/vars/RedHat.yml
 create mode 100644 tests/integration/targets/monit/vars/Suse.yml
 create mode 100644 tests/integration/targets/monit/vars/defaults.yml
 create mode 100644 tests/unit/plugins/modules/monitoring/test_monit.py

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 <simongdkelly@gmail.com>
+# 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