Repository: ansible-collections/community.general

Issue:
[zypper_repository] `state: absent` does not work properly
### Summary

When passing `state: absent` to the `community.general_zypper_repository` module, Ansible reports a change but does not actually remove the `.repo` file in `/etc/zypp/repos.d`. This results in a task that does not behave as it should and a non-idempotent playbook (it still reports a change when you run the same playbook a second time).

### Issue Type

Bug Report

### Component Name

zypper_repository

### Ansible Version

```console (paste below)
$ ansible --version
 ansible [core 2.11.5] 
  config file = None
  configured module search path = ['/home/runner/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /opt/hostedtoolcache/Python/3.9.7/x64/lib/python3.9/site-packages/ansible
  ansible collection location = /home/runner/.ansible/collections:/usr/share/ansible/collections
  executable location = /opt/hostedtoolcache/Python/3.9.7/x64/bin/ansible
  python version = 3.9.7 (default, Sep  6 2021, 15:30:15) [GCC 9.3.0]
  jinja version = 3.0.1
  libyaml = True
```

### Community.general Version

```console (paste below)
$ ansible-galaxy collection list community.general
# /home/runner/.ansible/collections/ansible_collections
Collection        Version
----------------- -------
community.general 3.7.0  
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
 ANSIBLE_FORCE_COLOR(env: ANSIBLE_FORCE_COLOR) = True
```


### OS / Environment

Control Node: Github Actions Runner using Molecule
Targets: OpenSUSE Leap 15.2, OpenSUSE 15.3, and OpenSUSE Tumbleweed running in Podman containers.

Notably, this issue occurs on OpenSUSE Leap but not Tumbleweed. Also, Molecule bundles its own `ansible.cfg` file which makes more changes besides `ANSIBLE_FORCE_COLOR` that's shown above; Here is a copy of that file:

``` ini
# Molecule managed

[defaults]
ansible_managed = Ansible managed: Do NOT edit this file manually!
display_failed_stderr = True
forks = 50
retry_files_enabled = False
host_key_checking = False
nocows = 1
interpreter_python = auto_silent
stdout_callback = yaml
[ssh_connection]
scp_if_ssh = True
control_path = %(directory)s/%%h-%%p-%%r
```

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
I uncovered this bug by testing an Ansible role I wrote to install the Packman repository on SUSE. The actual code is located at [https://github.com/gotmax23/ansible-role-packman/tree/issue1](https://github.com/gotmax23/ansible-role-packman/tree/issue1), but here is a simplified playbook that demonstrates the bug.
```yaml (paste below)
---
- name: Install Packman Repository
  become: true

  vars:
    packman_state: present  # Run a second time with `absent` to reproduce the bug
    _packman_repo_url:
      "openSUSE Leap": "https://ftp.gwdg.de/pub/linux/misc/packman/suse/openSUSE_Leap_$releasever/"
      "openSUSE Tumbleweed": "https://ftp.gwdg.de/pub/linux/misc/packman/suse/openSUSE_Tumbleweed/"
    packman_repo_url: "{{ _packman_repo_url[ansible_distribution] }}"

    _packman_gpg_key_url:
      "openSUSE Tumbleweed": "https://ftp.gwdg.de/pub/linux/misc/packman/suse/openSUSE_Tumbleweed/repodata/repomd.xml.key"
      "openSUSE Leap": "https://ftp.gwdg.de/pub/linux/misc/packman/suse/openSUSE_Leap_{{ ansible_distribution_version }}/repodata/repomd.xml.key"
    packman_gpg_key_url: "{{ _packman_gpg_key_url[ansible_distribution] }}"

    packman_gpg_key_fingerprint: "F887 5B88 0D51 8B6B 8C53  0D13 45A1 D067 1ABD 1AFB"

  tasks:
    - name: Install/Remove Packamn GPG key
      ansible.builtin.rpm_key:
        key: "{{ packman_gpg_key_url }}"
        state: "{{ packman_state }}"

    - name: Install/Remove Packman Repository
      notify: zypper_dist_upgrade
      community.general.zypper_repository:
        name: packman
        refresh: true
        priority: 90
        repo: "{{ packman_repo_url }}"
        state: "{{ packman_state }}"

  handlers:
    - name: zypper_dist_upgrade
      become: true
      when: packman_state == "present"
      community.general.zypper:
        name: "*"
        state: dist-upgrade
        allow_vendor_change: true
        extra_args: "--from packman"

```
I implemented a temporary fix in [this commit](https://github.com/gotmax23/ansible-role-packman/commit/d4134a75e1a93b515ebd65aa4fc510b9878f92d5) by adding an extra step to manually delete the `/etc/zypp/repos.d/packman.repo` file using the `file` module.

### Expected Results

For the module to actually remove the repository

### Actual Results

This includes only the relevant part (removing the repository) of the output. I only used  `-v` because `-vvvv` produces extra clutter that is not relevant to this issue. You can also take a look at [this Github Actions job](https://github.com/gotmax23/ansible-role-packman/runs/3736989967?check_suite_focus=true) which includes the full Molecule run, including the failed idempotency and verify tests.

```console (paste below)
  Using /home/runner/.cache/molecule/ansible-role-packman/single/ansible.cfg as config file
  
  PLAY [Converge] ****************************************************************
  
  TASK [Gathering Facts] *********************************************************
  ok: [packman-single-opensuse-15.3]
  
[...]
  
  TASK [gotmax23.packman : Remove Packamn GPG key] *******************************
  changed: [packman-single-opensuse-15.3] => changed=true
  
  TASK [gotmax23.packman : Remove Packman Repository] ****************************
  changed: [packman-single-opensuse-15.3] => changed=true 
    repodata:
      alias: packman
      autorefresh: '1'
      enabled: '1'
      gpgcheck: '1'
      name: null
      priority: 90
      url: https://ftp.gwdg.de/pub/linux/misc/packman/suse/openSUSE_Leap_$releasever/
    state: absent
  
  RUNNING HANDLER [gotmax23.packman : zypper_dist_upgrade] ***********************
  skipping: [packman-single-opensuse-15.3] => changed=false 
    skip_reason: Conditional result was False
  
  PLAY RECAP *********************************************************************
  packman-single-opensuse-15.3 : ok=8    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* `lib/ansible/cli/__init__.py`
* `lib/ansible/galaxy`
* [`plugins/modules/clustering/consul/consul.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/clustering/consul/consul.py)
* [`plugins/modules/packaging/language/ansible_galaxy_install.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/packaging/language/ansible_galaxy_install.py)
* [`plugins/modules/packaging/os/zypper_repository.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/packaging/os/zypper_repository.py)

If these files are incorrect, 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/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
@gotmax23: Greetings! Thanks for taking the time to open this issue. In order for the community to handle your issue effectively, we need a bit more information.

Here are the items we could not find in your description:
- ansible version

Please set the description of this issue with an appropriate template from:
https://github.com/ansible/ansible/tree/devel/.github/ISSUE_TEMPLATE

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: issue_missing_data --->
cc @AnderEnder @alxgu @andytom @commel @dcermak @evrardjp @lrupp @russoz @sealor @sgargan @toabctl
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
[Here](https://gist.github.com/gotmax23/47741a17004d5fc088329eb46feb6572) is a link to a Gist with the same output as above but with `-vvvv`. I ran this as a new Github Actions job; [here](https://github.com/gotmax23/ansible-role-packman/runs/3737241653?check_suite_focus=true) is the full job output for this run.
> @gotmax23: Greetings! Thanks for taking the time to open this issue. In order for the community to handle your issue effectively, we need a bit more information.
> 
> Here are the items we could not find in your description:
> 
>     * ansible version
> 
> 
> Please set the description of this issue with an appropriate template from: https://github.com/ansible/ansible/tree/devel/.github/ISSUE_TEMPLATE
> 
> [click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)

I did include it. Is the bot broken or did I just mess something up?
!component =plugins/modules/packaging/os/zypper_repository.py
Files identified in the description:
* [`plugins/modules/packaging/os/zypper_repository.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/packaging/os/zypper_repository.py)

If these files are incorrect, 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/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
> > Here are the items we could not find in your description:
> > ```
> > * ansible version
> > ```
> 
> I did include it. Is the bot broken or did I just mess something up?

Sorry, that's completely my fault. I yesterday edited the form so it now correctly asks for the ansible-core version, which (since 2.9) is not related to the Ansible version. It looks like the bot insists that the ansible-core version is called "Ansible version" though...
It looks like ansibullbot also incorrectly added the `has_pr` label.
That label is added every time an issue is mentioned in a PR. Which is a good heuristic, but fails in cases such as this one :)

Resolving patch:
diff --git a/plugins/modules/packaging/os/zypper_repository.py b/plugins/modules/packaging/os/zypper_repository.py
--- a/plugins/modules/packaging/os/zypper_repository.py
+++ b/plugins/modules/packaging/os/zypper_repository.py
@@ -137,6 +137,10 @@
 
 from ansible.module_utils.basic import AnsibleModule, missing_required_lib
 
+from ansible.module_utils.urls import fetch_url
+from ansible.module_utils.common.text.converters import to_text
+from ansible.module_utils.six.moves import configparser, StringIO
+from io import open
 
 REPO_OPTS = ['alias', 'name', 'priority', 'enabled', 'autorefresh', 'gpgcheck']
 
@@ -382,12 +386,62 @@ def exit_unchanged():
         if not alias and state == "present":
             module.fail_json(msg='Name required when adding non-repo files.')
 
+    # Download / Open and parse .repo file to ensure idempotency
+    if repo and repo.endswith('.repo'):
+        if repo.startswith(('http://', 'https://')):
+            response, info = fetch_url(module=module, url=repo, force=True)
+            if not response or info['status'] != 200:
+                module.fail_json(msg='Error downloading .repo file from provided URL')
+            repofile_text = to_text(response.read(), errors='surrogate_or_strict')
+        else:
+            try:
+                with open(repo, encoding='utf-8') as file:
+                    repofile_text = file.read()
+            except IOError:
+                module.fail_json(msg='Error opening .repo file from provided path')
+
+        repofile = configparser.ConfigParser()
+        try:
+            repofile.readfp(StringIO(repofile_text))
+        except configparser.Error:
+            module.fail_json(msg='Invalid format, .repo file could not be parsed')
+
+        # No support for .repo file with zero or more than one repository
+        if len(repofile.sections()) != 1:
+            err = "Invalid format, .repo file contains %s repositories, expected 1" % len(repofile.sections())
+            module.fail_json(msg=err)
+
+        section = repofile.sections()[0]
+        repofile_items = dict(repofile.items(section))
+        # Only proceed if at least baseurl is available
+        if 'baseurl' not in repofile_items:
+            module.fail_json(msg='No baseurl found in .repo file')
+
+        # Set alias (name) and url based on values from .repo file
+        alias = section
+        repodata['alias'] = section
+        repodata['url'] = repofile_items['baseurl']
+
+        # If gpgkey is part of the .repo file, auto import key
+        if 'gpgkey' in repofile_items:
+            auto_import_keys = True
+
+        # Map additional values, if available
+        if 'name' in repofile_items:
+            repodata['name'] = repofile_items['name']
+        if 'enabled' in repofile_items:
+            repodata['enabled'] = repofile_items['enabled']
+        if 'autorefresh' in repofile_items:
+            repodata['autorefresh'] = repofile_items['autorefresh']
+        if 'gpgcheck' in repofile_items:
+            repodata['gpgcheck'] = repofile_items['gpgcheck']
+
     exists, mod, old_repos = repo_exists(module, repodata, overwrite_multiple)
 
-    if repo:
-        shortname = repo
-    else:
+    if alias:
         shortname = alias
+    else:
+        shortname = repo
 
     if state == 'present':
         if exists and not mod:


Test patch:
diff --git a/tests/integration/targets/zypper_repository/files/systemsmanagement_Uyuni_Utils.repo b/tests/integration/targets/zypper_repository/files/systemsmanagement_Uyuni_Utils.repo
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/zypper_repository/files/systemsmanagement_Uyuni_Utils.repo
@@ -0,0 +1,7 @@
+[systemsmanagement_Uyuni_Utils]
+name=Several utilities to develop, build or release Uyuni (openSUSE_Leap_15.3)
+type=rpm-md
+baseurl=https://download.opensuse.org/repositories/systemsmanagement:/Uyuni:/Utils/openSUSE_Leap_15.3/
+gpgcheck=1
+gpgkey=https://download.opensuse.org/repositories/systemsmanagement:/Uyuni:/Utils/openSUSE_Leap_15.3/repodata/repomd.xml.key
+enabled=1
diff --git a/tests/integration/targets/zypper_repository/meta/main.yml b/tests/integration/targets/zypper_repository/meta/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/zypper_repository/meta/main.yml
@@ -0,0 +1,2 @@
+dependencies:
+  - setup_remote_tmp_dir
diff --git a/tests/integration/targets/zypper_repository/tasks/test.yml b/tests/integration/targets/zypper_repository/tasks/test.yml
--- a/tests/integration/targets/zypper_repository/tasks/test.yml
+++ b/tests/integration/targets/zypper_repository/tasks/test.yml
@@ -19,6 +19,8 @@
         - testrefresh
         - testprio
         - Apache_PHP_Modules
+        - systemsmanagement_Uyuni_Stable
+        - systemsmanagement_Uyuni_Utils
 
     - name: collect repo configuration after test
       shell: "grep . /etc/zypp/repos.d/*"
diff --git a/tests/integration/targets/zypper_repository/tasks/zypper_repository.yml b/tests/integration/targets/zypper_repository/tasks/zypper_repository.yml
--- a/tests/integration/targets/zypper_repository/tasks/zypper_repository.yml
+++ b/tests/integration/targets/zypper_repository/tasks/zypper_repository.yml
@@ -4,6 +4,11 @@
     state: absent
   register: zypper_result
 
+- name: verify no change on test repo deletion
+  assert:
+    that:
+      - "not zypper_result.changed"
+
 - name: Add test repo
   community.general.zypper_repository:
     name: test
@@ -51,7 +56,8 @@
   command: zypper -x lr testrefresh
   register: zypper_result
 
-- assert:
+- name: verify autorefresh option set properly
+  assert:
     that:
       - '"autorefresh=\"0\"" in zypper_result.stdout'
 
@@ -66,7 +72,8 @@
   command: zypper -x lr testprio
   register: zypper_result
 
-- assert:
+- name: verify priority option set properly
+  assert:
     that:
       - '"priority=\"55\"" in zypper_result.stdout'
 
@@ -88,7 +95,8 @@
   command: zypper lr chrome2
   register: zypper_result2
 
-- assert:
+- name: ensure same url cause update of existing repo even if name differ
+  assert:
     that:
       - "zypper_result1.rc != 0"
       - "'not found' in zypper_result1.stderr"
@@ -108,7 +116,8 @@
   command: zypper lr samename
   register: zypper_result
 
-- assert:
+- name: ensure url get updated on repo with same name
+  assert:
     that:
       - "'/science/' not in zypper_result.stdout"
       - "'/devel:/languages:/ruby/' in zypper_result.stdout"
@@ -140,7 +149,8 @@
     state: present
   register: add_repo_again
 
-- assert:
+- name: no update in case of $releasever usage in url
+  assert:
     that:
       - add_repo is changed
       - add_repo_again is not changed
@@ -151,10 +161,21 @@
     state: absent
   register: remove_repo
 
-- assert:
+- name: verify repo was removed
+  assert:
     that:
       - remove_repo is changed
 
+- name: get list of files in /etc/zypp/repos.d/
+  command: ls /etc/zypp/repos.d/
+  changed_when: false
+  register: releaseverrepo_etc_zypp_reposd
+
+- name: verify removal of file releaseverrepo.repo in /etc/zypp/repos.d/
+  assert:
+    that:
+      - "'releaseverrepo' not in releaseverrepo_etc_zypp_reposd.stdout"
+
 - name: add a repo by basearch
   community.general.zypper_repository:
     name: basearchrepo
@@ -169,7 +190,8 @@
     state: present
   register: add_repo_again
 
-- assert:
+- name: no update in case of $basearch usage in url
+  assert:
     that:
       - add_repo is changed
       - add_repo_again is not changed
@@ -180,6 +202,74 @@
     state: absent
   register: remove_repo
 
-- assert:
+- name: verify repo was removed
+  assert:
     that:
       - remove_repo is changed
+
+- name: add new repository via url to .repo file
+  community.general.zypper_repository:
+    repo: http://download.opensuse.org/repositories/systemsmanagement:/Uyuni:/Stable/openSUSE_Leap_{{ ansible_distribution_version }}/systemsmanagement:Uyuni:Stable.repo
+    state: present
+  register: added_by_repo_file
+
+- name: get repository details from zypper
+  command: zypper lr systemsmanagement_Uyuni_Stable
+  register: get_repository_details_from_zypper
+
+- name: verify adding via .repo file was successful
+  assert:
+    that:
+      - "added_by_repo_file is changed"
+      - "get_repository_details_from_zypper.rc == 0"
+      - "'/systemsmanagement:/Uyuni:/Stable/' in get_repository_details_from_zypper.stdout"
+
+- name: add same repository via url to .repo file again to verify idempotency
+  community.general.zypper_repository:
+    repo: http://download.opensuse.org/repositories/systemsmanagement:/Uyuni:/Stable/openSUSE_Leap_{{ ansible_distribution_version }}/systemsmanagement:Uyuni:Stable.repo
+    state: present
+  register: added_again_by_repo_file
+
+- name: verify nothing was changed adding a repo with the same .repo file
+  assert:
+    that:
+      - added_again_by_repo_file is not changed
+
+- name: remove repository via url to .repo file
+  community.general.zypper_repository:
+    repo: http://download.opensuse.org/repositories/systemsmanagement:/Uyuni:/Stable/openSUSE_Leap_{{ ansible_distribution_version }}/systemsmanagement:Uyuni:Stable.repo
+    state: absent
+  register: removed_by_repo_file
+
+- name: get list of files in /etc/zypp/repos.d/
+  command: ls /etc/zypp/repos.d/
+  changed_when: false
+  register: etc_zypp_reposd
+
+- name: verify removal via .repo file was successful, including cleanup of local .repo file in /etc/zypp/repos.d/
+  assert:
+    that:
+      - "removed_by_repo_file"
+      - "'/systemsmanagement:/Uyuni:/Stable/' not in etc_zypp_reposd.stdout"
+
+- name: Copy test .repo file
+  copy:
+    src: 'files/systemsmanagement_Uyuni_Utils.repo'
+    dest: '{{ remote_tmp_dir }}'
+
+- name: add new repository via local path to .repo file
+  community.general.zypper_repository:
+    repo: "{{ remote_tmp_dir }}/systemsmanagement_Uyuni_Utils.repo"
+    state: present
+  register: added_by_repo_local_file
+
+- name: get repository details for systemsmanagement_Uyuni_Utils from zypper
+  command: zypper lr systemsmanagement_Uyuni_Utils
+  register: get_repository_details_from_zypper_for_systemsmanagement_Uyuni_Utils
+
+- name: verify adding repository via local .repo file was successful
+  assert:
+    that:
+      - "added_by_repo_local_file is changed"
+      - "get_repository_details_from_zypper_for_systemsmanagement_Uyuni_Utils.rc == 0"
+      - "'/systemsmanagement:/Uyuni:/Utils/' in get_repository_details_from_zypper_for_systemsmanagement_Uyuni_Utils.stdout"