#!/usr/bin/python # Copyright (c) 2013, Daniel Jaouen # Copyright (c) 2016, Indrajit Raychaudhuri # # 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 annotations DOCUMENTATION = r""" module: homebrew_cask author: - "Indrajit Raychaudhuri (@indrajitr)" - "Daniel Jaouen (@danieljaouen)" - "Enric Lluelles (@enriclluelles)" short_description: Install and uninstall homebrew casks description: - Manages Homebrew casks. extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: full diff_mode: support: none options: name: description: - Name of cask to install or remove. aliases: ['cask', 'package', 'pkg'] type: list elements: str path: description: - "':' separated list of paths to search for 'brew' executable." default: '/usr/local/bin:/opt/homebrew/bin' type: path state: description: - State of the cask. choices: ['absent', 'installed', 'latest', 'present', 'removed', 'uninstalled', 'upgraded'] default: present type: str sudo_password: description: - The sudo password to be passed to E(SUDO_ASKPASS). required: false type: str update_homebrew: description: - Update homebrew itself first. - Note that C(brew cask update) is a synonym for C(brew update). type: bool default: false install_options: description: - Options flags to install a package. aliases: ['options'] type: list elements: str accept_external_apps: description: - Allow external apps. type: bool default: false upgrade_all: description: - Upgrade all casks. - Mutually exclusive with C(upgraded) state. type: bool default: false aliases: ['upgrade'] greedy: description: - Upgrade casks that auto update. - Passes C(--greedy) to C(brew outdated --cask) when checking if an installed cask has a newer version available, or to C(brew upgrade --cask) when upgrading all casks. type: bool default: false """ EXAMPLES = r""" - name: Install cask community.general.homebrew_cask: name: alfred state: present - name: Remove cask community.general.homebrew_cask: name: alfred state: absent - name: Install cask with install options community.general.homebrew_cask: name: alfred state: present install_options: 'appdir=/Applications' - name: Install cask with install options community.general.homebrew_cask: name: alfred state: present install_options: 'debug,appdir=/Applications' - name: Install cask with force option community.general.homebrew_cask: name: alfred state: present install_options: force - name: Allow external app community.general.homebrew_cask: name: alfred state: present accept_external_apps: true - name: Remove cask with force option community.general.homebrew_cask: name: alfred state: absent install_options: force - name: Upgrade all casks community.general.homebrew_cask: upgrade_all: true - name: Upgrade all casks with greedy option community.general.homebrew_cask: upgrade_all: true greedy: true - name: Upgrade given cask with force option community.general.homebrew_cask: name: alfred state: upgraded install_options: force - name: Upgrade cask with greedy option community.general.homebrew_cask: name: 1password state: upgraded greedy: true - name: Using sudo password for installing cask community.general.homebrew_cask: name: wireshark state: present sudo_password: "{{ ansible_become_pass }}" """ import os import re import tempfile from ansible_collections.community.general.plugins.module_utils.version import LooseVersion from ansible_collections.community.general.plugins.module_utils.homebrew import HomebrewValidate from ansible.module_utils.common.text.converters import to_bytes from ansible.module_utils.basic import AnsibleModule # exceptions -------------------------------------------------------------- {{{ class HomebrewCaskException(Exception): pass # /exceptions ------------------------------------------------------------- }}} # utils ------------------------------------------------------------------- {{{ def _create_regex_group_complement(s): lines = (line.strip() for line in s.split("\n") if line.strip()) chars = [_f for _f in (line.split("#")[0].strip() for line in lines) if _f] group = rf"[^{''.join(chars)}]" return re.compile(group) # /utils ------------------------------------------------------------------ }}} class HomebrewCask: """A class to manage Homebrew casks.""" # class regexes ------------------------------------------------ {{{ VALID_CASK_CHARS = r""" \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) . # dots / # slash (for taps) \- # dashes @ # at symbol \+ # plus symbol """ INVALID_CASK_REGEX = _create_regex_group_complement(VALID_CASK_CHARS) # /class regexes ----------------------------------------------- }}} # class validations -------------------------------------------- {{{ @classmethod def valid_cask(cls, cask): """A valid cask is either None or alphanumeric + backslashes.""" if cask is None: return True return isinstance(cask, str) and not cls.INVALID_CASK_REGEX.search(cask) @classmethod def valid_state(cls, state): """ A valid state is one of: - installed - absent """ if state is None: return True else: return isinstance(state, str) and state.lower() in ( "installed", "absent", ) @classmethod def valid_module(cls, module): """A valid module is an instance of AnsibleModule.""" return isinstance(module, AnsibleModule) # /class validations ------------------------------------------- }}} # class properties --------------------------------------------- {{{ @property def module(self): return self._module @module.setter def module(self, module): if not self.valid_module(module): self._module = None self.failed = True self.message = f"Invalid module: {module}." raise HomebrewCaskException(self.message) else: self._module = module return module @property def path(self): return self._path @path.setter def path(self, path): if not HomebrewValidate.valid_path(path): self._path = [] self.failed = True self.message = f"Invalid path: {path}." raise HomebrewCaskException(self.message) else: if isinstance(path, str): self._path = path.split(":") else: self._path = path return path @property def brew_path(self): return self._brew_path @brew_path.setter def brew_path(self, brew_path): if not HomebrewValidate.valid_brew_path(brew_path): self._brew_path = None self.failed = True self.message = f"Invalid brew_path: {brew_path}." raise HomebrewCaskException(self.message) else: self._brew_path = brew_path return brew_path @property def params(self): return self._params @params.setter def params(self, params): self._params = self.module.params return self._params @property def current_cask(self): return self._current_cask @current_cask.setter def current_cask(self, cask): if not self.valid_cask(cask): self._current_cask = None self.failed = True self.message = f"Invalid cask: {cask}." raise HomebrewCaskException(self.message) else: self._current_cask = cask return cask @property def brew_version(self): try: return self._brew_version except AttributeError: return None @brew_version.setter def brew_version(self, brew_version): self._brew_version = brew_version # /class properties -------------------------------------------- }}} def __init__( self, module, path=path, casks=None, state=None, sudo_password=None, update_homebrew=False, install_options=None, accept_external_apps=False, upgrade_all=False, greedy=False, ): if not install_options: install_options = list() self._setup_status_vars() self._setup_instance_vars( module=module, path=path, casks=casks, state=state, sudo_password=sudo_password, update_homebrew=update_homebrew, install_options=install_options, accept_external_apps=accept_external_apps, upgrade_all=upgrade_all, greedy=greedy, ) self._prep() # prep --------------------------------------------------------- {{{ def _setup_status_vars(self): self.failed = False self.changed = False self.changed_count = 0 self.unchanged_count = 0 self.message = "" def _setup_instance_vars(self, **kwargs): for key, val in kwargs.items(): setattr(self, key, val) def _prep(self): self._prep_brew_path() def _prep_brew_path(self): if not self.module: self.brew_path = None self.failed = True self.message = "AnsibleModule not set." raise HomebrewCaskException(self.message) self.brew_path = self.module.get_bin_path( "brew", required=True, opt_dirs=self.path, ) if not self.brew_path: self.brew_path = None self.failed = True self.message = "Unable to locate homebrew executable." raise HomebrewCaskException("Unable to locate homebrew executable.") return self.brew_path def _status(self): return (self.failed, self.changed, self.message) # /prep -------------------------------------------------------- }}} def run(self): try: self._run() except HomebrewCaskException: pass if not self.failed and (self.changed_count + self.unchanged_count > 1): self.message = f"Changed: {self.changed_count}, Unchanged: {self.unchanged_count}" (failed, changed, message) = self._status() return (failed, changed, message) # checks ------------------------------------------------------- {{{ def _current_cask_is_outdated(self): if not self.valid_cask(self.current_cask): return False if self._brew_cask_command_is_deprecated(): base_opts = [self.brew_path, "outdated", "--cask"] else: base_opts = [self.brew_path, "cask", "outdated"] cask_is_outdated_command = base_opts + (["--greedy"] if self.greedy else []) + [self.current_cask] rc, out, err = self.module.run_command(cask_is_outdated_command) return out != "" def _current_cask_is_installed(self): if not self.valid_cask(self.current_cask): self.failed = True self.message = f"Invalid cask: {self.current_cask}." raise HomebrewCaskException(self.message) if self._brew_cask_command_is_deprecated(): base_opts = [self.brew_path, "list", "--cask"] else: base_opts = [self.brew_path, "cask", "list"] cmd = base_opts + [self.current_cask] rc, out, err = self.module.run_command(cmd) return rc == 0 def _get_brew_version(self): if self.brew_version: return self.brew_version cmd = [self.brew_path, "--version"] dummy, out, dummy = self.module.run_command(cmd, check_rc=True) pattern = r"Homebrew (.*)(\d+\.\d+\.\d+)(-dirty)?" rematch = re.search(pattern, out) if not rematch: self.module.fail_json(msg="Failed to match regex to get brew version", stdout=out) self.brew_version = rematch.groups()[1] return self.brew_version def _brew_cask_command_is_deprecated(self): # The `brew cask` replacements were fully available in 2.6.0 (https://brew.sh/2020/12/01/homebrew-2.6.0/) return LooseVersion(self._get_brew_version()) >= LooseVersion("2.6.0") # /checks ------------------------------------------------------ }}} # commands ----------------------------------------------------- {{{ def _run(self): if self.upgrade_all: return self._upgrade_all() if self.casks: if self.state == "installed": return self._install_casks() elif self.state == "upgraded": return self._upgrade_casks() elif self.state == "absent": return self._uninstall_casks() self.failed = True self.message = "You must select a cask to install." raise HomebrewCaskException(self.message) # sudo_password fix ---------------------- {{{ def _run_command_with_sudo_password(self, cmd): rc, out, err = "", "", "" with tempfile.NamedTemporaryFile() as sudo_askpass_file: sudo_askpass_file.write(b"#!/bin/sh\n\necho '%s'\n" % to_bytes(self.sudo_password)) os.chmod(sudo_askpass_file.name, 0o700) sudo_askpass_file.file.close() rc, out, err = self.module.run_command(cmd, environ_update={"SUDO_ASKPASS": sudo_askpass_file.name}) self.module.add_cleanup_file(sudo_askpass_file.name) return (rc, out, err) # /sudo_password fix --------------------- }}} # updated -------------------------------- {{{ def _update_homebrew(self): rc, out, err = self.module.run_command( [ self.brew_path, "update", ] ) if rc == 0: if out and isinstance(out, str): already_updated = any( re.search(r"Already up-to-date.", s.strip(), re.IGNORECASE) for s in out.split("\n") if s ) if not already_updated: self.changed = True self.message = "Homebrew updated successfully." else: self.message = "Homebrew already up-to-date." return True else: self.failed = True self.message = err.strip() raise HomebrewCaskException(self.message) # /updated ------------------------------- }}} # _upgrade_all --------------------------- {{{ def _upgrade_all(self): if self.module.check_mode: self.changed = True self.message = "Casks would be upgraded." raise HomebrewCaskException(self.message) if self._brew_cask_command_is_deprecated(): cmd = [self.brew_path, "upgrade", "--cask"] else: cmd = [self.brew_path, "cask", "upgrade"] if self.greedy: cmd = cmd + ["--greedy"] rc, out, err = "", "", "" if self.sudo_password: rc, out, err = self._run_command_with_sudo_password(cmd) else: rc, out, err = self.module.run_command(cmd) if rc == 0: # 'brew upgrade --cask' does not output anything if no casks are upgraded if not out.strip(): self.message = "Homebrew casks already upgraded." # handle legacy 'brew cask upgrade' elif re.search(r"==> No Casks to upgrade", out.strip(), re.IGNORECASE): self.message = "Homebrew casks already upgraded." else: self.changed = True self.message = "Homebrew casks upgraded." return True else: self.failed = True self.message = err.strip() raise HomebrewCaskException(self.message) # /_upgrade_all -------------------------- }}} # installed ------------------------------ {{{ def _install_current_cask(self): if not self.valid_cask(self.current_cask): self.failed = True self.message = f"Invalid cask: {self.current_cask}." raise HomebrewCaskException(self.message) if "--force" not in self.install_options and self._current_cask_is_installed(): self.unchanged_count += 1 self.message = f"Cask already installed: {self.current_cask}" return True if self.module.check_mode: self.changed = True self.message = f"Cask would be installed: {self.current_cask}" raise HomebrewCaskException(self.message) if self._brew_cask_command_is_deprecated(): base_opts = [self.brew_path, "install", "--cask"] else: base_opts = [self.brew_path, "cask", "install"] opts = base_opts + [self.current_cask] + self.install_options cmd = [opt for opt in opts if opt] rc, out, err = "", "", "" if self.sudo_password: rc, out, err = self._run_command_with_sudo_password(cmd) else: rc, out, err = self.module.run_command(cmd) if self._current_cask_is_installed(): self.changed_count += 1 self.changed = True self.message = f"Cask installed: {self.current_cask}" return True elif self.accept_external_apps and re.search(r"Error: It seems there is already an App at", err): self.unchanged_count += 1 self.message = f"Cask already installed: {self.current_cask}" return True else: self.failed = True self.message = err.strip() raise HomebrewCaskException(self.message) def _install_casks(self): for cask in self.casks: self.current_cask = cask self._install_current_cask() return True # /installed ----------------------------- }}} # upgraded ------------------------------- {{{ def _upgrade_current_cask(self): command = "upgrade" if not self.valid_cask(self.current_cask): self.failed = True self.message = f"Invalid cask: {self.current_cask}." raise HomebrewCaskException(self.message) if not self._current_cask_is_installed(): command = "install" if self._current_cask_is_installed() and not self._current_cask_is_outdated(): self.message = f"Cask is already upgraded: {self.current_cask}" self.unchanged_count += 1 return True if self.module.check_mode: self.changed = True self.message = f"Cask would be upgraded: {self.current_cask}" raise HomebrewCaskException(self.message) if self._brew_cask_command_is_deprecated(): base_opts = [self.brew_path, command, "--cask"] else: base_opts = [self.brew_path, "cask", command] opts = base_opts + self.install_options + [self.current_cask] cmd = [opt for opt in opts if opt] rc, out, err = "", "", "" if self.sudo_password: rc, out, err = self._run_command_with_sudo_password(cmd) else: rc, out, err = self.module.run_command(cmd) if self._current_cask_is_installed() and not self._current_cask_is_outdated(): self.changed_count += 1 self.changed = True self.message = f"Cask upgraded: {self.current_cask}" return True else: self.failed = True self.message = err.strip() raise HomebrewCaskException(self.message) def _upgrade_casks(self): for cask in self.casks: self.current_cask = cask self._upgrade_current_cask() return True # /upgraded ------------------------------ }}} # uninstalled ---------------------------- {{{ def _uninstall_current_cask(self): if not self.valid_cask(self.current_cask): self.failed = True self.message = f"Invalid cask: {self.current_cask}." raise HomebrewCaskException(self.message) if not self._current_cask_is_installed(): self.unchanged_count += 1 self.message = f"Cask already uninstalled: {self.current_cask}" return True if self.module.check_mode: self.changed = True self.message = f"Cask would be uninstalled: {self.current_cask}" raise HomebrewCaskException(self.message) if self._brew_cask_command_is_deprecated(): base_opts = [self.brew_path, "uninstall", "--cask"] else: base_opts = [self.brew_path, "cask", "uninstall"] opts = base_opts + [self.current_cask] + self.install_options cmd = [opt for opt in opts if opt] rc, out, err = "", "", "" if self.sudo_password: rc, out, err = self._run_command_with_sudo_password(cmd) else: rc, out, err = self.module.run_command(cmd) if not self._current_cask_is_installed(): self.changed_count += 1 self.changed = True self.message = f"Cask uninstalled: {self.current_cask}" return True else: self.failed = True self.message = err.strip() raise HomebrewCaskException(self.message) def _uninstall_casks(self): for cask in self.casks: self.current_cask = cask self._uninstall_current_cask() return True # /uninstalled --------------------------- }}} # /commands ---------------------------------------------------- }}} def main(): module = AnsibleModule( argument_spec=dict( name=dict( aliases=["pkg", "package", "cask"], type="list", elements="str", ), path=dict( default="/usr/local/bin:/opt/homebrew/bin", type="path", ), state=dict( default="present", choices=[ "present", "installed", "latest", "upgraded", "absent", "removed", "uninstalled", ], ), sudo_password=dict( type="str", no_log=True, ), update_homebrew=dict( default=False, type="bool", ), install_options=dict( aliases=["options"], type="list", elements="str", ), accept_external_apps=dict( default=False, type="bool", ), upgrade_all=dict( default=False, aliases=["upgrade"], type="bool", ), greedy=dict( default=False, type="bool", ), ), supports_check_mode=True, ) module.run_command_environ_update = dict(LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C") p = module.params if p["name"]: casks = p["name"] else: casks = None path = p["path"] if path: path = path.split(":") state = p["state"] if state in ("present", "installed"): state = "installed" if state in ("latest", "upgraded"): state = "upgraded" if state in ("absent", "removed", "uninstalled"): state = "absent" sudo_password = p["sudo_password"] update_homebrew = p["update_homebrew"] upgrade_all = p["upgrade_all"] greedy = p["greedy"] p["install_options"] = p["install_options"] or [] install_options = [f"--{install_option}" for install_option in p["install_options"]] accept_external_apps = p["accept_external_apps"] brew_cask = HomebrewCask( module=module, path=path, casks=casks, state=state, sudo_password=sudo_password, update_homebrew=update_homebrew, install_options=install_options, accept_external_apps=accept_external_apps, upgrade_all=upgrade_all, greedy=greedy, ) (failed, changed, message) = brew_cask.run() if failed: module.fail_json(msg=message) else: module.exit_json(changed=changed, msg=message) if __name__ == "__main__": main()