diff --git a/lib/ansible/modules/system/launchd.py b/lib/ansible/modules/system/launchd.py index 294dae99fb4f70..db88defcc27efc 100644 --- a/lib/ansible/modules/system/launchd.py +++ b/lib/ansible/modules/system/launchd.py @@ -1,57 +1,53 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# (c) 2016, Brian Coca -# (c) 2106, Theo Crevon (https://github.com/tcr-ableton) -# -# This file is part of Ansible -# -# Ansible is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# Ansible is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. + +# Copyright: (c) 2016, Brian Coca +# Copyright: (c) 2016, Theo Crevon (https://github.com/tcr-ableton) # -# You should have received a copy of the GNU General Public License -# along with Ansible. If not, see . +# 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 +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} -DOCUMENTATION = ''' + +DOCUMENTATION = r''' module: launchd author: - "Ansible Core Team" - Theo Crevon (@tcr-ableton) -version_added: "2.2" +version_added: "2.6" short_description: Manage OS X services. description: - - Controls launchd services on target hosts. + - This module can be used to control launchd services on target OS X hosts. options: name: - required: true - description: - - Name of the service. - aliases: ['service'] + required: true + description: + - Name of the service. + aliases: ['service'] state: - required: false - default: null - choices: [ 'started', 'stopped', 'restarted', 'reloaded' ] - 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, so these will both trigger a stop and start as needed. + default: null + choices: [ 'started', 'stopped', 'restarted', 'reloaded' ] + 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, so these will both trigger a stop and start as needed. enabled: - required: false - choices: [ "yes", "no" ] - default: null - description: - - Whether the service should start on boot. B(At least one of state and enabled are required.) + choices: [ True, False ] + default: null + type: 'bool' + description: + - Whether the service should start on boot. B(At least one of state and enabled are required.) sleep: - required: false - default: 1 - description: - - If the service is being C(restarted) or C(reloaded) then sleep this many seconds between the stop and start command. This helps to workaround badly behaving services. + default: 1 + description: + - If the service is being C(restarted) or C(reloaded) then sleep this many seconds between the stop and start command. + - This helps to workaround badly behaving services. notes: - One option other than name is required. requirements: @@ -60,28 +56,41 @@ ''' EXAMPLES = ''' -# make sure spotify webhelper is started -- launchd: - name: com.spotify.webhelper - state: started - become: yes +- name: Make sure spotify webhelper is started + launchd: + name: com.spotify.webhelper + state: started + become: yes ''' RETURN = ''' -# +status: + description: metadata about service status + returned: always + type: dict + sample: + { + "current_pid": "-", + "current_state": "stopped", + "previous_pid": "82636", + "previous_state": "running" + } ''' import os from time import sleep + try: import plistlib HAS_PLIST = True except ImportError: HAS_PLIST = False + from ansible.module_utils.basic import AnsibleModule + def find_service_plist(service_name): - ''' finds the plist file associated with a service ''' + """Finds the plist file associated with a service""" launchd_paths = [ '~/Library/LaunchAgents', @@ -101,18 +110,27 @@ def find_service_plist(service_name): if filename == '%s.plist' % service_name: return os.path.join(path, filename) + def main(): # init module = AnsibleModule( - argument_spec = dict( - name = dict(required=True, type='str', aliases=['service']), - state = dict(choices=[ 'started', 'stopped', 'restarted', 'reloaded'], type='str'), - enabled = dict(type='bool'), - sleep = dict(type='int', default=1), + argument_spec=dict( + name=dict( + required=True, + aliases=['service'] + ), + state=dict( + choices=['started', 'stopped', 'restarted', 'reloaded'], ), - supports_check_mode=True, - required_one_of=[['state', 'enabled']], - ) + enabled=dict(type='bool'), + sleep=dict(type='int', + default=1), + ), + supports_check_mode=True, + required_one_of=[ + ['state', 'enabled'] + ], + ) launch = module.get_bin_path('launchctl', True) service = module.params['name'] @@ -120,18 +138,18 @@ def main(): rc = 0 out = err = '' result = { - 'name': service, + 'name': service, 'changed': False, 'status': {}, } - - rc, dout, err = module.run_command('%s list' % (launch)) + rc, out, err = module.run_command('%s list' % launch) if rc != 0: - module.fail_json(msg='running %s list failed' % (launch)) + module.fail_json(msg='running %s list failed' % launch) running = False found = False + for line in out.splitlines(): if line.strip(): pid, last_exit_code, label = line.split('\t') @@ -139,18 +157,26 @@ def main(): found = True if pid != '-': running = True + result['status']['previous_state'] = 'running' + result['status']['previous_pid'] = pid + else: + result['status']['previous_state'] = 'stopped' + result['status']['previous_pid'] = '-' break + if not found: + module.fail_json(msg="Unable to find the service %s among active services." % service) + # Enable/disable service startup at boot if requested if module.params['enabled'] is not None: plist_file = find_service_plist(service) if plist_file is None: - msg='unable to infer the path of %s service plist file' % service + msg = 'Unable to infer the path of %s service plist file' % service if not found: msg += ' and it was not found among active services' module.fail_json(msg=msg) - # Launchctl does not expose functionalities to set the RunAtLoad + # 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. service_plist = plistlib.readPlist(plist_file) @@ -164,9 +190,9 @@ def main(): do = None if action is not None: - if action == 'started': + if action == 'started' and not running: do = 'start' - elif action == 'stopped': + elif action == 'stopped' and running: do = 'stop' elif action in ['restarted', 'reloaded']: # launchctl does not support a restart/reload command. @@ -182,14 +208,29 @@ def main(): if do is not None: result['changed'] = True if not module.check_mode: - rc, out, err = module.run_command('%s %s %s %s' % (launch, do, service)) + rc, out, err = module.run_command('%s %s %s' % (launch, do, service)) + if rc != 0: + msg = "Unable to %s service %s: %s" % (do, service, err) + module.fail_json(msg=msg) + + rc, out, err = module.run_command('%s list' % launch) if rc != 0: - msg="Unable to %s service %s: %s" % (do, service,err) - if not found: - msg += '\nAlso the service was not found among active services' - module.fail_json(msg=msg) + module.fail_json(msg='Failed to get status of %s after %s' % (launch, do)) + + for line in out.splitlines(): + if line.strip(): + pid, last_exit_code, label = line.split('\t') + if label.strip() == service: + if pid != '-': + result['status']['current_state'] = 'running' + result['status']['current_pid'] = pid + else: + result['status']['current_state'] = 'stopped' + result['status']['current_pid'] = '-' + break module.exit_json(**result) -if __name__ == 'main': + +if __name__ == '__main__': main() diff --git a/test/units/modules/system/test_launchd.py b/test/units/modules/system/test_launchd.py new file mode 100644 index 00000000000000..9bb4749684e287 --- /dev/null +++ b/test/units/modules/system/test_launchd.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2018, Ansible Project +# Copyright (c) 2018, Abhijeet Kasurde +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ansible.modules.system import launchd +from units.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args +from ansible.compat.tests.mock import patch +from ansible.module_utils import basic + + +def get_bin_path(*args, **kwargs): + """Function to return path of launchctl binary.""" + return "/bin/launchctl" + + +class TestLaunchd(ModuleTestCase): + """Main class for testing launchd module.""" + + def setUp(self): + """Setup.""" + super(TestLaunchd, self).setUp() + self.module = launchd + self.mock_get_bin_path = patch.object(basic.AnsibleModule, 'get_bin_path', get_bin_path) + self.mock_get_bin_path.start() + self.addCleanup(self.mock_get_bin_path.stop) # ensure that the patching is 'undone' + + def tearDown(self): + """Teardown.""" + super(TestLaunchd, self).tearDown() + + def test_without_required_parameters(self): + """Failure must occurs when all parameters are missing.""" + with self.assertRaises(AnsibleFailJson): + set_module_args({}) + self.module.main() + + def test_start_service(self): + """Check that result is changed for service start.""" + set_module_args({ + 'name': 'com.apple.safaridavclient', + 'state': 'started', + }) + + commands_results = [ + (0, "\n".join(['- 0 com.apple.safaridavclient']), ''), + (0, '', ''), + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleExitJson) as result: + launchd.main() + self.assertTrue(result.exception.args[0]['changed']) + + self.assertEqual(run_command.call_count, 3) + + def test_start_already_started_service(self): + """Check that result is not changed for already service start.""" + set_module_args({ + 'name': 'com.apple.safaridavclient', + 'state': 'started', + }) + + commands_results = [ + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleExitJson) as result: + launchd.main() + self.assertEqual(result.exception.args[0]['changed'], False) + + self.assertEqual(run_command.call_count, 2) + + def test_start_service_check_mode(self): + """Check that result is changed in check mode for service start.""" + set_module_args({ + 'name': 'com.apple.safaridavclient', + 'state': 'started', + '_ansible_check_mode': True, + }) + + commands_results = [ + (0, "\n".join(['- 0 com.apple.safaridavclient']), ''), + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleExitJson) as result: + launchd.main() + self.assertTrue(result.exception.args[0]['changed']) + + self.assertEqual(run_command.call_count, 2) + + def test_start_unknown_service(self): + """Check that result is not changed for unknown service.""" + set_module_args({ + 'name': 'blah blah', + 'state': 'started', + }) + + commands_results = [ + (0, '', ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleFailJson) as result: + launchd.main() + self.assertFalse(result.exception.args[0]['changed']) + self.assertEqual(result.exception.args[0]['msg'], + 'Unable to find the service blah blah among active services.') + + self.assertEqual(run_command.call_count, 1) + + def test_stop_service(self): + """Check that result is changed for service stop.""" + set_module_args({ + 'name': 'com.apple.safaridavclient', + 'state': 'stopped', + }) + + commands_results = [ + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + (0, '', ''), + (0, "\n".join(['- 0 com.apple.safaridavclient']), ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleExitJson) as result: + launchd.main() + self.assertTrue(result.exception.args[0]['changed']) + + self.assertEqual(run_command.call_count, 3) + + def test_stop_already_stopped_service(self): + """Check that result is not changed for already service stopped.""" + set_module_args({ + 'name': 'com.apple.safaridavclient', + 'state': 'stopped', + }) + + commands_results = [ + (0, "\n".join(['- 0 com.apple.safaridavclient']), ''), + (0, "\n".join(['- 0 com.apple.safaridavclient']), ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleExitJson) as result: + launchd.main() + self.assertEqual(result.exception.args[0]['changed'], False) + + self.assertEqual(run_command.call_count, 2) + + def test_stop_service_check_mode(self): + """Check that result is changed in check mode for service stop.""" + set_module_args({ + 'name': 'com.apple.safaridavclient', + 'state': 'stopped', + '_ansible_check_mode': True, + }) + + commands_results = [ + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + (0, "\n".join(['- 0 com.apple.safaridavclient']), ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleExitJson) as result: + launchd.main() + self.assertTrue(result.exception.args[0]['changed']) + + self.assertEqual(run_command.call_count, 2) + + def test_restart_service(self): + """Check that result is changed for service restart.""" + set_module_args({ + 'name': 'com.apple.safaridavclient', + 'state': 'restarted', + }) + + commands_results = [ + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + (0, '', ''), + (0, '', ''), + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleExitJson) as result: + launchd.main() + self.assertTrue(result.exception.args[0]['changed']) + + self.assertEqual(run_command.call_count, 4) + + def test_reloaded_service(self): + """Check that result is changed for service reloaded.""" + set_module_args({ + 'name': 'com.apple.safaridavclient', + 'state': 'reloaded', + }) + + commands_results = [ + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + (0, '', ''), + (0, '', ''), + (0, "\n".join(['1200 0 com.apple.safaridavclient']), ''), + ] + + with patch.object(basic.AnsibleModule, 'run_command') as run_command: + run_command.side_effect = commands_results + with self.assertRaises(AnsibleExitJson) as result: + launchd.main() + self.assertTrue(result.exception.args[0]['changed']) + + self.assertEqual(run_command.call_count, 4)