Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(grafana_folder): support sub folders #381

Merged
merged 24 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b22f3d1
refactor: folder finding loop
Nemental Jun 26, 2024
d871cd5
chore: add uid and parent_uid arg
Nemental Jun 26, 2024
009530d
fix: check if multiple folder found by name
Nemental Jun 26, 2024
0d19c64
feat: change get folder api and use parent_uid if defined and check a…
Nemental Jun 26, 2024
2150a4e
feat: get folders by uid and parent_uid if defined
Nemental Jun 26, 2024
a4fc0ef
chore: simplify
Nemental Jun 26, 2024
c1b14cd
feat: create folder with uid and parent_uid
Nemental Jun 26, 2024
8181820
docs: parent_uid and uid
Nemental Jun 26, 2024
258c3a9
docs: module return values
Nemental Jun 27, 2024
13c3da3
test: use module defaults
Nemental Jun 27, 2024
1b99cc6
test: add sub folder tests
Nemental Jun 27, 2024
aaa9e64
test: add sub folder tests to main
Nemental Jun 27, 2024
2d1780d
test: fix org name test
Nemental Jun 27, 2024
6a1c045
test: sub folder tests module defaults
Nemental Jun 27, 2024
560f146
docs: changelog fragment
Nemental Jun 27, 2024
b5541f8
feat: role uid and parent_uid
Nemental Jun 27, 2024
01346b1
docs: role new subfolder parameters
Nemental Jun 27, 2024
024730e
fix: check for api support
Nemental Jun 28, 2024
6203711
test: separate sub folder test file
Nemental Jun 28, 2024
c2b669d
docs: version notice for parent_uid arg
Nemental Jun 28, 2024
c13d9f4
docs: changelog fragment format fix
Nemental Jun 28, 2024
54e1e4b
chore: lower folder
Nemental Jun 28, 2024
0bfa32e
Merge branch 'main' into feat/sub-folders
Nemental Jul 2, 2024
6af16e2
chore: simplify if statement
Nemental Jul 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelogs/fragments/381-sub-folders.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
minor_changes:
- Manage subfolders for `grafana_folder` and specify uid
92 changes: 64 additions & 28 deletions plugins/modules/grafana_folder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -174,14 +189,19 @@
type: int
sample:
- 1
parentUid:
description: The parent folders uid
returned: always as subfolder
type: str
sample:
- "76HjcBH2"
"""

import json

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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 "")
Nemental marked this conversation as resolved.
Show resolved Hide resolved
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]
Nemental marked this conversation as resolved.
Show resolved Hide resolved

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

Expand All @@ -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,
Expand All @@ -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"))
Expand Down
6 changes: 4 additions & 2 deletions roles/grafana/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions roles/grafana/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 44 additions & 50 deletions tests/integration/targets/grafana_folder/tasks/create-delete.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 19 additions & 4 deletions tests/integration/targets/grafana_folder/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -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'"
Loading
Loading