Repository: ansible-collections/community.general

Issue:
Calling brew cask install is disabled! Use brew install [--cask] instead.
##### SUMMARY
I have a simple playbook that ensures a set of brew casks are installed. It stopped working this morning (actually, probably three days ago with this [merge](https://github.com/Homebrew/brew/pull/10056/files#)) because, apparently, the Homebrew folks [disabled](https://github.com/Homebrew/brew/pull/10056/files#diff-6b41cd34991fd4bf2887d97970060cd2c3303f29c13d51480dce512a9ebad369R177) `brew cask install`. I'm submitting this as a bug report because I imagine it affects everyone using the `homebrew_cask` module. Cask installation is no longer possible with `homebrew_cask` until this issue is resolved.

##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
homebrew_cask

##### ANSIBLE VERSION
```
ansible 2.10.3
  config file = None
  configured module search path = ['/Users/dsnyder/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/dsnyder/.pyenv/versions/3.9.1/lib/python3.9/site-packages/ansible
  executable location = /Users/dsnyder/.pyenv/versions/3.9.1/bin/ansible
  python version = 3.9.1 (default, Dec 14 2020, 14:55:40) [Clang 11.0.3 (clang-1103.0.32.62)]
```

##### CONFIGURATION
```
// No Changes //
```

##### OS / ENVIRONMENT
- macOS Catalina v10.15.7
- MacBook Pro (13-inch, 2019, Four Thunderbolt 3 ports)
- Processor. 2.8 GHz Quad-Core Intel Core i7
- Memory. 16 GB 2133 MHz LPDDR3

##### STEPS TO REPRODUCE
Create a simple inventory playbook to ensure that the "docker" cask is installed locally.

<!--- Paste example playbooks or commands between quotes below -->
```
$ head -n 500 inventory docker.yml
==> inventory <==
localhost ansible_connection=local ansible_python_interpreter=/Users/dsnyder/.pyenv/shims/python3

==> docker.yml <==
---
- name: Homebrew Cask Demo
  hosts: all
  tasks:
    - name: Try to install the docker cask
      community.general.homebrew_cask:
        name: docker
        install_options: 'debug,appdir=/Applications'
        accept_external_apps: true
        state: present

$ ansible-playbook -i inventory docker.yml
```

##### EXPECTED RESULTS
Since I already have this cask installed, I expect Ansible to report that no change was required.

##### ACTUAL RESULTS
The play fails with this stack trace:
```
Updating Homebrew...
Error: Calling brew cask install is disabled! Use brew install [--cask] instead.
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:177:in `run'
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:117:in `run'
/usr/local/Homebrew/Library/Homebrew/cmd/cask.rb:15:in `cask'
/usr/local/Homebrew/Library/Homebrew/brew.rb:124:in `<main>'
Error: Kernel.exit
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:188:in `exit'
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:188:in `rescue in run'
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:165:in `run'
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:117:in `run'
/usr/local/Homebrew/Library/Homebrew/cmd/cask.rb:15:in `cask'
/usr/local/Homebrew/Library/Homebrew/brew.rb:124:in `<main>'
```
<!--- Paste verbatim command output between quotes -->
```
$ ansible-playbook -i inventory docker.yml

PLAY [Homebrew Cask Demo] ******************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [Try to install the docker cask] ******************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Updating Homebrew...\nError: Calling brew cask install is disabled! Use brew install [--cask] instead.\n/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:177:in `run'\n/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:117:in `run'\n/usr/local/Homebrew/Library/Homebrew/cmd/cask.rb:15:in `cask'\n/usr/local/Homebrew/Library/Homebrew/brew.rb:124:in `<main>'\nError: Kernel.exit\n/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:188:in `exit'\n/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:188:in `rescue in run'\n/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:165:in `run'\n/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:117:in `run'\n/usr/local/Homebrew/Library/Homebrew/cmd/cask.rb:15:in `cask'\n/usr/local/Homebrew/Library/Homebrew/brew.rb:124:in `<main>'"}

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
```

##### ADDITIONAL COMMENTS
I can easily reproduce this error by running the following command:
```
$ brew cask --debug --appdir=/Applications install docker
Error: Calling brew cask install is disabled! Use brew install [--cask] instead.
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:177:in `run'
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:117:in `run'
/usr/local/Homebrew/Library/Homebrew/cmd/cask.rb:15:in `cask'
/usr/local/Homebrew/Library/Homebrew/brew.rb:124:in `<main>'
Error: Kernel.exit
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:188:in `exit'
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:188:in `rescue in run'
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:165:in `run'
/usr/local/Homebrew/Library/Homebrew/cask/cmd.rb:117:in `run'
/usr/local/Homebrew/Library/Homebrew/cmd/cask.rb:15:in `cask'
/usr/local/Homebrew/Library/Homebrew/brew.rb:124:in `<main>'
```
The solution seems to be to use a command-line like this instead:
```
$ brew install --cask --appdir=/Applications docker
Warning: Cask 'docker' is already installed.

To re-install docker, run:
  brew reinstall docker

$ echo $?
0
```

##### TRYING community.general.homebrew
I tried to do the same thing with the `homebrew` module, but the idempotency check fails because the `--cask` option is required for both `list` and `install` for casks:
```
$ cat docker2.yml
---
- name: Homebrew Cask Demo
  hosts: all
  tasks:
    - name: Try to install the docker cask
      community.general.homebrew:
        name: docker
        install_options: 'cask,appdir=/Applications'
        state: present

$ ansible-playbook -i inventory docker2.yml

PLAY [Homebrew Cask Demo] ******************************************************************

TASK [Gathering Facts] *********************************************************************
ok: [localhost]

TASK [Try to install the docker cask] ******************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Warning: Cask 'docker' is already installed.\n\nTo re-install docker, run:\n  brew reinstall docker"}

PLAY RECAP *********************************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
```
I've not studied the code in the `homebrew` module, but I imagine it checks for installation by doing something like this:
```
$ brew list docker
Error: No such keg: /usr/local/Cellar/docker
```
For casks, it needs to do this:
```
$ brew list --cask docker
==> App
/Applications/Docker.app (15,133 files, 1.3GB)
```
I can work around this idempotency issue by adding another task to check if docker is installed first, but that's messy. This check should _really_ be handled by the module.

```
$ cat docker3.yml
---
- name: Homebrew Cask Demo
  hosts: all
  tasks:
    - name: Check to see if docker is installed
      command: brew list --cask docker
      register: result
      ignore_errors: true
      changed_when: false

    - name: Try to install the docker cask
      community.general.homebrew:
        name: docker
        install_options: 'cask,appdir=/Applications'
        state: present
      when: result is failed

$ ansible-playbook -i inventory docker3.yml

PLAY [Homebrew Cask Demo] ******************************************************************

TASK [Gathering Facts] *********************************************************************
ok: [localhost]

TASK [Check to see if docker is installed] *************************************************
ok: [localhost]

TASK [Try to install the docker cask] ******************************************************
skipping: [localhost]

PLAY RECAP *********************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
```


Discussion:

Resolving patch:
diff --git a/plugins/modules/packaging/os/homebrew_cask.py b/plugins/modules/packaging/os/homebrew_cask.py
--- a/plugins/modules/packaging/os/homebrew_cask.py
+++ b/plugins/modules/packaging/os/homebrew_cask.py
@@ -139,6 +139,7 @@
 import os
 import re
 import tempfile
+from distutils import version
 
 from ansible.module_utils._text import to_bytes
 from ansible.module_utils.basic import AnsibleModule
@@ -356,6 +357,18 @@ def current_cask(self, cask):
         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,
@@ -434,15 +447,12 @@ def _current_cask_is_outdated(self):
         if not self.valid_cask(self.current_cask):
             return False
 
-        cask_is_outdated_command = (
-            [
-                self.brew_path,
-                'cask',
-                'outdated',
-            ]
-            + (['--greedy'] if self.greedy else [])
-            + [self.current_cask]
-        )
+        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)
 
@@ -454,18 +464,35 @@ def _current_cask_is_installed(self):
             self.message = 'Invalid cask: {0}.'.format(self.current_cask)
             raise HomebrewCaskException(self.message)
 
-        cmd = [
-            "{brew_path}".format(brew_path=self.brew_path),
-            "cask",
-            "list",
-            self.current_cask
-        ]
+        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)
 
         if rc == 0:
             return True
         else:
             return False
+
+    def _get_brew_version(self):
+        if self.brew_version:
+            return self.brew_version
+
+        cmd = [self.brew_path, '--version']
+
+        rc, out, err = self.module.run_command(cmd, check_rc=True)
+
+        # get version string from first line of "brew --version" output
+        version = out.split('\n')[0].split(' ')[1]
+        self.brew_version = version
+        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 version.LooseVersion(self._get_brew_version()) >= version.LooseVersion('2.6.0')
     # /checks ------------------------------------------------------ }}}
 
     # commands ----------------------------------------------------- {{{
@@ -537,11 +564,10 @@ def _upgrade_all(self):
             self.message = 'Casks would be upgraded.'
             raise HomebrewCaskException(self.message)
 
-        opts = (
-            [self.brew_path, 'cask', 'upgrade']
-        )
-
-        cmd = [opt for opt in opts if opt]
+        if self._brew_cask_command_is_deprecated():
+            cmd = [self.brew_path, 'upgrade', '--cask']
+        else:
+            cmd = [self.brew_path, 'cask', 'upgrade']
 
         rc, out, err = '', '', ''
 
@@ -586,10 +612,12 @@ def _install_current_cask(self):
             )
             raise HomebrewCaskException(self.message)
 
-        opts = (
-            [self.brew_path, 'cask', 'install', self.current_cask]
-            + self.install_options
-        )
+        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]
 
@@ -650,11 +678,13 @@ def _upgrade_current_cask(self):
             )
             raise HomebrewCaskException(self.message)
 
-        opts = (
-            [self.brew_path, 'cask', command]
-            + self.install_options
-            + [self.current_cask]
-        )
+        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 = '', '', ''
@@ -703,10 +733,12 @@ def _uninstall_current_cask(self):
             )
             raise HomebrewCaskException(self.message)
 
-        opts = (
-            [self.brew_path, 'cask', 'uninstall', self.current_cask]
-            + self.install_options
-        )
+        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]
 


Test patch: