diff --git a/plugins/modules/monitoring/monit.py b/plugins/modules/monitoring/monit.py index 0a16e0598d..f40b8486bf 100644 --- a/plugins/modules/monitoring/monit.py +++ b/plugins/modules/monitoring/monit.py @@ -61,22 +61,22 @@ ALL_STATUS = [ ] -class StatusValue(namedtuple("Status", "status, is_pending")): +class StatusValue(namedtuple("Status", "value, is_pending")): MISSING = 0 OK = 1 NOT_MONITORED = 2 INITIALIZING = 3 DOES_NOT_EXIST = 4 - def __new__(cls, status, is_pending=False): - return super(StatusValue, cls).__new__(cls, status, is_pending) + def __new__(cls, value, is_pending=False): + return super(StatusValue, cls).__new__(cls, value, is_pending) def pending(self): - return StatusValue(self.status, True) + return StatusValue(self.value, True) def __getattr__(self, item): if item in ('is_%s' % status for status in ALL_STATUS): - return self.status == getattr(self, item[3:].upper()) + return self.value == getattr(self, item[3:].upper()) class Status(object): @@ -122,22 +122,24 @@ class Monit(object): return Status.MISSING status_val = re.findall(r"^\s*status\s*([\w\- ]+)", output, re.MULTILINE) - if status_val: - status_val = status_val[0].strip().upper() - if ' - ' not in 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 not status_val: + self.module.fail_json(msg="Unable to find process status") - if state == 'pending': - status = status.pending() - return status + status_val = status_val[0].strip().upper() + 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 -B' % (self.monit_bin_path), check_rc=True) @@ -155,7 +157,12 @@ class Monit(object): timeout_time = time.time() + self.timeout running_status = self.get_status() - while running_status.is_missing or running_status.is_pending or running_status.is_initializing: + waiting_status = [ + StatusValue.MISSING, + StatusValue.INITIALIZING, + StatusValue.DOES_NOT_EXIST, + ] + while running_status.is_pending or (running_status.value in waiting_status): if time.time() >= timeout_time: self.module.fail_json( msg='waited too long for "pending", or "initiating" status to go away ({0})'.format( @@ -184,12 +191,12 @@ class Monit(object): def change_state(self, state, expected_status, invert_expected=None): self.run_command(STATE_COMMAND_MAP[state]) status = self.get_status() - status_match = status.status == expected_status.status + status_match = status.value == expected_status.value if invert_expected: status_match = not status_match if status_match: 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) + self.module.fail_json(msg='%s process not %s' % (self.process_name, state), status=repr(status)) def stop(self): self.change_state('stopped', Status.NOT_MONITORED) diff --git a/tests/integration/targets/monit/aliases b/tests/integration/targets/monit/aliases new file mode 100644 index 0000000000..cf28a97558 --- /dev/null +++ b/tests/integration/targets/monit/aliases @@ -0,0 +1 @@ +destructive diff --git a/tests/integration/targets/monit/files/daemon.py b/tests/integration/targets/monit/files/daemon.py new file mode 100644 index 0000000000..ec7fac7450 --- /dev/null +++ b/tests/integration/targets/monit/files/daemon.py @@ -0,0 +1,124 @@ +"""Generic linux daemon base class for python. +http://www.jejik.com/files/examples/daemon3x.py +""" + +import sys, os, time, atexit, signal + + +class Daemon: + """A generic daemon class. + + Usage: subclass the daemon class and override the run() method.""" + + def __init__(self, pidfile): + self.pidfile = pidfile + + def daemonize(self): + """Deamonize class. UNIX double fork mechanism.""" + + try: + pid = os.fork() + if pid > 0: + # exit first parent + sys.exit(0) + except OSError as err: + sys.stderr.write('fork #1 failed: {0}\n'.format(err)) + sys.exit(1) + + # decouple from parent environment + os.chdir('/') + os.setsid() + os.umask(0) + + # do second fork + try: + pid = os.fork() + if pid > 0: + # exit from second parent + sys.exit(0) + except OSError as err: + sys.stderr.write('fork #2 failed: {0}\n'.format(err)) + sys.exit(1) + + # redirect standard file descriptors + sys.stdout.flush() + sys.stderr.flush() + si = open(os.devnull, 'r') + so = open(os.devnull, 'a+') + se = open(os.devnull, 'a+') + + os.dup2(si.fileno(), sys.stdin.fileno()) + os.dup2(so.fileno(), sys.stdout.fileno()) + os.dup2(se.fileno(), sys.stderr.fileno()) + + # write pidfile + atexit.register(self.delpid) + + pid = str(os.getpid()) + with open(self.pidfile, 'w+') as f: + f.write(pid + '\n') + + def delpid(self): + os.remove(self.pidfile) + + def start(self): + """Start the daemon.""" + + # Check for a pidfile to see if the daemon already runs + try: + with open(self.pidfile, 'r') as pf: + + pid = int(pf.read().strip()) + except IOError: + pid = None + + if pid: + message = "pidfile {0} already exist. " + \ + "Daemon already running?\n" + sys.stderr.write(message.format(self.pidfile)) + sys.exit(1) + + # Start the daemon + self.daemonize() + self.run() + + def stop(self): + """Stop the daemon.""" + + # Get the pid from the pidfile + try: + with open(self.pidfile, 'r') as pf: + pid = int(pf.read().strip()) + except IOError: + pid = None + + if not pid: + message = "pidfile {0} does not exist. " + \ + "Daemon not running?\n" + sys.stderr.write(message.format(self.pidfile)) + return # not an error in a restart + + # Try killing the daemon process + try: + while 1: + os.kill(pid, signal.SIGTERM) + time.sleep(0.1) + except OSError as err: + e = str(err.args) + if e.find("No such process") > 0: + if os.path.exists(self.pidfile): + os.remove(self.pidfile) + else: + print(str(err.args)) + sys.exit(1) + + def restart(self): + """Restart the daemon.""" + self.stop() + self.start() + + def run(self): + """You should override this method when you subclass Daemon. + + It will be called after the process has been daemonized by + start() or restart().""" 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..8ebca78e13 --- /dev/null +++ b/tests/integration/targets/monit/files/httpd_echo.py @@ -0,0 +1,57 @@ +import sys +from daemon 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 MyServer(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) + + +class MyDaemon(Daemon): + def run(self): + webServer = HTTPServer((hostname, server_port), MyServer) + 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__": + daemon = MyDaemon('/tmp/httpd_echo.pid') + if len(sys.argv) == 2: + if 'start' == sys.argv[1]: + daemon.start() + elif 'stop' == sys.argv[1]: + daemon.stop() + elif 'restart' == sys.argv[1]: + daemon.restart() + else: + print("Unknown command") + sys.exit(2) + sys.exit(0) + else: + print("usage: %s start|stop|restart" % sys.argv[0]) + sys.exit(2) diff --git a/tests/integration/targets/monit/handlers/main.yml b/tests/integration/targets/monit/handlers/main.yml new file mode 100644 index 0000000000..9ce628ee7e --- /dev/null +++ b/tests/integration/targets/monit/handlers/main.yml @@ -0,0 +1,16 @@ +--- +- name: start monit + become: yes + service: name=monit state=started + +- name: restart monit + become: yes + service: name=monit state=restarted + +- name: reload monit + become: yes + service: name=monit state=reloaded + +- name: stop monit + become: yes + service: name=monit state=stopped 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/main.yml b/tests/integration/targets/monit/tasks/main.yml new file mode 100644 index 0000000000..d04f035965 --- /dev/null +++ b/tests/integration/targets/monit/tasks/main.yml @@ -0,0 +1,53 @@ +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- block: + - name: install monit + become: yes + package: + name: monit + state: present + + - name: monit config + become: yes + template: + src: "monitrc.j2" + dest: "/etc/monit/monitrc" + + - name: process monit config + become: yes + template: + src: "httpd_echo.j2" + dest: "/etc/monit/conf.d/httpd_echo" + + - name: copy process file + become: yes + copy: + src: "{{item}}" + dest: "/opt/{{item}}" + loop: + - daemon.py + - httpd_echo.py + + - 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 diff --git a/tests/integration/targets/monit/tasks/test.yml b/tests/integration/targets/monit/tasks/test.yml new file mode 100644 index 0000000000..3e600acb8e --- /dev/null +++ b/tests/integration/targets/monit/tasks/test.yml @@ -0,0 +1,26 @@ +# order is important +- 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_state.yml b/tests/integration/targets/monit/tasks/test_state.yml new file mode 100644 index 0000000000..6742a6e4dc --- /dev/null +++ b/tests/integration/targets/monit/tasks/test_state.yml @@ -0,0 +1,51 @@ +- name: verify initial state (up) + command: "curl -sf http://localhost:8082/hello" + args: + warn: false + when: initial_state == 'up' + +- name: verify initial state (down) + command: "curl -sf http://localhost:8082/hello" + args: + warn: false + register: curl_result + failed_when: curl_result == 0 + when: initial_state == 'down' + +- 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 + +- name: check that service is {{ state }} (expected 'up') + command: "curl -sf http://localhost:8082/hello" + args: + warn: false + when: expected_state == 'up' + +- name: check that service is {{ state }} (expected 'down') + command: "curl -sf http://localhost:8082/hello" + args: + warn: false + register: curl_result + failed_when: curl_result == 0 + when: expected_state == 'down' + +- 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/httpd_echo.j2 b/tests/integration/targets/monit/templates/httpd_echo.j2 new file mode 100644 index 0000000000..0f605c6e5c --- /dev/null +++ b/tests/integration/targets/monit/templates/httpd_echo.j2 @@ -0,0 +1,4 @@ +check process httpd_echo with pidfile /tmp/httpd_echo.pid + start program = "{{ansible_python.executable}} /opt/httpd_echo.py start" + stop program = "{{ansible_python.executable}} /opt/httpd_echo.py stop" + if failed host localhost port 8082 then restart diff --git a/tests/integration/targets/monit/templates/monitrc.j2 b/tests/integration/targets/monit/templates/monitrc.j2 new file mode 100644 index 0000000000..b4e8ed5203 --- /dev/null +++ b/tests/integration/targets/monit/templates/monitrc.j2 @@ -0,0 +1,14 @@ +set daemon 2 +set logfile /var/log/monit.log +set idfile /var/lib/monit/id +set statefile /var/lib/monit/state + +set eventqueue + basedir /var/lib/monit/events + slots 100 + +set httpd port 2812 and + use address localhost + allow localhost + +include /etc/monit/conf.d/* diff --git a/tests/unit/plugins/modules/monitoring/test_monit.py b/tests/unit/plugins/modules/monitoring/test_monit.py index f3d4c46b92..a12213eda4 100644 --- a/tests/unit/plugins/modules/monitoring/test_monit.py +++ b/tests/unit/plugins/modules/monitoring/test_monit.py @@ -55,16 +55,21 @@ class MonitTest(unittest.TestCase): def test_reload(self): self.module.run_command.return_value = (0, '', '') + with self.patch_status(monit.Status.OK) as get_status: + with self.assertRaises(AnsibleExitJson): + self.monit.reload() + + def test_wait_for_status(self): self.monit._sleep_time = 0 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: - with self.assertRaises(AnsibleExitJson): - self.monit.reload() + self.monit.wait_for_monit_to_stop_pending('ok') self.assertEqual(get_status.call_count, len(status)) def test_monitor(self): @@ -105,6 +110,8 @@ BASIC_OUTPUT_CASES = [ (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), ]) def test_parse_status(output, expected): status = monit.Monit(None, '', 'processX', 0)._parse_status(output)