diff --git a/changelogs/fragments/381-sub-folders.yml b/changelogs/fragments/381-sub-folders.yml new file mode 100644 index 00000000..bb6f2506 --- /dev/null +++ b/changelogs/fragments/381-sub-folders.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - Manage subfolders for `grafana_folder` and specify uid diff --git a/plugins/modules/grafana_folder.py b/plugins/modules/grafana_folder.py index 73c437db..9e7cea92 100644 --- a/plugins/modules/grafana_folder.py +++ b/plugins/modules/grafana_folder.py @@ -25,22 +25,31 @@ author: - RĂ©mi REY (@rrey) version_added: "1.0.0" -short_description: Manage Grafana Folders +short_description: Manage Grafana folders description: - - Create/update/delete Grafana Folders through the Folders API. + - Create/update/delete Grafana folders through the folders API. requirements: - - The Folders API is only available starting Grafana 5 and the module will fail if the server version is lower than version 5. + - The folders API is only available starting Grafana 5 and the module will fail if the server version is lower than version 5. options: name: description: - - The title of the Grafana Folder. + - The title of the Grafana folder. required: true type: str aliases: [ title ] + uid: + description: + - The folder UID. + type: str + parent_uid: + description: + - The parent folder UID. + - Available with subfolder feature of Grafana 11. + type: str state: description: - Delete the members not found in the C(members) parameters from the - - list of members found on the Folder. + - list of members found on the folder. default: present type: str choices: ["present", "absent"] @@ -92,30 +101,36 @@ RETURN = """ --- folder: - description: Information about the Folder + description: Information about the folder returned: On success type: complex contains: id: - description: The Folder identifier + description: The folder identifier returned: always type: int sample: - 42 uid: - description: The Folder uid + description: The folder uid returned: always type: str sample: - "nErXDvCkzz" + orgId: + description: The organization id + returned: always + type: int + sample: + - 1 title: - description: The Folder title + description: The folder title returned: always type: str sample: - "Department ABC" url: - description: The Folder url + description: The folder url returned: always type: str sample: @@ -174,6 +189,12 @@ type: int sample: - 1 + parentUid: + description: The parent folders uid + returned: always as subfolder + type: str + sample: + - "76HjcBH2" """ import json @@ -181,7 +202,6 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import fetch_url, basic_auth_header from ansible_collections.community.grafana.plugins.module_utils import base -from ansible.module_utils.six.moves.urllib.parse import quote from ansible.module_utils._text import to_text __metaclass__ = type @@ -220,7 +240,11 @@ def __init__(self, module): self._module.fail_json(failed=True, msg=to_text(e)) if grafana_version["major"] < 5: self._module.fail_json( - failed=True, msg="Folders API is available starting Grafana v5" + failed=True, msg="folders API is available starting Grafana v5" + ) + if grafana_version["major"] < 11 and module.params["parent_uid"]: + self._module.fail_json( + failed=True, msg="Subfolder API is available starting Grafana v11" ) def _send_request(self, url, data=None, headers=None, method="GET"): @@ -252,7 +276,7 @@ def _send_request(self, url, data=None, headers=None, method="GET"): response = resp.read() or "{}" return self._module.from_json(response) self._module.fail_json( - failed=True, msg="Grafana Folders API answered with HTTP %d" % status_code + failed=True, msg="Grafana folders API answered with HTTP %d" % status_code ) def switch_organization(self, org_id): @@ -281,24 +305,32 @@ def get_version(self): return {"major": int(major), "minor": int(minor), "rev": int(rev)} raise GrafanaError("Failed to retrieve version from '%s'" % url) - def create_folder(self, title): + def create_folder(self, title, uid=None, parent_uid=None): url = "/api/folders" - folder = dict(title=title) + folder = dict(title=title, uid=uid, parentUid=parent_uid) response = self._send_request( url, data=folder, headers=self.headers, method="POST" ) return response - def get_folder(self, title): - url = "/api/search?type=dash-folder&query={title}".format(title=quote(title)) + def get_folder(self, title, uid=None, parent_uid=None): + url = "/api/folders%s" % ("?parentUid=%s" % parent_uid if parent_uid else "") response = self._send_request(url, headers=self.headers, method="GET") - for item in response: - if item.get("title") == to_text(title): - return item + if response: + if uid: + folders = [item for item in response if item.get("uid") == uid] + else: + folders = [ + item for item in response if item.get("title") == to_text(title) + ] + + if folders: + return folders[0] + return None def delete_folder(self, folder_uid): - url = "/api/folders/{folder_uid}".format(folder_uid=folder_uid) + url = "/api/folders/%s" % folder_uid response = self._send_request(url, headers=self.headers, method="DELETE") return response @@ -307,10 +339,12 @@ def main(): argument_spec = base.grafana_argument_spec() argument_spec.update( name=dict(type="str", aliases=["title"], required=True), - state=dict(type="str", default="present", choices=["present", "absent"]), - skip_version_check=dict(type="bool", default=False), org_id=dict(default=1, type="int"), org_name=dict(type="str"), + parent_uid=dict(type="str"), + skip_version_check=dict(type="bool", default=False), + state=dict(type="str", default="present", choices=["present", "absent"]), + uid=dict(type="str"), ) module = AnsibleModule( argument_spec=argument_spec, @@ -324,21 +358,23 @@ def main(): ) state = module.params["state"] title = module.params["name"] + parent_uid = module.params["parent_uid"] + uid = module.params["uid"] module.params["url"] = base.clean_url(module.params["url"]) grafana_iface = GrafanaFolderInterface(module) changed = False + + folder = grafana_iface.get_folder(title, uid, parent_uid) + if state == "present": - folder = grafana_iface.get_folder(title) if folder is None: - grafana_iface.create_folder(title) - folder = grafana_iface.get_folder(title) + grafana_iface.create_folder(title, uid, parent_uid) + folder = grafana_iface.get_folder(title, uid, parent_uid) changed = True - folder = grafana_iface.get_folder(title) module.exit_json(changed=changed, folder=folder) elif state == "absent": - folder = grafana_iface.get_folder(title) if folder is None: module.exit_json(changed=False, message="No folder found") result = grafana_iface.delete_folder(folder.get("uid")) diff --git a/roles/grafana/README.md b/roles/grafana/README.md index a6ce7507..5c76497c 100644 --- a/roles/grafana/README.md +++ b/roles/grafana/README.md @@ -73,10 +73,12 @@ Configure Grafana organizations, dashboards, folders, datasources, teams and use | zabbix_user | no | | [**grafana_folders**](https://docs.ansible.com/ansible/latest/collections/community/grafana/grafana_folder_module.html) | | name | yes | -| skip_version_check | no | -| state | no | | org_id | no | | org_name | no | +| parent_uid | no | +| skip_version_check | no | +| state | no | +| uid | no | | [**grafana_dashboards**](https://docs.ansible.com/ansible/latest/collections/community/grafana/grafana_dashboard_module.html) | | commit_message | no | | dashboard_id | no | diff --git a/roles/grafana/tasks/main.yml b/roles/grafana/tasks/main.yml index 170d9337..d0ddab40 100644 --- a/roles/grafana/tasks/main.yml +++ b/roles/grafana/tasks/main.yml @@ -277,10 +277,12 @@ - name: Manage folder # noqa: args[module] community.grafana.grafana_folder: name: "{{ folder.name }}" - skip_version_check: "{{ folder.skip_version_check | default(omit) }}" - state: "{{ folder.state | default(omit) }}" org_id: "{{ folder.org_id | default(omit) }}" org_name: "{{ folder.org_name | default(omit) }}" + parent_uid: "{{ folder.parent_uid | default(omit) }}" + skip_version_check: "{{ folder.skip_version_check | default(omit) }}" + state: "{{ folder.state | default(omit) }}" + uid: "{{ folder.uid | default(omit) }}" loop: "{{ grafana_folders }}" loop_control: {loop_var: folder} tags: folder diff --git a/tests/integration/targets/grafana_folder/tasks/create-delete.yml b/tests/integration/targets/grafana_folder/tasks/create-delete.yml index c818c2ef..e1ee756c 100644 --- a/tests/integration/targets/grafana_folder/tasks/create-delete.yml +++ b/tests/integration/targets/grafana_folder/tasks/create-delete.yml @@ -1,58 +1,52 @@ --- -- name: Create a Folder - community.grafana.grafana_folder: - url: "{{ grafana_url }}" - url_username: "{{ grafana_username }}" - url_password: "{{ grafana_password }}" - title: grafana_working_group - state: present - register: result +- module_defaults: + community.grafana.grafana_folder: + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + block: + - name: Create a Folder + community.grafana.grafana_folder: + title: grafana_working_group + state: present + register: result -- ansible.builtin.assert: - that: - - result.changed == true - - result.folder.title == 'grafana_working_group' - when: not ansible_check_mode + - ansible.builtin.assert: + that: + - result.changed == true + - result.folder.title == 'grafana_working_group' + when: not ansible_check_mode -- name: Test folder creation idempotency - community.grafana.grafana_folder: - url: "{{ grafana_url }}" - url_username: "{{ grafana_username }}" - url_password: "{{ grafana_password }}" - title: grafana_working_group - state: present - register: result + - name: Test folder creation idempotency + community.grafana.grafana_folder: + title: grafana_working_group + state: present + register: result -- ansible.builtin.assert: - that: - - result.changed == false - - result.folder.title == 'grafana_working_group' - when: not ansible_check_mode + - ansible.builtin.assert: + that: + - result.changed == false + - result.folder.title == 'grafana_working_group' + when: not ansible_check_mode -- name: Delete a Folder - community.grafana.grafana_folder: - url: "{{ grafana_url }}" - url_username: "{{ grafana_username }}" - url_password: "{{ grafana_password }}" - title: grafana_working_group - state: absent - register: result + - name: Delete a Folder + community.grafana.grafana_folder: + title: grafana_working_group + state: absent + register: result -- ansible.builtin.assert: - that: - - result.changed == true - when: not ansible_check_mode + - ansible.builtin.assert: + that: + - result.changed == true + when: not ansible_check_mode -- name: Test folder deletion idempotency - community.grafana.grafana_folder: - url: "{{ grafana_url }}" - url_username: "{{ grafana_username }}" - url_password: "{{ grafana_password }}" - title: grafana_working_group - state: absent - register: result + - name: Test folder deletion idempotency + community.grafana.grafana_folder: + title: grafana_working_group + state: absent + register: result -- ansible.builtin.assert: - that: - - result.changed == false - when: not ansible_check_mode + - ansible.builtin.assert: + that: + - result.changed == false + when: not ansible_check_mode diff --git a/tests/integration/targets/grafana_folder/tasks/main.yml b/tests/integration/targets/grafana_folder/tasks/main.yml index 0ac91f37..ca3bd168 100644 --- a/tests/integration/targets/grafana_folder/tasks/main.yml +++ b/tests/integration/targets/grafana_folder/tasks/main.yml @@ -1,6 +1,21 @@ --- -- name: Folder creation and deletion - ansible.builtin.include_tasks: create-delete.yml +- name: Include folder task files + ansible.builtin.include_tasks: "{{ item }}.yml" + loop: + - create-delete + - org -- name: Folder creation and deletion for organization - ansible.builtin.include_tasks: org.yml +- name: Check for support of API endpoint + register: result + ignore_errors: true + community.grafana.grafana_folder: + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + title: apitest + parent_uid: "parent" + state: absent + +- name: Include folder task file for subfolders + ansible.builtin.include_tasks: sub.yml + when: "result.msg | default('') != 'Subfolder API is available starting Grafana v11'" diff --git a/tests/integration/targets/grafana_folder/tasks/org.yml b/tests/integration/targets/grafana_folder/tasks/org.yml index f30d48c1..7a628914 100644 --- a/tests/integration/targets/grafana_folder/tasks/org.yml +++ b/tests/integration/targets/grafana_folder/tasks/org.yml @@ -1,7 +1,53 @@ --- - module_defaults: community.grafana.grafana_folder: - org_name: Main Org. + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + org_name: "Main Org." block: - - name: Folder creation and deletion - ansible.builtin.include_tasks: create-delete.yml + - name: Create a Folder + community.grafana.grafana_folder: + title: grafana_working_group + state: present + register: result + + - ansible.builtin.assert: + that: + - result.changed == true + - result.folder.title == 'grafana_working_group' + when: not ansible_check_mode + + - name: Test folder creation idempotency + community.grafana.grafana_folder: + title: grafana_working_group + state: present + register: result + + - ansible.builtin.assert: + that: + - result.changed == false + - result.folder.title == 'grafana_working_group' + when: not ansible_check_mode + + - name: Delete a Folder + community.grafana.grafana_folder: + title: grafana_working_group + state: absent + register: result + + - ansible.builtin.assert: + that: + - result.changed == true + when: not ansible_check_mode + + - name: Test folder deletion idempotency + community.grafana.grafana_folder: + title: grafana_working_group + state: absent + register: result + + - ansible.builtin.assert: + that: + - result.changed == false + when: not ansible_check_mode diff --git a/tests/integration/targets/grafana_folder/tasks/sub.yml b/tests/integration/targets/grafana_folder/tasks/sub.yml new file mode 100644 index 00000000..21adb236 --- /dev/null +++ b/tests/integration/targets/grafana_folder/tasks/sub.yml @@ -0,0 +1,113 @@ +--- +- module_defaults: + community.grafana.grafana_folder: + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + uid: "parent" + block: + - name: Create a parent Folder + community.grafana.grafana_folder: + title: grafana_working_group + state: present + register: result + + - ansible.builtin.assert: + that: + - result.changed == true + - result.folder.title == 'grafana_working_group' + - result.folder.uid == 'parent' + when: not ansible_check_mode + + - name: Test folder parent creation idempotency + community.grafana.grafana_folder: + title: grafana_working_group + state: present + register: result + + - ansible.builtin.assert: + that: + - result.changed == false + - result.folder.title == 'grafana_working_group' + - result.folder.uid == 'parent' + when: not ansible_check_mode + + - module_defaults: + community.grafana.grafana_folder: + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + uid: "sub" + parent_uid: "parent" + block: + - name: Create a sub Folder + community.grafana.grafana_folder: + title: grafana_working_group + state: present + register: result + + - ansible.builtin.assert: + that: + - result.changed == true + - result.folder.title == 'grafana_working_group' + - result.folder.uid == 'sub' + - result.folder.parentUid == 'parent' + when: not ansible_check_mode + + - name: Test sub folder creation idempotency + community.grafana.grafana_folder: + title: grafana_working_group + state: present + register: result + + - ansible.builtin.assert: + that: + - result.changed == false + - result.folder.title == 'grafana_working_group' + - result.folder.uid == 'sub' + - result.folder.parentUid == 'parent' + when: not ansible_check_mode + + - name: Delete sub Folder + community.grafana.grafana_folder: + title: grafana_working_group + state: absent + register: result + + - ansible.builtin.assert: + that: + - result.changed == true + when: not ansible_check_mode + + - name: Test sub folder deletion idempotency + community.grafana.grafana_folder: + title: grafana_working_group + state: absent + register: result + + - ansible.builtin.assert: + that: + - result.changed == false + when: not ansible_check_mode + + - name: Delete a Folder + community.grafana.grafana_folder: + title: grafana_working_group + state: absent + register: result + + - ansible.builtin.assert: + that: + - result.changed == true + when: not ansible_check_mode + + - name: Test folder deletion idempotency + community.grafana.grafana_folder: + title: grafana_working_group + state: absent + register: result + + - ansible.builtin.assert: + that: + - result.changed == false + when: not ansible_check_mode