Repository: ansible-collections/community.general

Issue:
rundeck_project not working
### Summary

I tried to use the  `rundeck_project` module, but it is not working? Should this still a working module? For me it looks like kind of dead, no changes in the last ~ two years.

### Issue Type

Bug Report

### Component Name

rundeck_project

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.14.1]
  config file = /home/hille/gitrepos/plminfra_ansible/ansible.cfg
  configured module search path = ['/home/hille/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/hille/gitrepos/plminfra_ansible/.venv/lib/python3.9/site-packages/ansible
  ansible collection location = /home/hille/.ansible/collections:/usr/share/ansible/collections:/home/hille/server/data/ansible_collections
  executable location = /home/hille/gitrepos/plminfra_ansible/.venv/bin/ansible
  python version = 3.9.5 (default, Nov 23 2021, 15:27:38) [GCC 9.3.0] (/home/hille/gitrepos/plminfra_ansible/.venv/bin/python3.9)
  jinja version = 3.1.2
  libyaml = True
```


### Community.general Version

```console (paste below)
$ ansible-galaxy collection list community.general
Collection        Version
----------------- -------
community.general 6.1.0
```


### Configuration

_No response_

### OS / Environment

NAME="Ubuntu"
VERSION="20.04.5 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.5 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- name: Create Rundeck project 
  community.general.rundeck_project:
    name: ProjectName
    url: "{{ rundeck_url }}"
    token: "{{ rundeck_token }}"
    state: present
```


### Expected Results

Project will be created

### Actual Results

```console (paste below)
The full traceback is:
  File "/tmp/ansible_community.general.rundeck_project_payload_mft4mzlr/ansible_community.general.rundeck_project_payload.zip/ansible_collections/community/general/plugins/modules/rundeck_project.py", line 139, in request_rundeck_api
  File "/usr/lib/python3.9/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File "/usr/lib/python3.9/json/decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "/usr/lib/python3.9/json/decoder.py", line 355, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
fatal: [localhost]: FAILED! => changed=false 
  invocation:
    module_args:
      api_version: 41
      client_cert: null
      client_key: null
      force: false
      force_basic_auth: false
      http_agent: ansible-httpget
      name: plmops
      state: present
      token: VALUE_SPECIFIED_IN_NO_LOG_PARAMETER
      url: https://rdeckplmprod.icp.infineon.com
      url_password: null
      url_username: null
      use_gssapi: false
      use_proxy: true
      validate_certs: true
  msg: 'Rundeck response was not a valid JSON. Exception was: Expecting value: line 1 column 1 (char 0). Object was: b'''''
```


### Code of Conduct

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


Discussion:
Files identified in the description:
* [`plugins/modules/rundeck_project.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/rundeck_project.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 --->
cc @nerzhul
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Already found out that if following line https://github.com/ansible-collections/community.general/blob/main/plugins/modules/rundeck_project.py#L137 will be changed to `if resp != b"":`, it is running without an error. But it is still not creating a project, because of this line: https://github.com/ansible-collections/community.general/blob/main/plugins/modules/rundeck_project.py#L152. `facts` will be `b''` not none, thus it is doing nothing.
But at least the deletion with `absent` is working then. 

I also tried it with Python3.6 and even Python2. Same respectively another error :/

I would probably change line 136 to `resp = resp.read() or None` and line 137 to `if resp:`. That way the `request_rundeck_api()` function either returns decoded JSON or `None` as its first return value.

Resolving patch:
diff --git a/plugins/module_utils/rundeck.py b/plugins/module_utils/rundeck.py
--- a/plugins/module_utils/rundeck.py
+++ b/plugins/module_utils/rundeck.py
@@ -81,12 +81,18 @@ def api_request(module, endpoint, data=None, method="GET"):
 
     try:
         content = response.read()
-        json_response = json.loads(content)
-        return json_response, info
+
+        if not content:
+            return None, info
+        else:
+            json_response = json.loads(content)
+            return json_response, info
     except AttributeError as error:
-        module.fail_json(msg="Rundeck API request error",
-                         exception=to_native(error),
-                         execution_info=info)
+        module.fail_json(
+            msg="Rundeck API request error",
+            exception=to_native(error),
+            execution_info=info
+        )
     except ValueError as error:
         module.fail_json(
             msg="No valid JSON response",
diff --git a/plugins/modules/rundeck_acl_policy.py b/plugins/modules/rundeck_acl_policy.py
--- a/plugins/modules/rundeck_acl_policy.py
+++ b/plugins/modules/rundeck_acl_policy.py
@@ -36,22 +36,10 @@
         description:
             - Sets the project name.
         required: true
-    url:
-        type: str
-        description:
-            - Sets the rundeck instance URL.
-        required: true
-    api_version:
-        type: int
-        description:
-            - Sets the API version used by module.
-            - API version must be at least 14.
-        default: 14
-    token:
-        type: str
+    api_token:
         description:
             - Sets the token to authenticate against Rundeck API.
-        required: true
+        aliases: ["token"]
     project:
         type: str
         description:
@@ -82,8 +70,9 @@
     validate_certs:
         version_added: '0.2.0'
 extends_documentation_fragment:
-    - ansible.builtin.url
-    - community.general.attributes
+  - ansible.builtin.url
+  - community.general.attributes
+  - community.general.rundeck
 '''
 
 EXAMPLES = '''
@@ -107,7 +96,7 @@
 
 - name: Remove a rundeck system policy
   community.general.rundeck_acl_policy:
-    name: "Project_02"
+    name: "Project_01"
     url: "https://rundeck.example.org"
     token: "mytoken"
     state: absent
@@ -129,49 +118,25 @@
 '''
 
 # import module snippets
-from ansible.module_utils.basic import AnsibleModule
-from ansible.module_utils.urls import fetch_url, url_argument_spec
-from ansible.module_utils.common.text.converters import to_text
-import json
 import re
 
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.rundeck import (
+    api_argument_spec,
+    api_request,
+)
+
 
 class RundeckACLManager:
     def __init__(self, module):
         self.module = module
 
-    def handle_http_code_if_needed(self, infos):
-        if infos["status"] == 403:
-            self.module.fail_json(msg="Token not allowed. Please ensure token is allowed or has the correct "
-                                      "permissions.", rundeck_response=infos["body"])
-        elif infos["status"] >= 500:
-            self.module.fail_json(msg="Fatal Rundeck API error.", rundeck_response=infos["body"])
-
-    def request_rundeck_api(self, query, data=None, method="GET"):
-        resp, info = fetch_url(self.module,
-                               "%s/api/%d/%s" % (self.module.params["url"], self.module.params["api_version"], query),
-                               data=json.dumps(data),
-                               method=method,
-                               headers={
-                                   "Content-Type": "application/json",
-                                   "Accept": "application/json",
-                                   "X-Rundeck-Auth-Token": self.module.params["token"]
-                               })
-
-        self.handle_http_code_if_needed(info)
-        if resp is not None:
-            resp = resp.read()
-            if resp != b"":
-                try:
-                    json_resp = json.loads(to_text(resp, errors='surrogate_or_strict'))
-                    return json_resp, info
-                except ValueError as e:
-                    self.module.fail_json(msg="Rundeck response was not a valid JSON. Exception was: %s. "
-                                              "Object was: %s" % (str(e), resp))
-        return resp, info
-
     def get_acl(self):
-        resp, info = self.request_rundeck_api("system/acl/%s.aclpolicy" % self.module.params["name"])
+        resp, info = api_request(
+            module=self.module,
+            endpoint="system/acl/%s.aclpolicy" % self.module.params["name"],
+        )
+
         return resp
 
     def create_or_update_acl(self):
@@ -181,9 +146,12 @@ def create_or_update_acl(self):
             if self.module.check_mode:
                 self.module.exit_json(changed=True, before={}, after=self.module.params["policy"])
 
-            dummy, info = self.request_rundeck_api("system/acl/%s.aclpolicy" % self.module.params["name"],
-                                                   method="POST",
-                                                   data={"contents": self.module.params["policy"]})
+            resp, info = api_request(
+                module=self.module,
+                endpoint="system/acl/%s.aclpolicy" % self.module.params["name"],
+                method="POST",
+                data={"contents": self.module.params["policy"]},
+            )
 
             if info["status"] == 201:
                 self.module.exit_json(changed=True, before={}, after=self.get_acl())
@@ -202,9 +170,12 @@ def create_or_update_acl(self):
             if self.module.check_mode:
                 self.module.exit_json(changed=True, before=facts, after=facts)
 
-            dummy, info = self.request_rundeck_api("system/acl/%s.aclpolicy" % self.module.params["name"],
-                                                   method="PUT",
-                                                   data={"contents": self.module.params["policy"]})
+            resp, info = api_request(
+                module=self.module,
+                endpoint="system/acl/%s.aclpolicy" % self.module.params["name"],
+                method="PUT",
+                data={"contents": self.module.params["policy"]},
+            )
 
             if info["status"] == 200:
                 self.module.exit_json(changed=True, before=facts, after=self.get_acl())
@@ -216,34 +187,39 @@ def create_or_update_acl(self):
 
     def remove_acl(self):
         facts = self.get_acl()
+
         if facts is None:
             self.module.exit_json(changed=False, before={}, after={})
         else:
             # If not in check mode, remove the project
             if not self.module.check_mode:
-                self.request_rundeck_api("system/acl/%s.aclpolicy" % self.module.params["name"], method="DELETE")
-            self.module.exit_json(changed=True, before=facts, after={})
+                api_request(
+                    module=self.module,
+                    endpoint="system/acl/%s.aclpolicy" % self.module.params["name"],
+                    method="DELETE",
+                )
+
+                self.module.exit_json(changed=True, before=facts, after={})
 
 
 def main():
     # Also allow the user to set values for fetch_url
-    argument_spec = url_argument_spec()
+    argument_spec = api_argument_spec()
     argument_spec.update(dict(
         state=dict(type='str', choices=['present', 'absent'], default='present'),
         name=dict(required=True, type='str'),
-        url=dict(required=True, type='str'),
-        api_version=dict(type='int', default=14),
-        token=dict(required=True, type='str', no_log=True),
         policy=dict(type='str'),
         project=dict(type='str'),
     ))
 
+    argument_spec['api_token']['aliases'] = ['token']
+
     module = AnsibleModule(
         argument_spec=argument_spec,
         required_if=[
             ['state', 'present', ['policy']],
         ],
-        supports_check_mode=True
+        supports_check_mode=True,
     )
 
     if not bool(re.match("[a-zA-Z0-9,.+_-]+", module.params["name"])):
diff --git a/plugins/modules/rundeck_project.py b/plugins/modules/rundeck_project.py
--- a/plugins/modules/rundeck_project.py
+++ b/plugins/modules/rundeck_project.py
@@ -38,22 +38,10 @@
         description:
             - Sets the project name.
         required: true
-    url:
-        type: str
-        description:
-            - Sets the rundeck instance URL.
-        required: true
-    api_version:
-        type: int
-        description:
-            - Sets the API version used by module.
-            - API version must be at least 14.
-        default: 14
-    token:
-        type: str
+    api_token:
         description:
             - Sets the token to authenticate against Rundeck API.
-        required: true
+        aliases: ["token"]
     client_cert:
         version_added: '0.2.0'
     client_key:
@@ -73,24 +61,27 @@
     validate_certs:
         version_added: '0.2.0'
 extends_documentation_fragment:
-    - ansible.builtin.url
-    - community.general.attributes
+  - ansible.builtin.url
+  - community.general.attributes
+  - community.general.rundeck
 '''
 
 EXAMPLES = '''
 - name: Create a rundeck project
   community.general.rundeck_project:
     name: "Project_01"
-    api_version: 18
+    label: "Project 01"
+    description: "My Project 01"
     url: "https://rundeck.example.org"
-    token: "mytoken"
+    api_version: 39
+    api_token: "mytoken"
     state: present
 
 - name: Remove a rundeck project
   community.general.rundeck_project:
-    name: "Project_02"
+    name: "Project_01"
     url: "https://rundeck.example.org"
-    token: "mytoken"
+    api_token: "mytoken"
     state: absent
 '''
 
@@ -111,60 +102,47 @@
 
 # import module snippets
 from ansible.module_utils.basic import AnsibleModule
-from ansible.module_utils.common.text.converters import to_native
-from ansible.module_utils.urls import fetch_url, url_argument_spec
-import json
+from ansible_collections.community.general.plugins.module_utils.rundeck import (
+    api_argument_spec,
+    api_request,
+)
 
 
 class RundeckProjectManager(object):
     def __init__(self, module):
         self.module = module
 
-    def handle_http_code_if_needed(self, infos):
-        if infos["status"] == 403:
-            self.module.fail_json(msg="Token not allowed. Please ensure token is allowed or has the correct "
-                                      "permissions.", rundeck_response=infos["body"])
-        elif infos["status"] >= 500:
-            self.module.fail_json(msg="Fatal Rundeck API error.", rundeck_response=infos["body"])
-
-    def request_rundeck_api(self, query, data=None, method="GET"):
-        resp, info = fetch_url(self.module,
-                               "%s/api/%d/%s" % (self.module.params["url"], self.module.params["api_version"], query),
-                               data=json.dumps(data),
-                               method=method,
-                               headers={
-                                   "Content-Type": "application/json",
-                                   "Accept": "application/json",
-                                   "X-Rundeck-Auth-Token": self.module.params["token"]
-                               })
-
-        self.handle_http_code_if_needed(info)
-        if resp is not None:
-            resp = resp.read()
-            if resp != "":
-                try:
-                    json_resp = json.loads(resp)
-                    return json_resp, info
-                except ValueError as e:
-                    self.module.fail_json(msg="Rundeck response was not a valid JSON. Exception was: %s. "
-                                              "Object was: %s" % (to_native(e), resp))
-        return resp, info
-
     def get_project_facts(self):
-        resp, info = self.request_rundeck_api("project/%s" % self.module.params["name"])
+        resp, info = api_request(
+            module=self.module,
+            endpoint="project/%s" % self.module.params["name"],
+        )
+
         return resp
 
     def create_or_update_project(self):
         facts = self.get_project_facts()
+
         if facts is None:
             # If in check mode don't create project, simulate a fake project creation
             if self.module.check_mode:
-                self.module.exit_json(changed=True, before={}, after={"name": self.module.params["name"]})
-
-            resp, info = self.request_rundeck_api("projects", method="POST", data={
-                "name": self.module.params["name"],
-                "config": {}
-            })
+                self.module.exit_json(
+                    changed=True,
+                    before={},
+                    after={
+                        "name": self.module.params["name"]
+                    },
+                )
+
+            resp, info = api_request(
+                module=self.module,
+                endpoint="projects",
+                method="POST",
+                data={
+                    "name": self.module.params["name"],
+                    "config": {},
+                }
+            )
 
             if info["status"] == 201:
                 self.module.exit_json(changed=True, before={}, after=self.get_project_facts())
@@ -181,21 +159,25 @@ def remove_project(self):
         else:
             # If not in check mode, remove the project
             if not self.module.check_mode:
-                self.request_rundeck_api("project/%s" % self.module.params["name"], method="DELETE")
+                api_request(
+                    module=self.module,
+                    endpoint="project/%s" % self.module.params["name"],
+                    method="DELETE",
+                )
+
             self.module.exit_json(changed=True, before=facts, after={})
 
 
 def main():
     # Also allow the user to set values for fetch_url
-    argument_spec = url_argument_spec()
+    argument_spec = api_argument_spec()
     argument_spec.update(dict(
         state=dict(type='str', choices=['present', 'absent'], default='present'),
         name=dict(required=True, type='str'),
-        url=dict(required=True, type='str'),
-        api_version=dict(type='int', default=14),
-        token=dict(required=True, type='str', no_log=True),
     ))
 
+    argument_spec['api_token']['aliases'] = ['token']
+
     module = AnsibleModule(
         argument_spec=argument_spec,
         supports_check_mode=True


Test patch: