Repository: ansible-collections/community.general

Issue:
snap module does not provide a helpful message when snap is down
### Summary

Today (9/july/2021) we had a major outage on snap, which was good to test.

When a package is installed with snap module and it is down, we get the same message as an inexistent package:
Eg.
No snap matching 'pycharm-professional' available.

The way snap module was made it first runs `snap info`, which in turn returns this same message.  But if you try directly to install with snap (`sudo snap install pycharm-professional --classic`): you would get an error such as: `error: unable to contact snap store`.

During my tests I also got another output from install command:
```
$ snap install --classic pycharm-professional
error: cannot install "pycharm-professional": Post
       "https://api.snapcraft.io/v2/snaps/refresh": dial tcp: lookup
       api.snapcraft.io on [fd77:38ec:7316::1]:53: server misbehaving
$ echo $?
1
```

On the other hand, `snap info pycharm-professional` will always return: error: no snap found for "pycharm-professional"

### Issue Type

Bug Report

### Component Name

snap

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.11.2] 
  config file = None
  configured module search path = ['/home/user/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/user/.local/lib/python3.9/site-packages/ansible
  ansible collection location = /home/user/.ansible/collections:/usr/share/ansible/collections
  executable location = /home/user/.local/bin/ansible
  python version = 3.9.2 (default, Feb 28 2021, 17:03:44) [GCC 10.2.1 20210110]
  jinja version = 3.0.1
  libyaml = True
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed

```


### OS / Environment

Debian 11 (Bullseye)

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml
- name: Install pycharm
  community.general.snap:
    name: "pycharm-professional"
    classic: true
  become: true
```


### Expected Results

It is installed. When snap is down, you should get a message to try later or even retry.

### Actual Results

```console
TASK [staticdev.python_developer : Install pycharm] ****************************
An exception occurred during task execution. To see the full traceback, use -vvv. The error was: ansible_collections.community.general.plugins.module_utils.mh.exceptions.ModuleHelperException
fatal: [127.0.0.1]: FAILED! => {"changed": false, "cmd_args": ["/usr/bin/snap", "info", "pycharm-professional"], "force_lang": "C", "msg": "No snap matching 'pycharm-professional' available.", "output": {"cmd_args": ["/usr/bin/snap", "info", "pycharm-professional"], "force_lang": "C", "rc": 1, "stderr": "error: no snap found for \"pycharm-professional\"\n", "stdout": ""}, "rc": 1, "stderr": "error: no snap found for \"pycharm-professional\"\n", "stderr_lines": ["error: no snap found for \"pycharm-professional\""], "stdout": "", "stdout_lines": [], "vars": {"cmd_args": ["/usr/bin/snap", "info", "pycharm-professional"], "force_lang": "C", "rc": 1, "stderr": "error: no snap found for \"pycharm-professional\"\n", "stdout": ""}}
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/packaging/os/snap.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/packaging/os/snap.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 @angristan @vcarceler
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
I'm not sure there is much we can do, since snap itself does not provide a helpful error message in this case. The module returns stdout/stderr from snap, and as you can see it simply says `error: no snap found for "pycharm-professional"`. I don't see how the module should distinguish between "snap is down" and "the snap name you provided does not exist".
@felixfontein I may be losing context, but why `snap info` is needed before `snap install`?
It does that to provide a helpful error message that the snap does not exist.
But if you run install you also get a message:

```
snap install asdbnfaiosdfnasidfna
error: snap "asdbnfaiosdfnasidfna" not found
```
I don't know why someone made the decision to use `snap info` ages ago. Maybe it was necessary back then? At least the behavior was to use `snap info` for quite some time (in fact since it was added: https://github.com/ansible/ansible/pull/44939).
I really don't think this call is necessary though.. `snap info` is a command just to get metadata about a snap (https://snapcraft.io/docs/getting-started). If you are using the ansible module to install, you need `snap install` only. Same goes to `snap remove`.

I've seen the code on `plugins/modules/packaging/os/snap.py` and I would say this `snap info` is doing more harm than solving anything. There is a validation method `validate_input_snaps` called in all states first line just to throw an exception, and I think it is safe to just remove it.
@staticdev I also think it is an unnecessary check, but as I was writing the revamp'd version of the module I did not want to add more changes to it. I have very little familiarity with snap, so this is a call I am not comfortable in doing. It would be nice to have more users of snap itself and/or the c.g.snap module to pitch in.
> I've seen the code on `plugins/modules/packaging/os/snap.py` and I would say this `snap info` is doing more harm than solving anything. There is a validation method `validate_input_snaps` called in all states first line just to throw an exception, and I think it is safe to just remove it.

I don't think that just removing it is a good idea. If it is removed, there needs to be (at least) better error handling for a failed install that detects when the install failed because the snap does not exist. Potentially some more error handling is needed in other situations as well.
For me, it makes sense to improve error handling AND remove the call. This unnecessary calls to _snap info_ can make errors even more confusing.
> > I've seen the code on `plugins/modules/packaging/os/snap.py` and I would say this `snap info` is doing more harm than solving anything. There is a validation method `validate_input_snaps` called in all states first line just to throw an exception, and I think it is safe to just remove it.
> 
> I don't think that just removing it is a good idea. If it is removed, there needs to be (at least) better error handling for a failed install that detects when the install failed because the snap does not exist. Potentially some more error handling is needed in other situations as well.

@felixfontein the output from the `snap` command is clear enough, IMO.

For the case when then snap does not exists, `rc=1` and:
```
$ snap install this-is-not-a-snap
error: snap "this-is-not-a-snap" not found
```

For the case when the snap server is offline, `rc=1` and:
```
$ snap install hello-world
error: cannot install "hello-world": Post "https://api.snapcraft.io/v2/snaps/refresh": dial tcp:
       lookup api.snapcraft.io: no such host
```
As a matter of fact, if we use `check_rc=True` the user should get a clear picture of what happened.
@russoz the output of `snap` is clear. I just opened the issue because we don't see it on `community.general.snap` by default. And the current output is confusing, since it is always `No snap matching 'name-of-snap' available.` for very difference scenarios.
Exactly my point: improving the error handling is achieved by simply passing the messages from the command up to the user.

Resolving patch:
diff --git a/plugins/modules/packaging/os/snap.py b/plugins/modules/packaging/os/snap.py
--- a/plugins/modules/packaging/os/snap.py
+++ b/plugins/modules/packaging/os/snap.py
@@ -1,6 +1,7 @@
 #!/usr/bin/python
 # -*- coding: utf-8 -*-
 
+# Copyright: (c) 2021, Alexei Znamensky (russoz) <russoz@gmail.com>
 # Copyright: (c) 2018, Stanislas Lange (angristan) <angristan@pm.me>
 # Copyright: (c) 2018, Victor Carceler <vcarceler@iespuigcastellar.xeill.net>
 
@@ -12,17 +13,13 @@
 DOCUMENTATION = '''
 ---
 module: snap
-
 short_description: Manages snaps
-
-
 description:
     - "Manages snaps packages."
-
 options:
     name:
         description:
-            - Name of the snap to install or remove. Can be a list of snaps.
+            - Name of the snaps.
         required: true
         type: list
         elements: str
@@ -117,10 +114,10 @@
 __state_map = dict(
     present='install',
     absent='remove',
-    info='info',  # not public
-    list='list',  # not public
     enabled='enable',
     disabled='disable',
+    info='info',  # not public
+    list='list',  # not public
 )
 
 
@@ -171,9 +168,6 @@ def _run_multiple_commands(self, commands):
             '\n'.join(results[3]),
         ]
 
-    def snap_exists(self, snap_name):
-        return 0 == self.run_command(params=[{'state': 'info'}, {'name': snap_name}])[0]
-
     def is_snap_installed(self, snap_name):
         return 0 == self.run_command(params=[{'state': 'list'}, {'name': snap_name}])[0]
 
@@ -188,14 +182,7 @@ def is_snap_enabled(self, snap_name):
         notes = match.group('notes')
         return "disabled" not in notes.split(',')
 
-    def validate_input_snaps(self):
-        """Ensure that all exist."""
-        for snap_name in self.vars.name:
-            if not self.snap_exists(snap_name):
-                raise ModuleHelperException(msg="No snap matching '%s' available." % snap_name)
-
     def state_present(self):
-        self.validate_input_snaps()  # if snap doesnt exist, it will explode when trying to install
         self.vars.meta('classic').set(output=True)
         self.vars.meta('channel').set(output=True)
         actionable_snaps = [s for s in self.vars.name if not self.is_snap_installed(s)]
@@ -227,59 +214,32 @@ def state_present(self):
                   "error output for more details.".format(cmd=self.vars.cmd)
         raise ModuleHelperException(msg=msg)
 
-    def state_absent(self):
-        self.validate_input_snaps()  # if snap doesnt exist, it will be absent by definition
-        actionable_snaps = [s for s in self.vars.name if self.is_snap_installed(s)]
+    def _generic_state_action(self, actionable_func, actionable_var, params=None):
+        actionable_snaps = [s for s in self.vars.name if actionable_func(s)]
         if not actionable_snaps:
             return
         self.changed = True
-        self.vars.snaps_removed = actionable_snaps
+        self.vars[actionable_var] = actionable_snaps
         if self.module.check_mode:
             return
-        params = ['classic', 'channel', 'state']  # get base cmd parts
+        if params is None:
+            params = ['state']
         commands = [params + [{'actionable_snaps': actionable_snaps}]]
         self.vars.cmd, rc, out, err = self._run_multiple_commands(commands)
         if rc == 0:
             return
-        msg = "Ooops! Snap removal failed while executing '{cmd}', please examine logs and " \
+        msg = "Ooops! Snap operation failed while executing '{cmd}', please examine logs and " \
               "error output for more details.".format(cmd=self.vars.cmd)
         raise ModuleHelperException(msg=msg)
 
+    def state_absent(self):
+        self._generic_state_action(self.is_snap_installed, "snaps_removed", ['classic', 'channel', 'state'])
+
     def state_enabled(self):
-        self.validate_input_snaps()
-        actionable_snaps = [s for s in self.vars.name if self.is_snap_enabled(s) is False]
-        if not actionable_snaps:
-            return
-        self.changed = True
-        self.vars.snaps_enabled = actionable_snaps
-        if self.module.check_mode:
-            return
-        params = ['classic', 'channel', 'state']  # get base cmd parts
-        commands = [params + [{'actionable_snaps': actionable_snaps}]]
-        self.vars.cmd, rc, out, err = self._run_multiple_commands(commands)
-        if rc == 0:
-            return
-        msg = "Ooops! Snap enabling failed while executing '{cmd}', please examine logs and " \
-              "error output for more details.".format(cmd=self.vars.cmd)
-        raise ModuleHelperException(msg=msg)
+        self._generic_state_action(lambda s: not self.is_snap_enabled(s), "snaps_enabled", ['classic', 'channel', 'state'])
 
     def state_disabled(self):
-        self.validate_input_snaps()
-        actionable_snaps = [s for s in self.vars.name if self.is_snap_enabled(s) is True]
-        if not actionable_snaps:
-            return
-        self.changed = True
-        self.vars.snaps_enabled = actionable_snaps
-        if self.module.check_mode:
-            return
-        params = ['classic', 'channel', 'state']  # get base cmd parts
-        commands = [params + [{'actionable_snaps': actionable_snaps}]]
-        self.vars.cmd, rc, out, err = self._run_multiple_commands(commands)
-        if rc == 0:
-            return
-        msg = "Ooops! Snap disabling failed while executing '{cmd}', please examine logs and " \
-              "error output for more details.".format(cmd=self.vars.cmd)
-        raise ModuleHelperException(msg=msg)
+        self._generic_state_action(self.is_snap_enabled, "snaps_disabled", ['classic', 'channel', 'state'])
 
 
 def main():


Test patch: