Repository: ansible-collections/community.general

Issue:
MacPorts fails to install package whose name is contained within already installed package
##### SUMMARY
The `community.general.macports` module is failing to install a requested package, because that package's name occurs within another, already installed package.

It checks to see if a package is installed by listing all installed packages, and then grepping for `^.*%s`, considering the requested package installed if there is at least one match. Unfortunately, this *also* matches if the requested package is a substring of any of the output from the entire list of installed packages.

https://github.com/ansible-collections/community.general/blob/33b8d1c57e22bcfad247da9a435b9968712dbfb4/plugins/modules/packaging/os/macports.py#L149-L153


##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
plugins/modules/packaging/os/macports.py

##### ANSIBLE VERSION
<!--- Paste verbatim output from "ansible --version" between quotes -->
```paste below
ansible 2.10.2
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/Users/daniel/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/daniel/Library/Python/3.8/lib/python/site-packages/ansible
  executable location = /Users/daniel/bin/ansible
  python version = 3.8.2 (default, Oct  2 2020, 10:45:42) [Clang 12.0.0 (clang-1200.0.32.27)]
```

##### CONFIGURATION
<!--- Paste verbatim output from "ansible-config dump --only-changed" between quotes -->
```paste below
CACHE_PLUGIN(/Users/daniel/Developer/home-ansible/ansible.cfg) = jsonfile
CACHE_PLUGIN_CONNECTION(/Users/daniel/Developer/home-ansible/ansible.cfg) = /tmp/ansible-fact-cache
DEFAULT_HOST_LIST(/Users/daniel/Developer/home-ansible/ansible.cfg) = ['/Users/daniel/Developer/home-ansible/hosts']
DEFAULT_LOG_PATH(/Users/daniel/Developer/home-ansible/ansible.cfg) = /Users/daniel/Library/Logs/ansible.log
INJECT_FACTS_AS_VARS(/Users/daniel/Developer/home-ansible/ansible.cfg) = False
```

##### OS / ENVIRONMENT
macOS 11.0.1 build 20B29
macports v2.6.4


##### STEPS TO REPRODUCE

I had `git` installed via macports, and was trying to add `gh`. Ansible incorrectly thinks `gh` is already installed.

Here's a minimal playbook that I believe reproduces this. In my case, I'd already had `git` installed, using the default variants, from a previous run. It takes a long time to install (lots of dependencies), so I haven't uninstalled it to test.

A true reproduction would be to run one playbook that installs `git` successfully, and then try to run one that installs `gh`.

```yaml
- hosts: all
  tasks:
    - name: Install git and gh
      community.general.macports: name={{item}} state=present
      become: yes
      with_items:
        - git
        - gh
```

I expect there are potentially many other name collisions, but I haven't gone looking for them.

##### EXPECTED RESULTS

After playbook runs:
```
$ port installed git gh
The following ports are currently installed:
  gh @1.2.1_0 (active)
  git @2.29.2_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28 (active)
```


##### ACTUAL RESULTS

Checking what has already been installed on this machine:
```
❯ port installed git gh
The following ports are currently installed:
  git @2.29.2_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28 (active)
```

Notice that :point_up: `gh` is a substring of `diff_highlight`.

Running the playbook, only targeting my mac
```
❯ ansible-playbook -l liberty -K repro.yml -vv
ansible-playbook 2.10.2
  config file = /Users/daniel/Developer/home-ansible/ansible.cfg
  configured module search path = ['/Users/daniel/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/daniel/Library/Python/3.8/lib/python/site-packages/ansible
  executable location = /Users/daniel/Library/Python/3.8/bin/ansible-playbook
  python version = 3.8.2 (default, Oct  2 2020, 10:45:42) [Clang 12.0.0 (clang-1200.0.32.27)]
Using /Users/daniel/Developer/home-ansible/ansible.cfg as config file
BECOME password:

PLAYBOOK: repro.yml ******************************************************************************************************************************************************************************************************************************
1 plays in repro.yml

PLAY [all] ***************************************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] ***************************************************************************************************************************************************************************************************************************
task path: /Users/daniel/Developer/home-ansible/repro.yml:1
ok: [liberty]
META: ran handlers

TASK [Install git and gh] ************************************************************************************************************************************************************************************************************************
task path: /Users/daniel/Developer/home-ansible/repro.yml:3
ok: [liberty] => (item=git) => {"ansible_loop_var": "item", "changed": false, "item": "git", "msg": "Port(s) already present"}
ok: [liberty] => (item=gh) => {"ansible_loop_var": "item", "changed": false, "item": "gh", "msg": "Port(s) already present"}
META: ran handlers
META: ran handlers

PLAY RECAP ***************************************************************************************************************************************************************************************************************************************
liberty                    : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
```

"Port(s) already present" above is wrong. Double-checking that it hasn't been installed:
```
❯ port installed git gh
The following ports are currently installed:
  git @2.29.2_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28 (active)

# Using the `grep` pattern from macports.py to find out what's matching `gh`, and it's the (first) `gh` in `highlight`
❯ port installed | grep '^.*gh'
  git @2.29.2_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28 (active)
```

`git` just happens to be the port that conflicted for me. Any port/variant that contains `gh` would cause this conflict with the `gh` port. 







Discussion:

Resolving patch:
diff --git a/plugins/modules/packaging/os/macports.py b/plugins/modules/packaging/os/macports.py
--- a/plugins/modules/packaging/os/macports.py
+++ b/plugins/modules/packaging/os/macports.py
@@ -148,17 +148,18 @@ def query_port(module, port_path, name, state="present"):
 
     if state == "present":
 
-        rc, out, err = module.run_command("%s installed | grep -q ^.*%s" % (shlex_quote(port_path), shlex_quote(name)), use_unsafe_shell=True)
-        if rc == 0:
+        rc, out, err = module.run_command([port_path, "-q", "installed", name])
+
+        if rc == 0 and out.strip().startswith(name + " "):
             return True
 
         return False
 
     elif state == "active":
 
-        rc, out, err = module.run_command("%s installed %s | grep -q active" % (shlex_quote(port_path), shlex_quote(name)), use_unsafe_shell=True)
+        rc, out, err = module.run_command([port_path, "-q", "installed", name])
 
-        if rc == 0:
+        if rc == 0 and "(active)" in out:
             return True
 
         return False


Test patch:
diff --git a/tests/unit/plugins/modules/packaging/os/test_macports.py b/tests/unit/plugins/modules/packaging/os/test_macports.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/modules/packaging/os/test_macports.py
@@ -0,0 +1,34 @@
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+from ansible.module_utils import basic
+from ansible_collections.community.general.plugins.modules.packaging.os import macports
+
+import pytest
+
+TESTED_MODULE = macports.__name__
+
+QUERY_PORT_TEST_CASES = [
+    pytest.param('', False, False, id='Not installed'),
+    pytest.param('  git @2.29.2_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28', True, False, id='Installed but not active'),
+    pytest.param('  git @2.29.2_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28 (active)', True, True, id='Installed and active'),
+    pytest.param('''  git @2.29.2_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28
+  git @2.28.1_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28
+''', True, False, id='2 versions installed, neither active'),
+    pytest.param('''  git @2.29.2_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28 (active)
+  git @2.28.1_0+credential_osxkeychain+diff_highlight+doc+pcre+perl5_28
+''', True, True, id='2 versions installed, one active'),
+]
+
+
+@pytest.mark.parametrize("run_cmd_return_val, present_expected, active_expected", QUERY_PORT_TEST_CASES)
+def test_macports_query_port(mocker, run_cmd_return_val, present_expected, active_expected):
+    module = mocker.Mock()
+    run_command = mocker.Mock()
+    run_command.return_value = (0, run_cmd_return_val, '')
+    module.run_command = run_command
+
+    assert macports.query_port(module, 'port', 'git', state="present") == present_expected
+    assert macports.query_port(module, 'port', 'git', state="active") == active_expected
diff --git a/tests/unit/plugins/modules/packaging/os/test_redhat_subscription.py b/tests/unit/plugins/modules/packaging/os/test_redhat_subscription.py
--- a/tests/unit/plugins/modules/packaging/os/test_redhat_subscription.py
+++ b/tests/unit/plugins/modules/packaging/os/test_redhat_subscription.py
@@ -18,7 +18,7 @@
 @pytest.fixture
 def patch_redhat_subscription(mocker):
     """
-    Function used for mocking some parts of redhat_subscribtion module
+    Function used for mocking some parts of redhat_subscription module
     """
     mocker.patch('ansible_collections.community.general.plugins.modules.packaging.os.redhat_subscription.RegistrationBase.REDHAT_REPO')
     mocker.patch('ansible_collections.community.general.plugins.modules.packaging.os.redhat_subscription.isfile', return_value=False)
@@ -817,7 +817,7 @@ def test_without_required_parameters(capfd, patch_redhat_subscription):
 
 @pytest.mark.parametrize('patch_ansible_module, testcase', TEST_CASES, ids=TEST_CASES_IDS, indirect=['patch_ansible_module'])
 @pytest.mark.usefixtures('patch_ansible_module')
-def test_redhat_subscribtion(mocker, capfd, patch_redhat_subscription, testcase):
+def test_redhat_subscription(mocker, capfd, patch_redhat_subscription, testcase):
     """
     Run unit tests for test cases listen in TEST_CASES
     """
@@ -1173,7 +1173,7 @@ def test_redhat_subscribtion(mocker, capfd, patch_redhat_subscription, testcase)
 
 @pytest.mark.parametrize('patch_ansible_module, testcase', SYSPURPOSE_TEST_CASES, ids=SYSPURPOSE_TEST_CASES_IDS, indirect=['patch_ansible_module'])
 @pytest.mark.usefixtures('patch_ansible_module')
-def test_redhat_subscribtion_syspurpose(mocker, capfd, patch_redhat_subscription, patch_ansible_module, testcase, tmpdir):
+def test_redhat_subscription_syspurpose(mocker, capfd, patch_redhat_subscription, patch_ansible_module, testcase, tmpdir):
     """
     Run unit tests for test cases listen in SYSPURPOSE_TEST_CASES (syspurpose specific cases)
     """