From 80cd8329e0ff0bf5169444d59805e4aa458a2949 Mon Sep 17 00:00:00 2001 From: Martin Migasiewicz <616250+martinm82@users.noreply.github.com> Date: Wed, 22 Jul 2020 15:54:58 +0200 Subject: [PATCH] launchd: new module to control services on macOS hosts (#305) --- plugins/modules/launchd.py | 1 + plugins/modules/system/launchd.py | 480 ++++++++++++++++++ tests/integration/targets/launchd/aliases | 3 + .../launchd/files/ansible_test_service.py | 21 + .../integration/targets/launchd/meta/main.yml | 4 + .../targets/launchd/tasks/main.yml | 22 + .../targets/launchd/tasks/setup.yml | 20 + .../targets/launchd/tasks/teardown.yml | 27 + .../targets/launchd/tasks/test.yml | 8 + .../launchd/tasks/tests/test_reload.yml | 68 +++ .../launchd/tasks/tests/test_restart.yml | 43 ++ .../launchd/tasks/tests/test_runatload.yml | 32 ++ .../launchd/tasks/tests/test_start_stop.yml | 112 ++++ .../launchd/tasks/tests/test_unknown.yml | 11 + .../launchd/tasks/tests/test_unload.yml | 62 +++ .../templates/launchd.test.service.plist.j2 | 13 + .../modified.launchd.test.service.plist.j2 | 13 + .../integration/targets/launchd/vars/main.yml | 4 + tests/sanity/ignore-2.10.txt | 1 + tests/sanity/ignore-2.11.txt | 1 + tests/sanity/ignore-2.9.txt | 1 + 21 files changed, 947 insertions(+) create mode 120000 plugins/modules/launchd.py create mode 100644 plugins/modules/system/launchd.py create mode 100644 tests/integration/targets/launchd/aliases create mode 100644 tests/integration/targets/launchd/files/ansible_test_service.py create mode 100644 tests/integration/targets/launchd/meta/main.yml create mode 100644 tests/integration/targets/launchd/tasks/main.yml create mode 100644 tests/integration/targets/launchd/tasks/setup.yml create mode 100644 tests/integration/targets/launchd/tasks/teardown.yml create mode 100644 tests/integration/targets/launchd/tasks/test.yml create mode 100644 tests/integration/targets/launchd/tasks/tests/test_reload.yml create mode 100644 tests/integration/targets/launchd/tasks/tests/test_restart.yml create mode 100644 tests/integration/targets/launchd/tasks/tests/test_runatload.yml create mode 100644 tests/integration/targets/launchd/tasks/tests/test_start_stop.yml create mode 100644 tests/integration/targets/launchd/tasks/tests/test_unknown.yml create mode 100644 tests/integration/targets/launchd/tasks/tests/test_unload.yml create mode 100644 tests/integration/targets/launchd/templates/launchd.test.service.plist.j2 create mode 100644 tests/integration/targets/launchd/templates/modified.launchd.test.service.plist.j2 create mode 100644 tests/integration/targets/launchd/vars/main.yml diff --git a/plugins/modules/launchd.py b/plugins/modules/launchd.py new file mode 120000 index 0000000000..38013fb253 --- /dev/null +++ b/plugins/modules/launchd.py @@ -0,0 +1 @@ +./system/launchd.py \ No newline at end of file diff --git a/plugins/modules/system/launchd.py b/plugins/modules/system/launchd.py new file mode 100644 index 0000000000..76cbf565a0 --- /dev/null +++ b/plugins/modules/system/launchd.py @@ -0,0 +1,480 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Martin Migasiewicz +# 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 + +DOCUMENTATION = r''' +--- +module: launchd +author: +- Martin Migasiewicz (@martinm82) +short_description: Manage macOS services +version_added: 1.0.0 +description: +- Manage launchd services on target macOS hosts. +options: + name: + description: + - Name of the service. + type: str + required: true + state: + description: + - C(started)/C(stopped) are idempotent actions that will not run + commands unless necessary. + - Launchd does not support C(restarted) nor C(reloaded) natively. + These will trigger a stop/start (restarted) or an unload/load + (reloaded). + - C(restarted) unloads and loads the service before start to ensure + that the latest job definition (plist) is used. + - C(reloaded) unloads and loads the service to ensure that the latest + job definition (plist) is used. Whether a service is started or + stopped depends on the content of the definition file. + type: str + choices: [ reloaded, restarted, started, stopped, unloaded ] + enabled: + description: + - Whether the service should start on boot. + - B(At least one of state and enabled are required.) + type: bool + force_stop: + description: + - Whether the service should not be restarted automatically by launchd. + - Services might have the 'KeepAlive' attribute set to true in a launchd configuration. + In case this is set to true, stopping a service will cause that launchd starts the service again. + - Set this option to C(yes) to let this module change the 'KeepAlive' attribute to false. + type: bool + default: no +notes: +- A user must privileged to manage services using this module. +requirements: +- A system managed by launchd +- The plistlib python library +''' + +EXAMPLES = r''' +- name: Make sure spotify webhelper is started + community.general.launchd: + name: com.spotify.webhelper + state: started + +- name: Deploy custom memcached job definition + template: + src: org.memcached.plist.j2 + dest: /Library/LaunchDaemons/org.memcached.plist + +- name: Run memcached + community.general.launchd: + name: org.memcached + state: started + +- name: Stop memcached + community.general.launchd: + name: org.memcached + state: stopped + +- name: Stop memcached + community.general.launchd: + name: org.memcached + state: stopped + force_stop: yes + +- name: Restart memcached + community.general.launchd: + name: org.memcached + state: restarted + +- name: Unload memcached + community.general.launchd: + name: org.memcached + state: unloaded +''' + +RETURN = r''' +status: + description: Metadata about service status + returned: always + type: dict + sample: + { + "current_pid": "-", + "current_state": "stopped", + "previous_pid": "82636", + "previous_state": "running" + } +''' + +import os +import plistlib +from abc import ABCMeta, abstractmethod +from time import sleep + +from ansible.module_utils.basic import AnsibleModule + + +class ServiceState: + UNKNOWN = 0 + LOADED = 1 + STOPPED = 2 + STARTED = 3 + UNLOADED = 4 + + @staticmethod + def to_string(state): + strings = { + ServiceState.UNKNOWN: 'unknown', + ServiceState.LOADED: 'loaded', + ServiceState.STOPPED: 'stopped', + ServiceState.STARTED: 'started', + ServiceState.UNLOADED: 'unloaded' + } + return strings[state] + + +class Plist: + def __init__(self, module, service): + self.__changed = False + self.__service = service + + state, pid, dummy, dummy = LaunchCtlList(module, service).run() + + self.__file = self.__find_service_plist(service) + if self.__file is None: + msg = 'Unable to infer the path of %s service plist file' % service + if pid is None and state == ServiceState.UNLOADED: + msg += ' and it was not found among active services' + module.fail_json(msg=msg) + self.__update(module) + + def __find_service_plist(self, service_name): + """Finds the plist file associated with a service""" + + launchd_paths = [ + '~/Library/LaunchAgents', + '/Library/LaunchAgents', + '/Library/LaunchDaemons', + '/System/Library/LaunchAgents', + '/System/Library/LaunchDaemons' + ] + + for path in launchd_paths: + try: + files = os.listdir(os.path.expanduser(path)) + except OSError: + continue + + filename = '%s.plist' % service_name + if filename in files: + return os.path.join(path, filename) + return None + + def __update(self, module): + self.__handle_param_enabled(module) + self.__handle_param_force_stop(module) + + def __handle_param_enabled(self, module): + if module.params['enabled'] is not None: + service_plist = plistlib.readPlist(self.__file) + + # Enable/disable service startup at boot if requested + # Launchctl does not expose functionality to set the RunAtLoad + # attribute of a job definition. So we parse and modify the job + # definition plist file directly for this purpose. + if module.params['enabled'] is not None: + enabled = service_plist.get('RunAtLoad', False) + if module.params['enabled'] != enabled: + service_plist['RunAtLoad'] = module.params['enabled'] + + # Update the plist with one of the changes done. + if not module.check_mode: + plistlib.writePlist(service_plist, self.__file) + self.__changed = True + + def __handle_param_force_stop(self, module): + if module.params['force_stop'] is not None: + service_plist = plistlib.readPlist(self.__file) + + # Set KeepAlive to false in case force_stop is defined to avoid + # that the service gets restarted when stopping was requested. + if module.params['force_stop'] is not None: + keep_alive = service_plist.get('KeepAlive', False) + if module.params['force_stop'] and keep_alive: + service_plist['KeepAlive'] = not module.params['force_stop'] + + # Update the plist with one of the changes done. + if not module.check_mode: + plistlib.writePlist(service_plist, self.__file) + self.__changed = True + + def is_changed(self): + return self.__changed + + def get_file(self): + return self.__file + + +class LaunchCtlTask(object): + __metaclass__ = ABCMeta + WAITING_TIME = 5 # seconds + + def __init__(self, module, service, plist): + self._module = module + self._service = service + self._plist = plist + self._launch = self._module.get_bin_path('launchctl', True) + + def run(self): + """Runs a launchd command like 'load', 'unload', 'start', 'stop', etc. + and returns the new state and pid. + """ + self.runCommand() + return self.get_state() + + @abstractmethod + def runCommand(self): + pass + + def get_state(self): + rc, out, err = self._launchctl("list") + if rc != 0: + self._module.fail_json( + msg='Failed to get status of %s' % (self._launch)) + + state = ServiceState.UNLOADED + service_pid = "-" + status_code = None + for line in out.splitlines(): + if line.strip(): + pid, last_exit_code, label = line.split('\t') + if label.strip() == self._service: + service_pid = pid + status_code = last_exit_code + + # From launchctl man page: + # If the number [...] is negative, it represents the + # negative of the signal which killed the job. Thus, + # "-15" would indicate that the job was terminated with + # SIGTERM. + if last_exit_code not in ['0', '-2', '-3', '-9', '-15']: + # Something strange happened and we have no clue in + # which state the service is now. Therefore we mark + # the service state as UNKNOWN. + state = ServiceState.UNKNOWN + elif pid != '-': + # PID seems to be an integer so we assume the service + # is started. + state = ServiceState.STARTED + else: + # Exit code is 0 and PID is not available so we assume + # the service is stopped. + state = ServiceState.STOPPED + break + return (state, service_pid, status_code, err) + + def start(self): + rc, out, err = self._launchctl("start") + # Unfortunately launchd does not wait until the process really started. + sleep(self.WAITING_TIME) + return (rc, out, err) + + def stop(self): + rc, out, err = self._launchctl("stop") + # Unfortunately launchd does not wait until the process really stopped. + sleep(self.WAITING_TIME) + return (rc, out, err) + + def restart(self): + # TODO: check for rc, out, err + self.stop() + return self.start() + + def reload(self): + # TODO: check for rc, out, err + self.unload() + return self.load() + + def load(self): + return self._launchctl("load") + + def unload(self): + return self._launchctl("unload") + + def _launchctl(self, command): + service_or_plist = self._plist.get_file() if command in [ + 'load', 'unload'] else self._service if command in ['start', 'stop'] else "" + + rc, out, err = self._module.run_command( + '%s %s %s' % (self._launch, command, service_or_plist)) + + if rc != 0: + msg = "Unable to %s '%s' (%s): '%s'" % ( + command, self._service, self._plist.get_file(), err) + self._module.fail_json(msg=msg) + + return (rc, out, err) + + +class LaunchCtlStart(LaunchCtlTask): + def __init__(self, module, service, plist): + super(LaunchCtlStart, self).__init__(module, service, plist) + + def runCommand(self): + state, dummy, dummy, dummy = self.get_state() + + if state == ServiceState.STOPPED or state == ServiceState.LOADED: + self.reload() + self.start() + elif state == ServiceState.STARTED: + # In case the service is already in started state but the + # job definition was changed we need to unload/load the + # service and start the service again. + if self._plist.is_changed(): + self.reload() + self.start() + elif state == ServiceState.UNLOADED: + self.load() + self.start() + elif state == ServiceState.UNKNOWN: + # We are in an unknown state, let's try to reload the config + # and start the service again. + self.reload() + self.start() + + +class LaunchCtlStop(LaunchCtlTask): + def __init__(self, module, service, plist): + super(LaunchCtlStop, self).__init__(module, service, plist) + + def runCommand(self): + state, dummy, dummy, dummy = self.get_state() + + if state == ServiceState.STOPPED: + # In case the service is stopped and we might later decide + # to start it, we need to reload the job definition by + # forcing an unload and load first. + # Afterwards we need to stop it as it might have been + # started again (KeepAlive or RunAtLoad). + if self._plist.is_changed(): + self.reload() + self.stop() + elif state == ServiceState.STARTED or state == ServiceState.LOADED: + if self._plist.is_changed(): + self.reload() + self.stop() + elif state == ServiceState.UNKNOWN: + # We are in an unknown state, let's try to reload the config + # and stop the service gracefully. + self.reload() + self.stop() + + +class LaunchCtlReload(LaunchCtlTask): + def __init__(self, module, service, plist): + super(LaunchCtlReload, self).__init__(module, service, plist) + + def runCommand(self): + state, dummy, dummy, dummy = self.get_state() + + if state == ServiceState.UNLOADED: + # launchd throws an error if we do an unload on an already + # unloaded service. + self.load() + else: + self.reload() + + +class LaunchCtlUnload(LaunchCtlTask): + def __init__(self, module, service, plist): + super(LaunchCtlUnload, self).__init__(module, service, plist) + + def runCommand(self): + state, dummy, dummy, dummy = self.get_state() + self.unload() + + +class LaunchCtlRestart(LaunchCtlReload): + def __init__(self, module, service, plist): + super(LaunchCtlRestart, self).__init__(module, service, plist) + + def runCommand(self): + super(LaunchCtlRestart, self).runCommand() + self.start() + + +class LaunchCtlList(LaunchCtlTask): + def __init__(self, module, service): + super(LaunchCtlList, self).__init__(module, service, None) + + def runCommand(self): + # Do nothing, the list functionality is done by the + # base class run method. + pass + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict(type='str', required=True), + state=dict(type='str', choices=['reloaded', 'restarted', 'started', 'stopped', 'unloaded']), + enabled=dict(type='bool'), + force_stop=dict(type='bool', default=False), + ), + supports_check_mode=True, + required_one_of=[ + ['state', 'enabled'], + ], + ) + + service = module.params['name'] + action = module.params['state'] + rc = 0 + out = err = '' + result = { + 'name': service, + 'changed': False, + 'status': {}, + } + + # We will tailor the plist file in case one of the options + # (enabled, force_stop) was specified. + plist = Plist(module, service) + result['changed'] = plist.is_changed() + + # Gather information about the service to be controlled. + state, pid, dummy, dummy = LaunchCtlList(module, service).run() + result['status']['previous_state'] = ServiceState.to_string(state) + result['status']['previous_pid'] = pid + + # Map the actions to specific tasks + tasks = { + 'started': LaunchCtlStart(module, service, plist), + 'stopped': LaunchCtlStop(module, service, plist), + 'restarted': LaunchCtlRestart(module, service, plist), + 'reloaded': LaunchCtlReload(module, service, plist), + 'unloaded': LaunchCtlUnload(module, service, plist) + } + + status_code = '0' + # Run the requested task + if not module.check_mode: + state, pid, status_code, err = tasks[action].run() + + result['status']['current_state'] = ServiceState.to_string(state) + result['status']['current_pid'] = pid + result['status']['status_code'] = status_code + result['status']['error'] = err + + if (result['status']['current_state'] != result['status']['previous_state'] or + result['status']['current_pid'] != result['status']['previous_pid']): + result['changed'] = True + if module.check_mode: + result['changed'] = True + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/launchd/aliases b/tests/integration/targets/launchd/aliases new file mode 100644 index 0000000000..a377870962 --- /dev/null +++ b/tests/integration/targets/launchd/aliases @@ -0,0 +1,3 @@ +shippable/posix/group1 +skip/freebsd +skip/rhel diff --git a/tests/integration/targets/launchd/files/ansible_test_service.py b/tests/integration/targets/launchd/files/ansible_test_service.py new file mode 100644 index 0000000000..87a23fc47d --- /dev/null +++ b/tests/integration/targets/launchd/files/ansible_test_service.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import sys + +if __name__ == '__main__': + if sys.version_info[0] >= 3: + import http.server + import socketserver + PORT = int(sys.argv[1]) + Handler = http.server.SimpleHTTPRequestHandler + httpd = socketserver.TCPServer(("", PORT), Handler) + httpd.serve_forever() + else: + import mimetypes + mimetypes.init() + mimetypes.add_type('application/json', '.json') + import SimpleHTTPServer + SimpleHTTPServer.test() diff --git a/tests/integration/targets/launchd/meta/main.yml b/tests/integration/targets/launchd/meta/main.yml new file mode 100644 index 0000000000..039249398e --- /dev/null +++ b/tests/integration/targets/launchd/meta/main.yml @@ -0,0 +1,4 @@ +--- + +dependencies: + - prepare_tests diff --git a/tests/integration/targets/launchd/tasks/main.yml b/tests/integration/targets/launchd/tasks/main.yml new file mode 100644 index 0000000000..8ca72fb1fc --- /dev/null +++ b/tests/integration/targets/launchd/tasks/main.yml @@ -0,0 +1,22 @@ +--- + +- name: Test launchd module + block: + - name: Expect that launchctl exists + stat: + path: /bin/launchctl + register: launchctl_check + failed_when: + - not launchctl_check.stat.exists + + - name: Run tests + include_tasks: test.yml + with_items: + - test_unknown + - test_start_stop + - test_restart + - test_unload + - test_reload + - test_runatload + + when: ansible_os_family == 'Darwin' diff --git a/tests/integration/targets/launchd/tasks/setup.yml b/tests/integration/targets/launchd/tasks/setup.yml new file mode 100644 index 0000000000..1ec57bf659 --- /dev/null +++ b/tests/integration/targets/launchd/tasks/setup.yml @@ -0,0 +1,20 @@ +--- + +- name: "[{{ item }}] Deploy test service configuration" + template: + src: "{{ launchd_service_name }}.plist.j2" + dest: "{{ launchd_plist_location }}" + become: yes + +- name: install the test daemon script + copy: + src: ansible_test_service.py + dest: /usr/local/sbin/ansible_test_service + mode: '755' + +- name: rewrite shebang in the test daemon script + lineinfile: + path: /usr/local/sbin/ansible_test_service + line: "#!{{ ansible_python_interpreter | realpath }}" + insertbefore: BOF + firstmatch: yes diff --git a/tests/integration/targets/launchd/tasks/teardown.yml b/tests/integration/targets/launchd/tasks/teardown.yml new file mode 100644 index 0000000000..50b0a36a7b --- /dev/null +++ b/tests/integration/targets/launchd/tasks/teardown.yml @@ -0,0 +1,27 @@ +--- + +- name: "[{{ item }}] Unload service" + launchd: + name: "{{ launchd_service_name }}" + state: unloaded + become: yes + register: launchd_unloaded_result + +- name: "[{{ item }}] Validation" + assert: + that: + - launchd_unloaded_result is success + - launchd_unloaded_result.status.current_state == 'unloaded' + - launchd_unloaded_result.status.current_pid == '-' + +- name: "[{{ item }}] Remove test service configuration" + file: + path: "{{ launchd_plist_location }}" + state: absent + become: yes + +- name: "[{{ item }}] Remove test service server" + file: + path: "/usr/local/sbin/ansible_test_service" + state: absent + become: yes diff --git a/tests/integration/targets/launchd/tasks/test.yml b/tests/integration/targets/launchd/tasks/test.yml new file mode 100644 index 0000000000..211b051d7d --- /dev/null +++ b/tests/integration/targets/launchd/tasks/test.yml @@ -0,0 +1,8 @@ +--- + +- name: "Running {{ item }}" + block: + - include_tasks: setup.yml + - include_tasks: "tests/{{ item }}.yml" + always: + - include_tasks: teardown.yml diff --git a/tests/integration/targets/launchd/tasks/tests/test_reload.yml b/tests/integration/targets/launchd/tasks/tests/test_reload.yml new file mode 100644 index 0000000000..fe2682abda --- /dev/null +++ b/tests/integration/targets/launchd/tasks/tests/test_reload.yml @@ -0,0 +1,68 @@ +--- + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given a started service in check_mode" + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_1_launchd_start_result_check_mode" + check_mode: yes + +- name: "[{{ item }}] Assert that everything work in check mode" + assert: + that: + - test_1_launchd_start_result_check_mode is success + - test_1_launchd_start_result_check_mode is changed + +- name: "[{{ item }}] Given a started service..." + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_1_launchd_start_result" + + +- name: "[{{ item }}] The started service should run on port 21212" + wait_for: + port: 21212 + delay: 5 + timeout: 10 + +- name: "[{{ item }}] Deploy a new test service configuration with a new port 21213" + template: + src: "modified.{{ launchd_service_name }}.plist.j2" + dest: "{{ launchd_plist_location }}" + become: yes + +- name: "[{{ item }}] When reloading the service..." + launchd: + name: "{{ launchd_service_name }}" + state: reloaded + become: yes + register: "test_1_launchd_reload_result" + +- name: "[{{ item }}] Validate that service was reloaded" + assert: + that: + - test_1_launchd_reload_result is success + - test_1_launchd_reload_result is changed + - test_1_launchd_reload_result.status.previous_pid == test_1_launchd_start_result.status.current_pid + - test_1_launchd_reload_result.status.previous_state == test_1_launchd_start_result.status.current_state + - test_1_launchd_reload_result.status.current_state == 'stopped' + - test_1_launchd_reload_result.status.current_pid == '-' + +- name: "[{{ item }}] Start the service with the new configuration..." + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_1_launchd_start_result" + + +- name: "[{{ item }}] The started service should run on port 21213" + wait_for: + port: 21213 + delay: 5 + timeout: 10 diff --git a/tests/integration/targets/launchd/tasks/tests/test_restart.yml b/tests/integration/targets/launchd/tasks/tests/test_restart.yml new file mode 100644 index 0000000000..976775678d --- /dev/null +++ b/tests/integration/targets/launchd/tasks/tests/test_restart.yml @@ -0,0 +1,43 @@ +--- + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given a started service..." + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_1_launchd_start_result" + + +- name: "[{{ item }}] When restarting the service in check mode" + launchd: + name: "{{ launchd_service_name }}" + state: restarted + become: yes + register: "test_1_launchd_restart_result_check_mode" + check_mode: yes + +- name: "[{{ item }}] Validate that service was restarted in check mode" + assert: + that: + - test_1_launchd_restart_result_check_mode is success + - test_1_launchd_restart_result_check_mode is changed + +- name: "[{{ item }}] When restarting the service..." + launchd: + name: "{{ launchd_service_name }}" + state: restarted + become: yes + register: "test_1_launchd_restart_result" + +- name: "[{{ item }}] Validate that service was restarted" + assert: + that: + - test_1_launchd_restart_result is success + - test_1_launchd_restart_result is changed + - test_1_launchd_restart_result.status.previous_pid == test_1_launchd_start_result.status.current_pid + - test_1_launchd_restart_result.status.previous_state == test_1_launchd_start_result.status.current_state + - test_1_launchd_restart_result.status.current_state == 'started' + - test_1_launchd_restart_result.status.current_pid != '-' + - test_1_launchd_restart_result.status.status_code == '0' diff --git a/tests/integration/targets/launchd/tasks/tests/test_runatload.yml b/tests/integration/targets/launchd/tasks/tests/test_runatload.yml new file mode 100644 index 0000000000..08f21efce7 --- /dev/null +++ b/tests/integration/targets/launchd/tasks/tests/test_runatload.yml @@ -0,0 +1,32 @@ +--- +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given a started service with RunAtLoad set to true..." + launchd: + name: "{{ launchd_service_name }}" + state: started + enabled: yes + become: yes + register: test_1_launchd_start_result + +- name: "[{{ item }}] Validate that service was started" + assert: + that: + - test_1_launchd_start_result is success + - test_1_launchd_start_result is changed + - test_1_launchd_start_result.status.previous_pid == '-' + - test_1_launchd_start_result.status.previous_state == 'unloaded' + - test_1_launchd_start_result.status.current_state == 'started' + - test_1_launchd_start_result.status.current_pid != '-' + - test_1_launchd_start_result.status.status_code == '0' + +- name: "[{{ item }}] Validate that RunAtLoad is set to true" + replace: + path: "{{ launchd_plist_location }}" + regexp: | + \s+RunAtLoad + \s+ + replace: found_run_at_load + check_mode: yes + register: contents_would_have + failed_when: not contents_would_have is changed diff --git a/tests/integration/targets/launchd/tasks/tests/test_start_stop.yml b/tests/integration/targets/launchd/tasks/tests/test_start_stop.yml new file mode 100644 index 0000000000..b3cc380e85 --- /dev/null +++ b/tests/integration/targets/launchd/tasks/tests/test_start_stop.yml @@ -0,0 +1,112 @@ +--- + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given a started service in check mode" + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_1_launchd_start_result_check_mode" + check_mode: yes + + +- name: "[{{ item }}] Validate that service was started in check mode" + assert: + that: + - test_1_launchd_start_result_check_mode is success + - test_1_launchd_start_result_check_mode is changed + + +- name: "[{{ item }}] Given a started service..." + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_1_launchd_start_result" + + +- name: "[{{ item }}] Validate that service was started" + assert: + that: + - test_1_launchd_start_result is success + - test_1_launchd_start_result is changed + - test_1_launchd_start_result.status.previous_pid == '-' + - test_1_launchd_start_result.status.previous_state == 'unloaded' + - test_1_launchd_start_result.status.current_state == 'started' + - test_1_launchd_start_result.status.current_pid != '-' + - test_1_launchd_start_result.status.status_code == '0' + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given a stopped service..." + launchd: + name: "{{ launchd_service_name }}" + state: stopped + become: yes + register: "test_2_launchd_stop_result" + +- name: "[{{ item }}] Validate that service was stopped after it was started" + assert: + that: + - test_2_launchd_stop_result is success + - test_2_launchd_stop_result is changed + - test_2_launchd_stop_result.status.previous_pid == test_1_launchd_start_result.status.current_pid + - test_2_launchd_stop_result.status.previous_state == test_1_launchd_start_result.status.current_state + - test_2_launchd_stop_result.status.current_state == 'stopped' + - test_2_launchd_stop_result.status.current_pid == '-' + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given a stopped service..." + launchd: + name: "{{ launchd_service_name }}" + state: stopped + become: yes + register: "test_3_launchd_stop_result" + +- name: "[{{ item }}] Validate that service can be stopped after being already stopped" + assert: + that: + - test_3_launchd_stop_result is success + - not test_3_launchd_stop_result is changed + - test_3_launchd_stop_result.status.previous_pid == '-' + - test_3_launchd_stop_result.status.previous_state == 'stopped' + - test_3_launchd_stop_result.status.current_state == 'stopped' + - test_3_launchd_stop_result.status.current_pid == '-' + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given a started service..." + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_4_launchd_start_result" + +- name: "[{{ item }}] Validate that service was started..." + assert: + that: + - test_4_launchd_start_result is success + - test_4_launchd_start_result is changed + - test_4_launchd_start_result.status.previous_pid == '-' + - test_4_launchd_start_result.status.previous_state == 'stopped' + - test_4_launchd_start_result.status.current_state == 'started' + - test_4_launchd_start_result.status.current_pid != '-' + - test_4_launchd_start_result.status.status_code == '0' + +- name: "[{{ item }}] And when service is started again..." + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_5_launchd_start_result" + +- name: "[{{ item }}] Validate that service is still in the same state as before" + assert: + that: + - test_5_launchd_start_result is success + - not test_5_launchd_start_result is changed + - test_5_launchd_start_result.status.previous_pid == test_4_launchd_start_result.status.current_pid + - test_5_launchd_start_result.status.previous_state == test_4_launchd_start_result.status.current_state + - test_5_launchd_start_result.status.status_code == '0' diff --git a/tests/integration/targets/launchd/tasks/tests/test_unknown.yml b/tests/integration/targets/launchd/tasks/tests/test_unknown.yml new file mode 100644 index 0000000000..e005d87ee1 --- /dev/null +++ b/tests/integration/targets/launchd/tasks/tests/test_unknown.yml @@ -0,0 +1,11 @@ +--- + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Expect that an error occurs when an unknown service is used." + launchd: + name: com.acme.unknownservice + state: started + register: result + failed_when: + - not '"Unable to infer the path of com.acme.unknownservice service plist file and it was not found among active services" in result.msg' diff --git a/tests/integration/targets/launchd/tasks/tests/test_unload.yml b/tests/integration/targets/launchd/tasks/tests/test_unload.yml new file mode 100644 index 0000000000..b51a87fb3a --- /dev/null +++ b/tests/integration/targets/launchd/tasks/tests/test_unload.yml @@ -0,0 +1,62 @@ +--- + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given a started service..." + launchd: + name: "{{ launchd_service_name }}" + state: started + become: yes + register: "test_1_launchd_start_result" + + +- name: "[{{ item }}] When unloading the service in check mode" + launchd: + name: "{{ launchd_service_name }}" + state: unloaded + become: yes + register: "test_1_launchd_unloaded_result_check_mode" + check_mode: yes + +- name: "[{{ item }}] Validate that service was unloaded in check mode" + assert: + that: + - test_1_launchd_unloaded_result_check_mode is success + - test_1_launchd_unloaded_result_check_mode is changed + + +- name: "[{{ item }}] When unloading the service..." + launchd: + name: "{{ launchd_service_name }}" + state: unloaded + become: yes + register: "test_1_launchd_unloaded_result" + +- name: "[{{ item }}] Validate that service was unloaded" + assert: + that: + - test_1_launchd_unloaded_result is success + - test_1_launchd_unloaded_result is changed + - test_1_launchd_unloaded_result.status.previous_pid == test_1_launchd_start_result.status.current_pid + - test_1_launchd_unloaded_result.status.previous_state == test_1_launchd_start_result.status.current_state + - test_1_launchd_unloaded_result.status.current_state == 'unloaded' + - test_1_launchd_unloaded_result.status.current_pid == '-' + +# ----------------------------------------------------------- + +- name: "[{{ item }}] Given an unloaded service on an unloaded service..." + launchd: + name: "{{ launchd_service_name }}" + state: unloaded + become: yes + register: "test_2_launchd_unloaded_result" + +- name: "[{{ item }}] Validate that service did not change and is still unloaded" + assert: + that: + - test_2_launchd_unloaded_result is success + - not test_2_launchd_unloaded_result is changed + - test_2_launchd_unloaded_result.status.previous_pid == '-' + - test_2_launchd_unloaded_result.status.previous_state == 'unloaded' + - test_2_launchd_unloaded_result.status.current_state == 'unloaded' + - test_2_launchd_unloaded_result.status.current_pid == '-' diff --git a/tests/integration/targets/launchd/templates/launchd.test.service.plist.j2 b/tests/integration/targets/launchd/templates/launchd.test.service.plist.j2 new file mode 100644 index 0000000000..27affa3b39 --- /dev/null +++ b/tests/integration/targets/launchd/templates/launchd.test.service.plist.j2 @@ -0,0 +1,13 @@ + + + + + Label + {{ launchd_service_name }} + ProgramArguments + + /usr/local/sbin/ansible_test_service + 21212 + + + diff --git a/tests/integration/targets/launchd/templates/modified.launchd.test.service.plist.j2 b/tests/integration/targets/launchd/templates/modified.launchd.test.service.plist.j2 new file mode 100644 index 0000000000..ac25cab0d7 --- /dev/null +++ b/tests/integration/targets/launchd/templates/modified.launchd.test.service.plist.j2 @@ -0,0 +1,13 @@ + + + + + Label + {{ launchd_service_name }} + ProgramArguments + + /usr/local/sbin/ansible_test_service + 21213 + + + diff --git a/tests/integration/targets/launchd/vars/main.yml b/tests/integration/targets/launchd/vars/main.yml new file mode 100644 index 0000000000..2d58be16ed --- /dev/null +++ b/tests/integration/targets/launchd/vars/main.yml @@ -0,0 +1,4 @@ +--- + +launchd_service_name: launchd.test.service +launchd_plist_location: /Library/LaunchDaemons/{{ launchd_service_name }}.plist diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index d4837e2513..4f84aa136b 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -1235,6 +1235,7 @@ plugins/modules/system/java_keystore.py validate-modules:doc-missing-type plugins/modules/system/java_keystore.py validate-modules:parameter-type-not-in-doc plugins/modules/system/java_keystore.py validate-modules:undocumented-parameter plugins/modules/system/kernel_blacklist.py validate-modules:parameter-type-not-in-doc +plugins/modules/system/launchd.py use-argspec-type-path # False positive plugins/modules/system/lbu.py validate-modules:doc-elements-mismatch plugins/modules/system/locale_gen.py validate-modules:parameter-type-not-in-doc plugins/modules/system/lvg.py pylint:blacklisted-name diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index d4837e2513..4f84aa136b 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -1235,6 +1235,7 @@ plugins/modules/system/java_keystore.py validate-modules:doc-missing-type plugins/modules/system/java_keystore.py validate-modules:parameter-type-not-in-doc plugins/modules/system/java_keystore.py validate-modules:undocumented-parameter plugins/modules/system/kernel_blacklist.py validate-modules:parameter-type-not-in-doc +plugins/modules/system/launchd.py use-argspec-type-path # False positive plugins/modules/system/lbu.py validate-modules:doc-elements-mismatch plugins/modules/system/locale_gen.py validate-modules:parameter-type-not-in-doc plugins/modules/system/lvg.py pylint:blacklisted-name diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index e254fbfd68..3a371939c0 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -955,6 +955,7 @@ plugins/modules/system/interfaces_file.py validate-modules:parameter-type-not-in plugins/modules/system/java_cert.py pylint:blacklisted-name plugins/modules/system/java_keystore.py validate-modules:doc-missing-type plugins/modules/system/kernel_blacklist.py validate-modules:parameter-type-not-in-doc +plugins/modules/system/launchd.py use-argspec-type-path # False positive plugins/modules/system/locale_gen.py validate-modules:parameter-type-not-in-doc plugins/modules/system/lvg.py pylint:blacklisted-name plugins/modules/system/lvol.py pylint:blacklisted-name