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:
Files identified in the description:
None

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
Note: I've commented [here](https://github.com/Homebrew/discussions/discussions/340) about this issue. It's not helpful that the Homebrew folks have decided against maintaining a stable command-line interface. It would be worth checking for other Homebrew subcommands that may be in deprecated state since those changes will create similar problems.
There's probably more to do, but these quick local edits (patch file below) seem to have things working as expected for me.
```
--- homebrew_cask.py	2020-12-21 18:04:53.000000000 -0800
+++ homebrew_cask_edit.py	2020-12-21 18:05:07.000000000 -0800
@@ -437,8 +437,8 @@
         cask_is_outdated_command = (
             [
                 self.brew_path,
-                'cask',
                 'outdated',
+                '--cask',
             ]
             + (['--greedy'] if self.greedy else [])
             + [self.current_cask]
@@ -456,8 +456,8 @@
 
         cmd = [
             "{brew_path}".format(brew_path=self.brew_path),
-            "cask",
             "list",
+            "--cask",
             self.current_cask
         ]
         rc, out, err = self.module.run_command(cmd)
@@ -538,7 +538,7 @@
             raise HomebrewCaskException(self.message)
 
         opts = (
-            [self.brew_path, 'cask', 'upgrade']
+            [self.brew_path, 'upgrade', '--cask']
         )
 
         cmd = [opt for opt in opts if opt]
@@ -587,7 +587,7 @@
             raise HomebrewCaskException(self.message)
 
         opts = (
-            [self.brew_path, 'cask', 'install', self.current_cask]
+            [self.brew_path, 'install', '--cask', self.current_cask]
             + self.install_options
         )
 
@@ -651,7 +651,7 @@
             raise HomebrewCaskException(self.message)
 
         opts = (
-            [self.brew_path, 'cask', command]
+            [self.brew_path, 'command', '--cask']
             + self.install_options
             + [self.current_cask]
         )
@@ -704,7 +704,7 @@
             raise HomebrewCaskException(self.message)
 
         opts = (
-            [self.brew_path, 'cask', 'uninstall', self.current_cask]
+            [self.brew_path, 'uninstall', '--cask', self.current_cask]
             + self.install_options
         )
 ```
tl;dr: The recent homebrew 2.7 release broke the `homebrew_cask` module. 
Was able to get it working again by downgrading and locking Homebrew to `2.6.2`

1. Downgrade to `2.6.2`
```
cd /usr/local/Homebrew
git fetch --tags
git checkout 2.6.2
```

2. Prevent automatic brew upgrades whenever you install/upgrade something. In your `.zshrc` or `.bashrc`,
```
export HOMEBREW_NO_AUTO_UPDATE=1
```
There's already a PR for this: #1481

Since you are Homebrew users, would be great if you could test it!
(Also, I'm quite confused why ~~everyone~~ some people ~~is~~ are saying that `brew cask` is deprecated. It is apparently not deprecated (otherwise it would still work), but has been removed. [Deprecation](https://en.wikipedia.org/wiki/Deprecation#Software) means there will be a warning that it will be removed in the future.)
Files identified in the description:
* [`plugins/modules/packaging/os/homebrew_cask.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/packaging/os/homebrew_cask.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @Akasurde @danieljaouen @enriclluelles @indrajitr @kyleabenson @martinm82
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
> (Also, I'm quite confused why ~everyone~ some people ~is~ are saying that `brew cask` is deprecated. It is apparently not deprecated (otherwise it would still work), but has been removed. [Deprecation](https://en.wikipedia.org/wiki/Deprecation#Software) means there will be a warning that it will be removed in the future.)

Well, from looking at the commit history, the `brew cask` subcommand has been deprecated for a while. As of 2.7 of Homebrew, `brew cask` was deleted. 
Response from the Homebrew team:

https://github.com/Homebrew/discussions/discussions/340#discussioncomment-232364
> > (Also, I'm quite confused why ~everyone~ some people ~is~ are saying that `brew cask` is deprecated. It is apparently not deprecated (otherwise it would still work), but has been removed. [Deprecation](https://en.wikipedia.org/wiki/Deprecation#Software) means there will be a warning that it will be removed in the future.)
> 
> Well, from looking at the commit history, the `brew cask` subcommand has been deprecated for a while. As of 2.7 of Homebrew, `brew cask` was deleted.

Ok, so it was indeed just deprecated a few days ago (December 1st), and got removed only since yesterday. So talking about it as deprecated was actually correct until the day before yesterday :)
Hi there, Homebrew maintainer here.

I can clear up a few things:

- `brew cask` commands were deprecated on 2020-12-01 with the release of Homebrew 2.6.0. Starting then, all `brew cask` commands succeeded but displayed a warning informing users that the command would soon be disabled. The message also provides the appropriate replacement.
- `brew cask` commands were disabled on 2020-12-21 with the release of Homebrew 2.7.0. Starting then, all `brew cask` commands failed and displayed a warning informing users that the command is disabled. The message also provides the appropriate replacement.
- With the release of Homebrew 2.8.0 (release date TBD), this disable message will be removed.

The alternative to `brew cask <command>` is to use `brew <command>`. In many cases, you can add the `--cask` flag to specify casks only. For example, `brew cask install atom` is now `brew install atom` or `brew install --cask atom`. There are some casks that share a name with formulae (e.g. `wireshark`) so adding `--cask` ensures that the cask is installed not the formula.

I'm not much of an ansible user but I'm happy to try and help out if needed. Feel free to point me in the right direction if anything is blocked and could use feedback from the Homebrew side of things.
@Rylan12 thanks! We already have a PR which should fix that (#1481), it needs a little more work but should already work. It detects the homebrew version so it will work with older versions of homebrew as well.
> Hi there, Homebrew maintainer here.
> 
> I can clear up a few things:
> 
> * `brew cask` commands were deprecated on 2020-12-01 with the release of Homebrew 2.6.0. Starting then, all `brew cask` commands succeeded but displayed a warning informing users that the command would soon be disabled. The message also provides the appropriate replacement.
> * `brew cask` commands were disabled on 2020-12-21 with the release of Homebrew 2.7.0. Starting then, all `brew cask` commands failed and displayed a warning informing users that the command is disabled. The message also provides the appropriate replacement.
> * With the release of Homebrew 2.8.0 (release date TBD), this disable message will be removed.
> 
> The alternative to `brew cask <command>` is to use `brew <command>`. In many cases, you can add the `--cask` flag to specify casks only. For example, `brew cask install atom` is now `brew install atom` or `brew install --cask atom`. There are some casks that share a name with formulae (e.g. `wireshark`) so adding `--cask` ensures that the cask is installed not the formula.
> 
> I'm not much of an ansible user but I'm happy to try and help out if needed. Feel free to point me in the right direction if anything is blocked and could use feedback from the Homebrew side of things.

Thank you @Rylan12!
For everyone else, this is the solution. 
Very helpful @raymondyou97  Thanks.
Would be great if someone could test the patch in #1481 so we can get it merged. Thanks.
@felixfontein : I have upgraded to the code in the PR with the following command, reran my playbook:

```sh
ansible-galaxy collection install --force git+https://github.com/jaanhio/community.general.git,update-deprecated-homebrew-cask-commands
```

Here was the output:

```sh
TASK [misc-packages : Best effort homebrew updates] *********************************
[WARNING]: Error deleting remote temporary files (rc: -9, stderr: })
fatal: [bigbookpro]: FAILED! => {"changed": false, "msg": "Error: The `brew link` step did not complete successfully\nError: The `brew link` step did not complete successfully\nWarning: Reverting upgrade for Cask fly\nWarning: No checksum defined for cask 'google-drive-file-stream', skipping verification.\nError: fly: It seems there is already a Binary at '/usr/local/bin/fly'."}
...ignoring

TASK [misc-packages : Make sure homebrew is in stable shape] ************************
ERROR! A worker was found in a dead state
broz@bigbookpro:~$ 
```

I didn't think to try to reproduce immediately and instead focused on removing the logged issue:

```sh
broz@bigbookpro:~/ansible/roles/misc-packages/tasks$ brew uninstall --force homebrew/cask/fly
==> Uninstalling Cask fly
==> Purging files for version 6.7.2 of Cask fly
broz@bigbookpro:~/ansible/roles/misc-packages/tasks$
```

Afterwards, things worked fine: 


```sh
TASK [misc-packages : Best effort homebrew updates] *********************************
changed: [bigbookpro]

TASK [misc-packages : Make sure homebrew is in stable shape] ************************
ok: [bigbookpro]

TASK [misc-packages : Brew bundle] **************************************************
changed: [bigbookpro]
```

Here's the relevant bits of the role:

```ansible
---
- name: "Best effort homebrew updates"
  homebrew:
    update_homebrew: yes
    upgrade_all: yes
  ignore_errors: yes

- name: "Make sure homebrew is in stable shape"
  homebrew:
    update_homebrew: yes
    upgrade_all: yes

- name: Brew bundle
  command: brew bundle
  args:
    chdir: "{{ ansible_env.HOME }}"
```
(for what it's worth, as a user, even if the error handling has issues, having a version of this that works even a little bit is better than something that doesn't work at all)
@apiology from the code you posted, you only tested the `homebrew` module, which is not affected by the PR. Could you please test the `homebrew_cask` module?
Ah, right you are.  These also ran in the same playbook with no errors:

```ansible
---
- name: "Install 1password-cli"
  community.general.homebrew_cask:
    name: "1password-cli"
    state: upgraded
    greedy: True
```

```ansible
- name: Install emacs
  homebrew_cask:
    name: emacs
  become: "{{ package_become }}"
```

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: