summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/general/plugins/modules/launchd.py
diff options
context:
space:
mode:
Diffstat (limited to 'ansible_collections/community/general/plugins/modules/launchd.py')
-rw-r--r--ansible_collections/community/general/plugins/modules/launchd.py522
1 files changed, 522 insertions, 0 deletions
diff --git a/ansible_collections/community/general/plugins/modules/launchd.py b/ansible_collections/community/general/plugins/modules/launchd.py
new file mode 100644
index 000000000..13a8ce086
--- /dev/null
+++ b/ansible_collections/community/general/plugins/modules/launchd.py
@@ -0,0 +1,522 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2018, Martin Migasiewicz <migasiew.nk@gmail.com>
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+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.
+extends_documentation_fragment:
+ - community.general.attributes
+attributes:
+ check_mode:
+ support: full
+ diff_mode:
+ support: none
+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(true) to let this module change the 'KeepAlive' attribute to false.
+ type: bool
+ default: false
+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: true
+
+- 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
+from ansible.module_utils.common.text.converters import to_native
+
+
+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, self.__service).run()
+
+ # Check if readPlist is available or not
+ self.old_plistlib = hasattr(plistlib, 'readPlist')
+
+ self.__file = self.__find_service_plist(self.__service)
+ if self.__file is None:
+ msg = 'Unable to infer the path of %s service plist file' % self.__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)
+
+ @staticmethod
+ def __find_service_plist(service_name):
+ """Finds the plist file associated with a service"""
+
+ launchd_paths = [
+ os.path.join(os.getenv('HOME'), 'Library/LaunchAgents'),
+ '/Library/LaunchAgents',
+ '/Library/LaunchDaemons',
+ '/System/Library/LaunchAgents',
+ '/System/Library/LaunchDaemons'
+ ]
+
+ for path in launchd_paths:
+ try:
+ files = os.listdir(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 __read_plist_file(self, module):
+ service_plist = {}
+ if self.old_plistlib:
+ return plistlib.readPlist(self.__file)
+
+ # readPlist is deprecated in Python 3 and onwards
+ try:
+ with open(self.__file, 'rb') as plist_fp:
+ service_plist = plistlib.load(plist_fp)
+ except Exception as e:
+ module.fail_json(msg="Failed to read plist file "
+ "%s due to %s" % (self.__file, to_native(e)))
+ return service_plist
+
+ def __write_plist_file(self, module, service_plist=None):
+ if not service_plist:
+ service_plist = {}
+
+ if self.old_plistlib:
+ plistlib.writePlist(service_plist, self.__file)
+ return
+ # writePlist is deprecated in Python 3 and onwards
+ try:
+ with open(self.__file, 'wb') as plist_fp:
+ plistlib.dump(service_plist, plist_fp)
+ except Exception as e:
+ module.fail_json(msg="Failed to write to plist file "
+ " %s due to %s" % (self.__file, to_native(e)))
+
+ def __handle_param_enabled(self, module):
+ if module.params['enabled'] is not None:
+ service_plist = self.__read_plist_file(module)
+
+ # 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:
+ self.__write_plist_file(module, service_plist)
+ self.__changed = True
+
+ def __handle_param_force_stop(self, module):
+ if module.params['force_stop'] is not None:
+ service_plist = self.__read_plist_file(module)
+
+ # 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:
+ self.__write_plist_file(module, service_plist)
+ 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 in (ServiceState.STOPPED, 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 in (ServiceState.STARTED, 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()