Merge ~mustafakemalgilor/charm-grafana:bug/1983814 into charm-grafana:master

Proposed by Mustafa Kemal Gilor
Status: Merged
Approved by: Giuseppe Petralia
Approved revision: 705dbcf4c964638f84e4d03f1575e7d5dd621dac
Merged at revision: 22ccaf7f9b76949809863ba4dd58d0188365410b
Proposed branch: ~mustafakemalgilor/charm-grafana:bug/1983814
Merge into: charm-grafana:master
Diff against target: 357 lines (+259/-49)
6 files modified
dev/null (+0/-46)
src/files/dashboards_backup.py (+86/-0)
src/reactive/grafana.py (+2/-2)
src/templates/juju-dashboards-backup.j2 (+1/-1)
src/tests/unit/test_dashboards_backup.py (+169/-0)
src/tox.ini (+1/-0)
Reviewer Review Type Date Requested Status
Giuseppe Petralia Approve
Robert Gildein Approve
BootStack Reviewers Pending
Review via email: mp+428028@code.launchpad.net

Commit message

dashboards_backup script compatibility update for Grafana version >=8:

The dashboards_backup script was using Slug API for retrieving the
dashboards, which is deprecated in Grafana version '5.4.0' in favor
of UID API, and removed in version '8.0.0'. Due to that, the backup
script was failing to generate backups in version 8.0.0 and above.

This fix introduces an API switch mechanism between Slug API and
UID API, which allows the script to behave as before for Grafana
(< 8.0.0) and use UID API for Grafana version (>= 8.0.0).

Fixes LP issue #1983814

Description of the change

dashboards_backup script compatibility update for Grafana version >=8:

The dashboards_backup script was using Slug API for retrieving the
dashboards, which is deprecated in Grafana version '5.4.0' in favor
of UID API, and removed in version '8.0.0'. Due to that, the backup
script was failing to generate backups in version 8.0.0 and above.

This fix introduces an API switch mechanism between Slug API and
UID API, which allows the script to behave as before for Grafana
(< 8.0.0) and use UID API for Grafana version (>= 8.0.0).

Fixes LP issue #1983814

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
Robert Gildein (rgildein) wrote :

Can we add at least some unit test for these changes.

review: Needs Fixing
Revision history for this message
Mustafa Kemal Gilor (mustafakemalgilor) wrote :

> Can we add at least some unit test for these changes.

Well, the original dashboards_backup code does not have any unit tests. I am implementing a bugfix, so in fact, I'll still not be writing any unit tests for this change if the code had them already. It is not my intention to implement unit tests from scratch. Don't get me wrong I *love* unit tests and am a strong believer in testing, but I don't necessarily believe I should take the burden in the scope of a simple bugfix... but anyways. I added unit tests for dashboards_backup.

Revision history for this message
Robert Gildein (rgildein) wrote :

Of course, if there are no tests and the change is negligible, it
is more acceptable that no tests are added. But in this case, a new
`get_backup_filename` function was added, so I don't see any reason
why unit tests should not be made for it. Also a change in the main
function was not negligible.

If every bugfix will add new functions without tests, then of course
we will never have any.

Thanks.

I tried to run the unit tests but they failed.
https://pastebin.ubuntu.com/p/Xgq98C88JP/

review: Needs Fixing
Revision history for this message
Mustafa Kemal Gilor (mustafakemalgilor) wrote :

> Of course, if there are no tests and the change is negligible, it
> is more acceptable that no tests are added. But in this case, a new
> `get_backup_filename` function was added, so I don't see any reason
> why unit tests should not be made for it. Also a change in the main
> function was not negligible.
>
> If every bugfix will add new functions without tests, then of course
> we will never have any.
>
> Thanks.
>
> I tried to run the unit tests but they failed.
> https://pastebin.ubuntu.com/p/Xgq98C88JP/

I've fixed the failing unit test, should be good to go. Also deployed & tested on queens/bionic with both snap & apt install methods; works as intended.

Revision history for this message
Mustafa Kemal Gilor (mustafakemalgilor) wrote :

Bump; still waiting for review & merge.

Revision history for this message
Robert Gildein (rgildein) wrote :

LGTM

review: Approve
Revision history for this message
Mustafa Kemal Gilor (mustafakemalgilor) wrote :
Revision history for this message
Giuseppe Petralia (peppepetra) wrote :

LGTM, thanks!

review: Approve
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision 22ccaf7f9b76949809863ba4dd58d0188365410b

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/files/dashboards_backup b/src/files/dashboards_backup
2deleted file mode 100755
3index 88c6310..0000000
4--- a/src/files/dashboards_backup
5+++ /dev/null
6@@ -1,46 +0,0 @@
7-#!/usr/bin/python3
8-
9-import os
10-import argparse
11-import requests
12-import string
13-import datetime
14-import json
15-
16-
17-def get_backup_filename(directory, org_name, uri):
18- valid_chars = '-_(){}{}'.format(string.ascii_letters, string.digits)
19- dashboard = uri.split('/')[1]
20- filename = ''.join(c if c in valid_chars else '_' for c in dashboard)
21- filename += '.json.' + datetime.datetime.utcnow().strftime('%Y%m%d_%H%M%S')
22- org_name = ''.join(c if c in valid_chars else '_' for c in org_name)
23- return os.path.join(directory, org_name, filename)
24-
25-
26-def main(args):
27- base_url = '{scheme}://localhost:{port}/api/'.format(scheme=args.scheme, port=args.port)
28- for key in args.api_keys:
29- headers = {'Authorization': 'Bearer {}'.format(key)}
30- org_name = requests.get(base_url+'org', headers=headers).json()['name']
31- r = requests.get(base_url+'search', headers=headers).json()
32- uris = [i['uri'] for i in r]
33- for d in uris:
34- backup_file = get_backup_filename(args.directory, org_name, d)
35- if not os.path.isdir(os.path.dirname(backup_file)):
36- os.mkdir(os.path.dirname(backup_file))
37- dashboard = requests.get(base_url+'dashboards/'+d, headers=headers).json()
38- with open(backup_file, 'w') as f:
39- f.write(json.dumps(dashboard))
40-
41-
42-if __name__ == '__main__':
43- parser = argparse.ArgumentParser(description='Back up grafana dashboards to disk')
44- parser.add_argument('-d', '--directory', help='Directory where to store backups',
45- default='/srv/backups')
46- parser.add_argument('-p', '--port', help='Port to access grafana API', default='3000')
47- parser.add_argument('-s', '--scheme',
48- help='Scheme to use to access grafana API e.g. http or https',
49- default='http')
50- parser.add_argument('api_keys', help='List of API keys to use for backups', nargs='+')
51- args = parser.parse_args()
52- main(args)
53diff --git a/src/files/dashboards_backup.py b/src/files/dashboards_backup.py
54new file mode 100755
55index 0000000..a1abe64
56--- /dev/null
57+++ b/src/files/dashboards_backup.py
58@@ -0,0 +1,86 @@
59+#!/usr/bin/python3
60+"""Grafana dashboards backup script."""
61+
62+import argparse
63+import datetime
64+import json
65+import os
66+import string
67+
68+import requests
69+
70+
71+def get_backup_filename(directory, org_name, uri):
72+ """Make a backup file name for dashboard."""
73+ valid_chars = "-_(){}{}".format(string.ascii_letters, string.digits)
74+ dashboard = uri.split("/")[1]
75+ filename = "".join(c if c in valid_chars else "_" for c in dashboard)
76+ filename += ".json." + datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S")
77+ org_name = "".join(c if c in valid_chars else "_" for c in org_name)
78+ return os.path.join(directory, org_name, filename)
79+
80+
81+def should_use_uid_api(base_url, session):
82+ """Determine the API to be used to retrieve the dashboard data."""
83+ # Slug API is deprecated in 5.4.0 and removed in 8.0.0
84+ # Switch to UID API if we're running in 8.0.0 and above
85+ try:
86+ fe_settings = session.get(base_url + "frontend/settings").json()
87+ grafana_version_str = fe_settings["buildInfo"]["version"]
88+ grafana_major_version = int(grafana_version_str.split(".")[0])
89+ return grafana_major_version >= 8
90+ except Exception as error:
91+ print("Failed retrieving/parsing Grafana version string: " + str(error))
92+ return False
93+
94+
95+def main(args):
96+ """Backup entry point."""
97+ base_url = "{scheme}://localhost:{port}/api/".format(
98+ scheme=args.scheme, port=args.port
99+ )
100+
101+ for key in args.api_keys:
102+ sess = requests.Session()
103+ sess.headers.update({"Authorization": "Bearer {}".format(key)})
104+
105+ org_name = sess.get(base_url + "org").json()["name"]
106+ search_result = sess.get(base_url + "search").json()
107+
108+ for elem in search_result:
109+ dashboard_url = base_url + "dashboards/" + elem["uri"]
110+
111+ if should_use_uid_api(base_url, sess):
112+ dashboard_url = base_url + "dashboards/uid/" + elem["uid"]
113+ pass
114+
115+ backup_file = get_backup_filename(args.directory, org_name, elem["uri"])
116+ if not os.path.isdir(os.path.dirname(backup_file)):
117+ os.mkdir(os.path.dirname(backup_file))
118+ dashboard = sess.get(dashboard_url).json()
119+ with open(backup_file, "w") as f:
120+ f.write(json.dumps(dashboard))
121+
122+
123+if __name__ == "__main__":
124+ parser = argparse.ArgumentParser(description="Back up grafana dashboards to disk")
125+ parser.add_argument(
126+ "-d",
127+ "--directory",
128+ help="Directory where to store backups",
129+ default="/srv/backups",
130+ )
131+ parser.add_argument(
132+ "-p", "--port", help="Port to access grafana API", default="3000"
133+ )
134+ parser.add_argument(
135+ "-s",
136+ "--scheme",
137+ help="Scheme to use to access grafana API e.g. http or https",
138+ default="http",
139+ )
140+ parser.add_argument(
141+ "api_keys", help="List of API keys to use for backups", nargs="+"
142+ )
143+ args = parser.parse_args()
144+ main(args)
145diff --git a/src/reactive/grafana.py b/src/reactive/grafana.py
146index dd70008..c9e6f28 100644
147--- a/src/reactive/grafana.py
148+++ b/src/reactive/grafana.py
149@@ -600,8 +600,8 @@ def setup_backup_shedule():
150 hookenv.status_set("maintenance", "Configuring grafana dashboard backup")
151 hookenv.log("Setting up dashboards backup job...")
152 host.rsync(
153- "files/dashboards_backup",
154- "/usr/local/bin/dashboards_backup",
155+ "files/dashboards_backup.py",
156+ "/usr/local/bin/dashboards_backup.py",
157 )
158 host.mkdir(config.get("dashboards_backup_dir"))
159 settings = {
160diff --git a/src/templates/juju-dashboards-backup.j2 b/src/templates/juju-dashboards-backup.j2
161index 993c48a..86fc672 100644
162--- a/src/templates/juju-dashboards-backup.j2
163+++ b/src/templates/juju-dashboards-backup.j2
164@@ -1,5 +1,5 @@
165 #
166 # Please do not edit. Juju will overwrite it.
167 #
168-{{ schedule }} root /usr/local/bin/dashboards_backup -d {{ directory }} -p {{ port }} -s {{ scheme }} {{ backup_keys }}
169+{{ schedule }} root /usr/local/bin/dashboards_backup.py -d {{ directory }} -p {{ port }} -s {{ scheme }} {{ backup_keys }}
170
171diff --git a/src/tests/unit/test_dashboards_backup.py b/src/tests/unit/test_dashboards_backup.py
172new file mode 100644
173index 0000000..3186d82
174--- /dev/null
175+++ b/src/tests/unit/test_dashboards_backup.py
176@@ -0,0 +1,169 @@
177+"""Unit test for dashboard_backup script."""
178+
179+import random
180+import string
181+import unittest
182+from unittest import mock
183+
184+from files.dashboards_backup import get_backup_filename
185+from files.dashboards_backup import main as invoke_backup
186+from files.dashboards_backup import should_use_uid_api
187+
188+
189+class MockResponse:
190+ """Mock requests response data."""
191+
192+ def __init__(self, json_data, status_code):
193+ """init."""
194+ self.json_data = json_data
195+ self.status_code = status_code
196+
197+ def json(self):
198+ """json."""
199+ return self.json_data
200+
201+
202+# This method will be used by the mock to replace requests.get
203+
204+
205+def mock_requests_get_pre_v8(*args, **kwargs):
206+ """Mock API responses for pre-v8 Grafana."""
207+ print("pre:" + args[0])
208+ if "org" in args[0]:
209+ return MockResponse({"name": "MainOrg"}, 200)
210+ elif "search" in args[0]:
211+ return MockResponse([{"uri": "dashboards/testdash", "uid": "testuid"}], 200)
212+ elif "frontend/settings" in args[0]:
213+ return MockResponse({"buildInfo": {"version": "7.9.9"}}, 200)
214+ elif "dashboards/" in args[0]:
215+ return MockResponse({"dashboard": "info"}, 200)
216+ return MockResponse(None, 404)
217+
218+
219+def mock_requests_get_post_v8(*args, **kwargs):
220+ """Mock API responses for post-v8 Grafana."""
221+ print("post:" + args[0])
222+ if "frontend/settings" in args[0]:
223+ return MockResponse({"buildInfo": {"version": "8.0.0"}}, 200)
224+ elif "dashboards/uid/" in args[0]:
225+ return MockResponse({"dashboard": "info-uid"}, 200)
226+
227+ return mock_requests_get_pre_v8(*args, **kwargs)
228+
229+
230+def mock_requests_get_invalid_version(*args, **kwargs):
231+ """Mock for returning an invalid version value."""
232+ if "frontend/settings" in args[0]:
233+ return MockResponse({"buildInfo": {"version": "invalid"}}, 200)
234+ return mock_requests_get_pre_v8(*args, **kwargs)
235+
236+
237+class GrafanaDashboardBackupTestCase(unittest.TestCase):
238+ """Unit tests class for Dashboard Backup."""
239+
240+ def test_verify_backup_filename_sanity(self):
241+ """Verify backup filename is sanitized properly."""
242+ # The backup filename should be in form of
243+ # directory/org_name/filename where all non-numeric,
244+ # non-ascii characters replaced with an underscore.,
245+ exceptions = ["_", "-", "(", ")"]
246+ non_allowed_chars = "".join(
247+ filter(
248+ lambda item: item not in exceptions,
249+ (string.punctuation + string.whitespace),
250+ )
251+ )
252+ print(non_allowed_chars)
253+ dir = "rootdir"
254+ org = "default{}rg".format(random.choice(non_allowed_chars))
255+ uri = "dashboards/da{}hboardslug".format(random.choice(non_allowed_chars))
256+ result = get_backup_filename(dir, org, uri)
257+ # Ensure all non-allowed chars are replaced
258+ self.assertFalse(all(c in non_allowed_chars for c in result))
259+ # Verify the count of replaced characters. 2(+1), +1 is for date suffix
260+ self.assertEqual(result.count("_"), 3)
261+
262+ @mock.patch("files.dashboards_backup.requests.Session")
263+ def test_should_use_uid_api_false(self, mock_session):
264+ """Test should_use_uid_api with (<8.0.0) version number."""
265+ mock_session.get.return_value = MockResponse(
266+ {"buildInfo": {"version": "7.9.9"}}, 200
267+ )
268+ self.assertFalse(should_use_uid_api("http://test/a", mock_session))
269+
270+ @mock.patch("files.dashboards_backup.requests.Session")
271+ def test_should_use_uid_api_true(self, mock_session):
272+ """Test should_use_uid_api with (>=8.0.0) version number."""
273+ mock_session.get.return_value = MockResponse(
274+ {"buildInfo": {"version": "8.0.0"}}, 200
275+ )
276+ self.assertTrue(should_use_uid_api("http://test/a", mock_session))
277+
278+ @mock.patch(
279+ "files.dashboards_backup.requests.Session.get",
280+ side_effect=mock_requests_get_pre_v8,
281+ )
282+ @mock.patch("__main__.__builtins__.open", new_callable=mock.mock_open)
283+ @mock.patch("files.dashboards_backup.os")
284+ def test_verify_backup_slug_api(self, mock_os, mock_open, mock_get):
285+ """Test main() against mock pre-v8 Grafana API."""
286+
287+ class DummyArgs(object):
288+ pass
289+
290+ handle = mock_open()
291+ args = DummyArgs()
292+ setattr(args, "scheme", "http")
293+ setattr(args, "port", "3000")
294+ setattr(args, "api_keys", ["dummy-key-1"])
295+ setattr(args, "directory", "backupdir")
296+ mock_os.path.isdir.return_value = False
297+ invoke_backup(args)
298+ mock_os.mkdir.assert_called_once()
299+ handle.write.assert_called_once_with(str('{"dashboard": "info"}'))
300+
301+ @mock.patch(
302+ "files.dashboards_backup.requests.Session.get",
303+ side_effect=mock_requests_get_post_v8,
304+ )
305+ @mock.patch("__main__.__builtins__.open", new_callable=mock.mock_open)
306+ @mock.patch("files.dashboards_backup.os")
307+ def test_verify_backup_uid_api(self, mock_os, mock_open, mock_get):
308+ """Test main() against mock post-v8 Grafana API."""
309+
310+ class DummyArgs(object):
311+ pass
312+
313+ handle = mock_open()
314+ args = DummyArgs()
315+ setattr(args, "scheme", "http")
316+ setattr(args, "port", "3000")
317+ setattr(args, "api_keys", ["dummy-key-1"])
318+ setattr(args, "directory", "backupdir")
319+ mock_os.path.isdir.return_value = False
320+ invoke_backup(args)
321+ mock_os.mkdir.assert_called_once()
322+ handle.write.assert_called_once_with(str('{"dashboard": "info-uid"}'))
323+
324+ @mock.patch(
325+ "files.dashboards_backup.requests.Session.get",
326+ side_effect=mock_requests_get_invalid_version,
327+ )
328+ @mock.patch("__main__.__builtins__.open", new_callable=mock.mock_open)
329+ @mock.patch("files.dashboards_backup.os")
330+ def test_verify_backup_invalid_version(self, mock_os, mock_open, mock_get):
331+ """Test main() against invalid Grafana version."""
332+
333+ class DummyArgs(object):
334+ pass
335+
336+ handle = mock_open()
337+ args = DummyArgs()
338+ setattr(args, "scheme", "http")
339+ setattr(args, "port", "3000")
340+ setattr(args, "api_keys", ["dummy-key-1"])
341+ setattr(args, "directory", "backupdir")
342+ mock_os.path.isdir.return_value = False
343+ invoke_backup(args)
344+ mock_os.mkdir.assert_called_once()
345+ handle.write.assert_called_once_with(str('{"dashboard": "info"}'))
346diff --git a/src/tox.ini b/src/tox.ini
347index 8bdf81e..2ef2d7f 100644
348--- a/src/tox.ini
349+++ b/src/tox.ini
350@@ -70,6 +70,7 @@ commands =
351 --cov=actions \
352 --cov=hooks \
353 --cov=src \
354+ --cov=files \
355 --cov-report=term \
356 --cov-report=annotate:report/annotated \
357 --cov-report=html:report/html

Subscribers

People subscribed via source and target branches

to all changes: