diff --git a/plugins/module_utils/homebrew.py b/plugins/module_utils/homebrew.py index 28168321094..28fa94bf49e 100644 --- a/plugins/module_utils/homebrew.py +++ b/plugins/module_utils/homebrew.py @@ -113,3 +113,27 @@ def valid_package(cls, package): return isinstance( package, string_types ) and not cls.INVALID_PACKAGE_REGEX.search(package) + + +def parse_brew_path(module) -> str: + """Attempt to find the Homebrew executable path. + + Requires: + - module has a `path` parameter + - path is a valid path string for the target OS. Otherwise, module.fail_json() + is called with msg="Invalid_path: ". + """ + path = module.params["path"] + if not HomebrewValidate.valid_path(path): + module.fail_json(msg="Invalid path: {}".format(path)) + + if isinstance(path, string_types): + paths = path.split(":") + else: + paths = path + + brew_path = module.get_bin_path("brew", required=True, opt_dirs=paths) + if not HomebrewValidate.valid_brew_path(brew_path): + module.fail_json(msg="Invalid brew path: {}".format(brew_path)) + + return brew_path diff --git a/plugins/modules/homebrew_services.py b/plugins/modules/homebrew_services.py new file mode 100644 index 00000000000..dfe850800d2 --- /dev/null +++ b/plugins/modules/homebrew_services.py @@ -0,0 +1,260 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Kit Ham +# +# 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 +import json + +__metaclass__ = type + + +DOCUMENTATION = """ +--- +module: homebrew_services +author: + - "Kit Ham (@kitizz)" +requirements: + - homebrew must already be installed on the target system +short_description: Services manager for Homebrew +description: + - Manages daemons and services via Homebrew +extends_documentation_fragment: + - community.general.attributes +options: + name: + description: + - An installed homebrew package whose service is to be updated. + aliases: [ 'formula', 'package', 'pkg' ] + type: str + path: + description: + - "A V(:) separated list of paths to search for C(brew) executable. + Since a package (I(formula) in homebrew parlance) location is prefixed relative to the actual path of C(brew) command, + providing an alternative C(brew) path enables managing different set of packages in an alternative location in the system." + default: '/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin' + type: path + state: + description: + - state of the package. + choices: [ 'present', 'absent', 'restarted' ] + default: present + type: str +""" + +EXAMPLES = """ +# Install foo package +- community.general.homebrew: + name: foo + state: present + +# Start the foo service (equivalent to C(brew services start foo)) +- community.general.homebrew_service: + name: foo + state: present + +# Restart the foo service (equivalent to C(brew services restart foo)) +- community.general.homebrew_service: + name: foo + state: restarted + +# Remove the foo service (equivalent to C(brew services stop foo)) +- community.general.homebrew_service: + name: foo + service_state: absent +""" + +RETURN = """ +msg: + description: + - Whether the service is now running, its PID if it is running, + and whether the running state changed (including restarts). + returned: always + type: str + sample: "Running: true, Changed: false, PID: 1234" +pid: + description: + - If the service is now running, this is the PID of the service, otherwise -1. + returned: success + type: int + sample: 1234 +running: + description: + - Whether the service is running after running this command. + returned: success + type: bool + sample: true +changed: + description: + - Whether the service state changed. This is true when an already running service is restarted. + returned: success + type: bool + sample: false +""" + +from dataclasses import dataclass +from typing import Callable, Optional + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.homebrew import ( + HomebrewValidate, parse_brew_path +) + + +@dataclass +class HomebrewServiceArgs: + """Stores validated arguments for an instance of an action. + + See DOCUMENTATION string for argument-specific information. + """ + + name: str + state: str + brew_path: str + + +@dataclass +class HomebrewServiceState: + """Stores the state of a Homebrew service.""" + + running: bool + pid: Optional[int] + + +def _brew_service_state(args: HomebrewServiceArgs, module: AnsibleModule): + cmd = [args.brew_path, "services", "info", args.name, "--json"] + rc, stdout, stderr = module.run_command(cmd) + + if rc != 0: + module.fail_json(msg=stderr.strip()) + + data = json.loads(stdout)[0] + return HomebrewServiceState(running=data["status"] == "started", pid=data["pid"]) + + +def _exit_with_state( + args: HomebrewServiceArgs, module: AnsibleModule, changed=False, message=None +): + state = _brew_service_state(args, module) + if message is None: + message = ( + f"Running: {state.running}, Changed: {state.running}, PID: {state.pid}" + ) + module.exit_json(msg=message, pid=state.pid, running=state.running, changed=changed) + + +def validate_and_load_arguments(module: AnsibleModule): + """Reuse the Homebrew module's validation logic to validate these arguments.""" + package = module.params["name"] # type: ignore + if not HomebrewValidate.valid_package(package): + module.fail_json(msg="Invalid package name: {}".format(package)) + + state = module.params["state"] # type: ignore + if state not in ["present", "absent", "restarted"]: + module.fail_json(msg="Invalid state: {}".format(state)) + + brew_path = parse_brew_path(module) + + return HomebrewServiceArgs(name=package, state=state, brew_path=brew_path) + + +def start_service(args: HomebrewServiceArgs, module: AnsibleModule): + """Start the requested brew service if it is not already running.""" + state = _brew_service_state(args, module) + if state.running: + # Nothing to do, return early. + _exit_with_state(args, module, changed=False, message="Service already running") + + if module.check_mode: + _exit_with_state(args, module, changed=True, message="Service would be started") + + start_cmd = [args.brew_path, "services", "start", args.name] + rc, stdout, stderr = module.run_command(start_cmd) + if rc != 0: + module.fail_json(msg=stderr.strip()) + + _exit_with_state(args, module, changed=True) + + +def stop_service(args: HomebrewServiceArgs, module: AnsibleModule): + """Stop the requested brew service if it is running.""" + state = _brew_service_state(args, module) + if not state.running: + # Nothing to do, return early. + _exit_with_state(args, module, changed=False, message="Service already stopped") + + if module.check_mode: + _exit_with_state(args, module, changed=True, message="Service would be stopped") + + stop_cmd = [args.brew_path, "services", "stop", args.name] + rc, stdout, stderr = module.run_command(stop_cmd) + if rc != 0: + module.fail_json(msg=stderr.strip()) + + _exit_with_state(args, module, changed=True) + + +def restart_service(args: HomebrewServiceArgs, module: AnsibleModule): + """Restart the requested brew service. This always results in a change.""" + if module.check_mode: + _exit_with_state( + args, module, changed=True, message="Service would be restarted" + ) + + restart_cmd = [args.brew_path, "services", "restart", args.name] + rc, stdout, stderr = module.run_command(restart_cmd) + if rc != 0: + module.fail_json(msg=stderr.strip()) + + _exit_with_state(args, module, changed=True) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict( + aliases=["pkg", "package", "formula"], + required=True, + type="str", + ), + state=dict( + required=True, + choices=["present", "absent", "restarted"], + ), + path=dict( + default="/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", + required=False, + type="path", + ), + ), + supports_check_mode=True, + ) + + module.run_command_environ_update = dict( + LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C" + ) + + # Pre-validate arguments. + service_args = validate_and_load_arguments(module) + + # Choose logic based on the desired state. + if service_args.state == "present": + start_service(service_args, module) + elif service_args.state == "absent": + stop_service(service_args, module) + elif service_args.state == "restarted": + restart_service(service_args, module) + + # Argument validation should make this unreachable. + module.fail_json( + msg="Code bug: Should not reach this point (state={})".format( + service_args.state + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/black/handlers/main.yml b/tests/integration/targets/black/handlers/main.yml new file mode 100644 index 00000000000..18856120d0d --- /dev/null +++ b/tests/integration/targets/black/handlers/main.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# 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 + +- name: uninstall black + community.general.homebrew: + name: black + state: absent + become: true + become_user: "{{ brew_stat.stat.pw_name }}" diff --git a/tests/integration/targets/black/tasks/main.yml b/tests/integration/targets/black/tasks/main.yml new file mode 100644 index 00000000000..7c008491860 --- /dev/null +++ b/tests/integration/targets/black/tasks/main.yml @@ -0,0 +1,69 @@ +--- +# Copyright (c) Ansible Project +# 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 + +- when: ansible_facts.distribution == 'MacOSX' + block: + - name: MACOS | Find brew binary + command: which brew + register: brew_which + + - name: MACOS | Get owner of brew binary + stat: + path: "{{ brew_which.stdout }}" + register: brew_stat + + - name: MACOS | Install black + community.general.homebrew: + name: black + state: present + become: true + become_user: "{{ brew_stat.stat.pw_name }}" + register: install_result + + - name: Check the black service is installed + assert: + that: + - install_result is success + + - name: Start the black service + community.general.homebrew_services: + name: black + state: present + become: true + become_user: "{{ brew_stat.stat.pw_name }}" + register: start_result + + - name: Check the black service is running + assert: + that: + - start_result is success + + - name: Restart the black service + community.general.homebrew_services: + name: black + state: restarted + become: true + become_user: "{{ brew_stat.stat.pw_name }}" + register: restart_result + + - name: Check the black service is restarted + assert: + that: + - restart_result is success + + - name: Stop the black service + community.general.homebrew_services: + name: black + state: present + become: true + become_user: "{{ brew_stat.stat.pw_name }}" + register: stop_result + + - name: Check the black service is stopped + assert: + that: + - stop_result is success + notify: + - uninstall black