Repository: ansible-collections/community.general

Issue:
No effect modprobe
<!--- Verify first that your issue is not already reported on GitHub -->
<!--- Also test if the latest release and devel branch are affected too -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
It shows status changed but the module is not listed on loaded modules. Also I run many times and all the time it is changed.

After some more investigation, I see the module is builtin. It is not correctly verifying if the module is loaded.

##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
community.general.modprobe

##### ANSIBLE VERSION
<!--- Paste verbatim output from "ansible --version" between quotes -->
```paste below
ansible 2.10.4
```

##### CONFIGURATION
<!--- Paste verbatim output from "ansible-config dump --only-changed" between quotes -->
```paste below
Nothing.
```

##### OS / ENVIRONMENT
<!--- Provide all relevant information below, e.g. target OS versions, network device firmware, etc. -->
Distributor ID:	Ubuntu
Description:	Ubuntu 20.10
Release:	20.10
Codename:	groovy

##### STEPS TO REPRODUCE
<!--- Describe exactly how to reproduce the problem, using a minimal test-case -->

<!--- Paste example playbooks or commands between quotes below -->

```yaml
---
- hosts: all

  tasks:
    - name: Load i2c-dev module
      community.general.modprobe:
        name: i2c-dev
        state: present
```

Inventory:

```
[all]
127.0.0.1  ansible_connection=local
```

Run:

```sh
ansible-playbook main.yml -i inventory --ask-become-pass
```

<!--- HINT: You can paste gist.github.com links for larger files -->

##### EXPECTED RESULTS
Have changed status and module loaded. Run again and have ok status.

##### ACTUAL RESULTS
<!--- Describe what actually happened. If possible run with extra verbosity (-vvvv) -->
Have changed status and module non-loaded. Run again and have chagend status again.

-vvvv output:

```paste below
TASK [Load i2c-dev module] ***********************************************************************************************************
task path: /home/static/workspace/main.yml:4
Using module file /home/static/.ansible/collections/ansible_collections/community/general/plugins/modules/modprobe.py
Pipelining is enabled.
<127.0.0.1> ESTABLISH LOCAL CONNECTION FOR USER: static
<127.0.0.1> EXEC /bin/sh -c '/usr/bin/python3 && sleep 0'
changed: [127.0.0.1] => {
    "changed": true,
    "invocation": {
        "module_args": {
            "name": "i2c-dev",
            "params": "",
            "state": "present"
        }
    },
    "name": "i2c-dev",
    "params": "",
    "state": "present"
}
```



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 --->
!component=modprobe
Files identified in the description:
* [`modprobe`](https://github.com/ansible-collections/community.general/blob/master/modprobe)

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 --->
!component =plugins/modules/system/modprobe.py
Files identified in the description:
* [`plugins/modules/system/modprobe.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/system/modprobe.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 @jdauphant @mattjeffery
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/system/modprobe.py b/plugins/modules/system/modprobe.py
--- a/plugins/modules/system/modprobe.py
+++ b/plugins/modules/system/modprobe.py
@@ -50,11 +50,90 @@
 '''
 
 import os.path
+import platform
 import shlex
 import traceback
 
 from ansible.module_utils.basic import AnsibleModule
-from ansible.module_utils._text import to_native
+from ansible.module_utils.common.text.converters import to_native
+
+RELEASE_VER = platform.release()
+
+
+class Modprobe(object):
+    def __init__(self, module):
+        self.module = module
+        self.modprobe_bin = module.get_bin_path('modprobe', True)
+
+        self.check_mode = module.check_mode
+        self.desired_state = module.params['state']
+        self.name = module.params['name']
+        self.params = module.params['params']
+
+        self.changed = False
+
+    def load_module(self):
+        command = [self.modprobe_bin]
+        if self.check_mode:
+            command.append('-n')
+        command.extend([self.name] + shlex.split(self.params))
+
+        rc, out, err = self.module.run_command(command)
+
+        if rc != 0:
+            return self.module.fail_json(msg=err, rc=rc, stdout=out, stderr=err, **self.result)
+
+        if self.check_mode or self.module_loaded():
+            self.changed = True
+        else:
+            rc, stdout, stderr = self.module.run_command(
+                [self.modprobe_bin, '-n', '--first-time', self.name] + shlex.split(self.params)
+            )
+            if rc != 0:
+                self.module.warn(stderr)
+
+    def module_loaded(self):
+        is_loaded = False
+        try:
+            with open('/proc/modules') as modules:
+                module_name = self.name.replace('-', '_') + ' '
+                for line in modules:
+                    if line.startswith(module_name):
+                        is_loaded = True
+                        break
+
+            if not is_loaded:
+                module_file = '/' + self.name + '.ko'
+                builtin_path = os.path.join('/lib/modules/', RELEASE_VER, 'modules.builtin')
+                with open(builtin_path) as builtins:
+                    for line in builtins:
+                        if line.rstrip().endswith(module_file):
+                            is_loaded = True
+                            break
+        except (IOError, OSError) as e:
+            self.module.fail_json(msg=to_native(e), exception=traceback.format_exc(), **self.result)
+
+        return is_loaded
+
+    def unload_module(self):
+        command = [self.modprobe_bin, '-r', self.name]
+        if self.check_mode:
+            command.append('-n')
+
+        rc, out, err = self.module.run_command(command)
+        if rc != 0:
+            return self.module.fail_json(msg=err, rc=rc, stdout=out, stderr=err, **self.result)
+
+        self.changed = True
+
+    @property
+    def result(self):
+        return {
+            'changed': self.changed,
+            'name': self.name,
+            'params': self.params,
+            'state': self.desired_state,
+        }
 
 
 def main():
@@ -67,60 +146,14 @@ def main():
         supports_check_mode=True,
     )
 
-    name = module.params['name']
-    params = module.params['params']
-    state = module.params['state']
+    modprobe = Modprobe(module)
 
-    # FIXME: Adding all parameters as result values is useless
-    result = dict(
-        changed=False,
-        name=name,
-        params=params,
-        state=state,
-    )
+    if modprobe.desired_state == 'present' and not modprobe.module_loaded():
+        modprobe.load_module()
+    elif modprobe.desired_state == 'absent' and modprobe.module_loaded():
+        modprobe.unload_module()
 
-    # Check if module is present
-    try:
-        present = False
-        with open('/proc/modules') as modules:
-            module_name = name.replace('-', '_') + ' '
-            for line in modules:
-                if line.startswith(module_name):
-                    present = True
-                    break
-        if not present:
-            command = [module.get_bin_path('uname', True), '-r']
-            rc, uname_kernel_release, err = module.run_command(command)
-            module_file = '/' + name + '.ko'
-            builtin_path = os.path.join('/lib/modules/', uname_kernel_release.strip(),
-                                        'modules.builtin')
-            with open(builtin_path) as builtins:
-                for line in builtins:
-                    if line.endswith(module_file):
-                        present = True
-                        break
-    except IOError as e:
-        module.fail_json(msg=to_native(e), exception=traceback.format_exc(), **result)
-
-    # Add/remove module as needed
-    if state == 'present':
-        if not present:
-            if not module.check_mode:
-                command = [module.get_bin_path('modprobe', True), name]
-                command.extend(shlex.split(params))
-                rc, out, err = module.run_command(command)
-                if rc != 0:
-                    module.fail_json(msg=err, rc=rc, stdout=out, stderr=err, **result)
-            result['changed'] = True
-    elif state == 'absent':
-        if present:
-            if not module.check_mode:
-                rc, out, err = module.run_command([module.get_bin_path('modprobe', True), '-r', name])
-                if rc != 0:
-                    module.fail_json(msg=err, rc=rc, stdout=out, stderr=err, **result)
-            result['changed'] = True
-
-    module.exit_json(**result)
+    module.exit_json(**modprobe.result)
 
 
 if __name__ == '__main__':


Test patch:
diff --git a/tests/unit/plugins/modules/system/test_modprobe.py b/tests/unit/plugins/modules/system/test_modprobe.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/modules/system/test_modprobe.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+# 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_collections.community.general.tests.unit.plugins.modules.utils import ModuleTestCase, set_module_args
+from ansible_collections.community.general.tests.unit.compat.mock import patch
+from ansible_collections.community.general.tests.unit.compat.mock import Mock
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.modules.system.modprobe import Modprobe
+
+
+class TestLoadModule(ModuleTestCase):
+    def setUp(self):
+        super(TestLoadModule, self).setUp()
+
+        self.mock_module_loaded = patch(
+            'ansible_collections.community.general.plugins.modules.system.modprobe.Modprobe.module_loaded'
+        )
+        self.mock_run_command = patch('ansible.module_utils.basic.AnsibleModule.run_command')
+        self.mock_get_bin_path = patch('ansible.module_utils.basic.AnsibleModule.get_bin_path')
+
+        self.module_loaded = self.mock_module_loaded.start()
+        self.run_command = self.mock_run_command.start()
+        self.get_bin_path = self.mock_get_bin_path.start()
+
+    def tearDown(self):
+        """Teardown."""
+        super(TestLoadModule, self).tearDown()
+        self.mock_module_loaded.stop()
+        self.mock_run_command.stop()
+        self.mock_get_bin_path.stop()
+
+    def test_load_module_success(self):
+        set_module_args(dict(
+            name='test',
+            state='present',
+        ))
+
+        module = AnsibleModule(
+            argument_spec=dict(
+                name=dict(type='str', required=True),
+                state=dict(type='str', default='present', choices=['absent', 'present']),
+                params=dict(type='str', default=''),
+            ),
+            supports_check_mode=True,
+        )
+
+        self.get_bin_path.side_effect = ['modprobe']
+        self.module_loaded.side_effect = [True]
+        self.run_command.side_effect = [(0, '', '')]
+
+        modprobe = Modprobe(module)
+        modprobe.load_module()
+
+        assert modprobe.result == {
+            'changed': True,
+            'name': 'test',
+            'params': '',
+            'state': 'present',
+        }
+
+    def test_load_module_unchanged(self):
+        set_module_args(dict(
+            name='test',
+            state='present',
+        ))
+
+        module = AnsibleModule(
+            argument_spec=dict(
+                name=dict(type='str', required=True),
+                state=dict(type='str', default='present', choices=['absent', 'present']),
+                params=dict(type='str', default=''),
+            ),
+            supports_check_mode=True,
+        )
+
+        module.warn = Mock()
+
+        self.get_bin_path.side_effect = ['modprobe']
+        self.module_loaded.side_effect = [False]
+        self.run_command.side_effect = [(0, '', ''), (1, '', '')]
+
+        modprobe = Modprobe(module)
+        modprobe.load_module()
+
+        module.warn.assert_called_once_with('')
+
+
+class TestUnloadModule(ModuleTestCase):
+    def setUp(self):
+        super(TestUnloadModule, self).setUp()
+
+        self.mock_module_loaded = patch(
+            'ansible_collections.community.general.plugins.modules.system.modprobe.Modprobe.module_loaded'
+        )
+        self.mock_run_command = patch('ansible.module_utils.basic.AnsibleModule.run_command')
+        self.mock_get_bin_path = patch('ansible.module_utils.basic.AnsibleModule.get_bin_path')
+
+        self.module_loaded = self.mock_module_loaded.start()
+        self.run_command = self.mock_run_command.start()
+        self.get_bin_path = self.mock_get_bin_path.start()
+
+    def tearDown(self):
+        """Teardown."""
+        super(TestUnloadModule, self).tearDown()
+        self.mock_module_loaded.stop()
+        self.mock_run_command.stop()
+        self.mock_get_bin_path.stop()
+
+    def test_unload_module_success(self):
+        set_module_args(dict(
+            name='test',
+            state='absent',
+        ))
+
+        module = AnsibleModule(
+            argument_spec=dict(
+                name=dict(type='str', required=True),
+                state=dict(type='str', default='present', choices=['absent', 'present']),
+                params=dict(type='str', default=''),
+            ),
+            supports_check_mode=True,
+        )
+
+        self.get_bin_path.side_effect = ['modprobe']
+        self.module_loaded.side_effect = [False]
+        self.run_command.side_effect = [(0, '', '')]
+
+        modprobe = Modprobe(module)
+        modprobe.unload_module()
+
+        assert modprobe.result == {
+            'changed': True,
+            'name': 'test',
+            'params': '',
+            'state': 'absent',
+        }
+
+    def test_unload_module_failure(self):
+        set_module_args(dict(
+            name='test',
+            state='absent',
+        ))
+
+        module = AnsibleModule(
+            argument_spec=dict(
+                name=dict(type='str', required=True),
+                state=dict(type='str', default='present', choices=['absent', 'present']),
+                params=dict(type='str', default=''),
+            ),
+            supports_check_mode=True,
+        )
+
+        module.fail_json = Mock()
+
+        self.get_bin_path.side_effect = ['modprobe']
+        self.module_loaded.side_effect = [True]
+        self.run_command.side_effect = [(1, '', '')]
+
+        modprobe = Modprobe(module)
+        modprobe.unload_module()
+
+        dummy_result = {
+            'changed': False,
+            'name': 'test',
+            'state': 'absent',
+            'params': '',
+        }
+
+        module.fail_json.assert_called_once_with(
+            msg='', rc=1, stdout='', stderr='', **dummy_result
+        )