Merge ~cjohnston/charm-grafana:lp1877796-ldap-integration into charm-grafana:master

Proposed by Chris Johnston
Status: Merged
Approved by: James Troup
Approved revision: 366e4235fa718b38a59e0712209b90668c93ab1f
Merged at revision: 3ec06b8337777d566e34552715c097dab91b4ebc
Proposed branch: ~cjohnston/charm-grafana:lp1877796-ldap-integration
Merge into: charm-grafana:master
Diff against target: 1070 lines (+637/-41)
18 files modified
src/README.md (+33/-0)
src/actions/do-upgrade (+5/-1)
src/config.yaml (+59/-0)
src/lib/charms/layer/grafana.py (+117/-9)
src/reactive/grafana.py (+119/-7)
src/templates/grafana.ini.j2 (+5/-0)
src/templates/ldap.toml.j2 (+58/-0)
src/tests/functional/tests/bundles/base.yaml (+4/-1)
src/tests/functional/tests/bundles/overlays/focal-self-signed-cert.yaml.j2 (+2/-0)
src/tests/functional/tests/bundles/overlays/focal-snap-self-signed-cert.yaml.j2 (+2/-0)
src/tests/functional/tests/bundles/overlays/focal-snap-tls.yaml.j2 (+2/-0)
src/tests/functional/tests/bundles/overlays/focal-snap.yaml.j2 (+3/-1)
src/tests/functional/tests/bundles/overlays/focal-tls.yaml.j2 (+2/-0)
src/tests/functional/tests/bundles/overlays/focal.yaml.j2 (+3/-1)
src/tests/functional/tests/bundles/overlays/xenial-tls.yaml.j2 (+2/-0)
src/tests/functional/tests/bundles/overlays/xenial.yaml.j2 (+2/-0)
src/tests/functional/tests/test_grafana.py (+218/-21)
src/tests/unit/requirements.txt (+1/-0)
Reviewer Review Type Date Requested Status
🤖 prod-jenkaas-bootstack (community) continuous-integration Needs Fixing
James Troup (community) Needs Fixing
BootStack Reviewers Pending
Review via email: mp+415017@code.launchpad.net

Commit message

Add support for LDAP authentication.

Description of the change

Add support for LDAP authentication.

This required some refactoring of other pieces that weren't working properly in order to make them work properly as well as the new code working properly.

To post a comment you must log in.
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

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
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
James Troup (elmo) wrote :

See inline comment

review: Needs Fixing
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
James Troup (elmo) wrote :

Thanks Chris for working on this - it looks great and is awesome functionality to have. I've reviewed it and I'm happy, my only comment is that you have merge conflicts in the tox.ini - if you fix that, it'll be +1 from me.

review: Needs Fixing
Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

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

Change successfully merged at revision 3ec06b8337777d566e34552715c097dab91b4ebc

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Needs Fixing (continuous-integration)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/README.md b/src/README.md
2index 2b3e49c..4e2d009 100644
3--- a/src/README.md
4+++ b/src/README.md
5@@ -129,6 +129,39 @@ If not all URL paths are behind the reverse proxy auth,
6 and `anonymous=true` is set, those paths will be accessible (view only) to
7 non-authenticated users.
8
9+## LDAP auth
10+
11+Grafana can be deployed to utilize LDAP as an authentication mechanism. To
12+enable LDAP authentication, all `ldap_*` config options
13+(except `ldap_config_flags`) must be set.
14+
15+Additional config settings as defined in the upstream
16+[LDAP Authentication documentation](https://grafana.com/docs/grafana/latest/auth/ldap/)
17+may be set using the `ldap_config_flags` option.
18+
19+Group mappings for permissions should be defined with the following:
20+
21+```
22+ldap_config_flags: "{
23+ group_mapping_admin: 'cn=grafanaadmins,dc=test,dc=com',
24+ group_mapping_editor: 'cn=grafanaeditors,dc=test,dc=com',
25+ group_mapping_viewer: 'cn=grafanaviewers,dc=test,dc=com',
26+}"
27+```
28+
29+Server attributes should be defined as `attribute_<attribute>`. Example:
30+
31+
32+```
33+ldap_config_flags: "{
34+ "attribute_member_of: memberOf,"
35+ "attribute_email: email,"
36+ "attribute_name: givenname,"
37+ "attribute_surname: sn,"
38+ "attribute_username: cn,"
39+}"
40+```
41+
42 ## Development
43
44 After modifying code, you must assemble the charm:
45diff --git a/src/actions/do-upgrade b/src/actions/do-upgrade
46index edb0eb9..662fba8 100755
47--- a/src/actions/do-upgrade
48+++ b/src/actions/do-upgrade
49@@ -11,7 +11,10 @@ from charmhelpers.core.hookenv import (
50 status_set,
51 )
52 from charms.layer import snap
53-from charms.reactive import remove_state
54+from charms.reactive import (
55+ remove_state,
56+ clear_flag,
57+)
58
59
60 sys.path.append("lib")
61@@ -35,6 +38,7 @@ def snap_upgrade(channel):
62 )
63 function_set({"result": "Upgraded, channel {}".format(channel)})
64 status_set("active", "Ready")
65+ clear_flag("snap_channel.change.upgrade")
66 remove_state("grafana.change-block")
67 elif result == ChangeStatus.UNCHANGED:
68 function_fail("Nothing to upgrade")
69diff --git a/src/config.yaml b/src/config.yaml
70index ed84bc2..3198705 100644
71--- a/src/config.yaml
72+++ b/src/config.yaml
73@@ -85,6 +85,65 @@ options:
74 default: False
75 type: boolean
76 description: Whether to enable default auth.proxy config, defaults to False.
77+ ldap_auth:
78+ default: False
79+ type: boolean
80+ description: Whether to enable LDAP authentication, defaults to False.
81+ ldap_server:
82+ type: string
83+ default: ""
84+ description: |
85+ LDAP server URL for Grafana LDAP identity backend.
86+
87+ Examples:
88+ ldap://10.10.10.10/
89+ ldaps://10.10.10.10/
90+ ldap://example.com:389/
91+ ldap://active-directory-host.com:3268/
92+ ldaps://active-directory-host.com:3269/
93+
94+ An ldap:// URL will result in mandatory StartTLS usage if the
95+ charm's ldap_server_ca option has been specified.
96+ ldap_server_ca:
97+ type: string
98+ default: !!null ""
99+ description: |
100+ This option controls which certificate (or a chain) will be used to connect
101+ to the LDAP server over TLS. Certificate contents should either be used
102+ directly or included via include-file://
103+ ldap_user:
104+ type: string
105+ default: ""
106+ description: |
107+ Username (Distinguished Name) used to bind to LDAP identity server.
108+ .
109+ Example: cn=admin,dc=test,dc=com
110+ ldap_password:
111+ type: string
112+ default: ""
113+ description: Password of the LDAP identity server.
114+ ldap_base_dn:
115+ type: string
116+ default: ""
117+ description: Comma separated list of base dns to be used by Grafana.
118+ ldap_config_flags:
119+ type: string
120+ default: ""
121+ description: |
122+ Additional LDAP configuration options.
123+ For simple configurations use a comma separated string of key=value pairs.
124+ "ssl_skip_verify=false, group_search_filter_user_attribute='uid'"
125+ For more complex configurations use a json like string with double quotes
126+ and braces around all the options and single quotes around complex values.
127+ "{ssl_skip_verify: false,
128+ group_mapping_admin: 'cn=grafanaadmins,dc=test,dc=com',
129+ group_mapping_editor: 'cn=grafanaeditors,dc=test,dc=com',
130+ group_mapping_viewer: 'cn=grafanaviewers,dc=test,dc=com'}"
131+
132+ See the README for more details.
133+
134+ Note: The explicitly defined ldap_* charm config options are required
135+ and cannot be set via `ldap_config_flags`.
136 datasources:
137 default: ""
138 type: string
139diff --git a/src/lib/charms/layer/grafana.py b/src/lib/charms/layer/grafana.py
140index 10135be..9c0fb16 100644
141--- a/src/lib/charms/layer/grafana.py
142+++ b/src/lib/charms/layer/grafana.py
143@@ -3,11 +3,14 @@
144 import base64
145 import enum
146 import json
147+import os
148 import re
149 import subprocess
150 import tempfile
151+from urllib.parse import urlparse
152
153-from charmhelpers.core import unitdata
154+from charmhelpers.contrib.openstack.utils import config_flags_parser
155+from charmhelpers.core import host, unitdata
156 from charmhelpers.core.hookenv import (
157 cached,
158 config,
159@@ -15,9 +18,15 @@ from charmhelpers.core.hookenv import (
160 model_name,
161 status_set,
162 )
163+from charmhelpers.core.templating import render
164+
165
166 from charms.layer import snap
167-from charms.reactive.flags import is_flag_set
168+from charms.reactive.flags import (
169+ clear_flag,
170+ is_flag_set,
171+ set_flag,
172+)
173
174 import requests
175
176@@ -28,6 +37,7 @@ import requests
177 # https://git.launchpad.net/ubuntu/+source/python-certifi/tree/debian/patches/0001-Use-Debian-provided-etc-ssl-certs-ca-certificates.cr.patch
178 SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt"
179 CA_CERT_PATH = "/var/snap/grafana/common/ssl/ca-certificates.crt"
180+LDAP_TOML_TMPL = "ldap.toml.j2"
181
182
183 class ChangeStatus(enum.Enum):
184@@ -311,13 +321,13 @@ def check_snap_channel(snap_name, channel):
185 'The "latest" track is not supported; revert the channel config '
186 "change to unblock"
187 )
188- status_set("blocked", msg)
189 log(msg)
190+ set_flag("snap_channel.change.latest")
191 return ChangeStatus.LATEST
192 if new_ver == -1:
193 msg = "Invalid snap channel: {}".format(channel)
194- status_set("blocked", msg)
195 log(msg)
196+ set_flag("snap_channel.change.invald")
197 return ChangeStatus.INVALID
198 if installed_ver == new_ver and not is_refresh_available:
199 status_set("active", "Ready")
200@@ -331,17 +341,15 @@ def check_snap_channel(snap_name, channel):
201 status_set("active", "Refresh complete")
202 return ChangeStatus.UNCHANGED
203 if installed_ver < new_ver:
204- msg = (
205- "PACKAGE UPGRADE REQUIRES MANUAL INTERVENTION: see {} for more info"
206- ).format(VERSION_CHANGE_URL)
207- status_set("blocked", msg)
208+ msg = "PACKAGE UPGRADE REQUIRES MANUAL INTERVENTION"
209 log(msg)
210+ set_flag("snap_channel.change.upgrade")
211 return ChangeStatus.UPGRADE
212 msg = (
213 "PACKAGE DOWNGRADES ARE NOT SUPPORTED BY THIS CHARM - to clear "
214 "this message, set config 'snap_channel' to '{}/stable'"
215 ).format(installed_ver)
216- status_set("blocked", msg)
217+ set_flag("snap_channel.change.downgrade")
218 log(msg)
219 return ChangeStatus.DOWNGRADE
220
221@@ -379,3 +387,103 @@ def get_instance_name():
222 return config("instance_name")
223 else:
224 return "127.0.0.1"
225+
226+
227+def get_ldap_configs_changed(config):
228+ """Check to see if any LDAP config flags have changed. If yes, configure LDAP."""
229+ ldap_cfgs = [k for k in config if k.startswith("ldap_")]
230+ for ldap_cfg in ldap_cfgs:
231+ if config.changed(ldap_cfg):
232+ clear_flag("grafana.ldap.configured")
233+
234+
235+def check_ldap_config_valid(config):
236+ """Determine whether sufficient configuration has been provided."""
237+ required_config = {
238+ "ldap_server": config["ldap_server"],
239+ "ldap_user": config["ldap_user"],
240+ "ldap_password": config["ldap_password"],
241+ "ldap_base_dn": config["ldap_base_dn"],
242+ }
243+ return all(required_config.values())
244+
245+
246+def render_ldap_ca(ldap_server_ca, ldap_ca_path):
247+ """Render crt file for LDAP server."""
248+ host.write_file(
249+ ldap_ca_path,
250+ ldap_server_ca,
251+ owner="root",
252+ group="root",
253+ perms=0o644,
254+ )
255+
256+
257+def render_ldap_config(config, ldap_toml_path, ldap_ca_path=None):
258+ """Validate and render the ldap config file."""
259+ clear_flag("ldap.config.incomplete")
260+ log("Setting up LDAP auth...")
261+ parsed_url = urlparse(config["ldap_server"])
262+ ldap_options, group_mappings, server_attributes = parse_ldap_config_flags(
263+ config["ldap_config_flags"]
264+ )
265+ settings = {
266+ "config": config,
267+ "host": parsed_url.hostname,
268+ "port": parsed_url.port if parsed_url.port else "389",
269+ "ldap_options": ldap_options,
270+ "group_mappings": group_mappings,
271+ "server_attributes": server_attributes,
272+ }
273+ if parsed_url.scheme == "ldaps" or ldap_ca_path:
274+ settings["ssl"] = "true"
275+ if ldap_ca_path:
276+ render_ldap_ca(config["ldap_server_ca"], ldap_ca_path)
277+ settings["ldap_ca_path"] = ldap_ca_path
278+ render(
279+ source=LDAP_TOML_TMPL,
280+ target=ldap_toml_path,
281+ context=settings,
282+ owner="grafana",
283+ group="grafana",
284+ perms=0o600,
285+ )
286+ status_set("active", "Completed configuring LDAP authentication")
287+ clear_flag("grafana.configured")
288+
289+
290+def parse_ldap_config_flags(ldap_config_flags):
291+ """Parse through the ldap_config_flags."""
292+ ldap_options = {}
293+ group_mappings = {}
294+ server_attributes = {}
295+ if ldap_config_flags:
296+ parsed_config_flags = config_flags_parser(ldap_config_flags)
297+ for k, v in parsed_config_flags.items():
298+ if k.startswith("group_mappings_"):
299+ group_mappings.update({k: v})
300+ elif k.startswith("attribute_"):
301+ new_k = k.replace("attribute_", "")
302+ server_attributes.update({new_k: v})
303+ else:
304+ ldap_options.update({k: v})
305+ return ldap_options, group_mappings, server_attributes
306+
307+
308+def remove_ldap_authentication(ldap_toml_path, ldap_ca_path):
309+ """Remove LDAP config files if they exist and restart Grafana if needed."""
310+ restart_needed = False
311+ try:
312+ os.remove(ldap_toml_path)
313+ clear_flag("grafana.configured")
314+ restart_needed = True
315+ except FileNotFoundError:
316+ pass
317+ try:
318+ os.remove(ldap_ca_path)
319+ # Is the clear flag actually needed?
320+ clear_flag("grafana.configured")
321+ except FileNotFoundError:
322+ pass
323+
324+ return restart_needed
325diff --git a/src/reactive/grafana.py b/src/reactive/grafana.py
326index 9672653..dd70008 100644
327--- a/src/reactive/grafana.py
328+++ b/src/reactive/grafana.py
329@@ -96,6 +96,7 @@ from charms.layer import snap, tls_client
330 from charms.layer.grafana import (
331 CA_CERT_PATH,
332 ChangeStatus,
333+ check_ldap_config_valid,
334 check_snap_channel,
335 config_defined_ssl_ca,
336 config_defined_ssl_cert,
337@@ -106,12 +107,18 @@ from charms.layer.grafana import (
338 get_deb_package_version,
339 get_installed_package_version,
340 get_instance_name,
341+ get_ldap_configs_changed,
342 get_protocol,
343 import_dashboard,
344+ parse_snap_major_version,
345+ remove_ldap_authentication,
346+ render_ldap_config,
347 )
348 from charms.reactive import (
349+ clear_flag,
350 hook,
351 remove_state,
352+ set_flag,
353 set_state,
354 when,
355 when_any,
356@@ -155,6 +162,14 @@ GRAFANA_INI = {
357 "snap": "{}/conf/grafana.ini".format(SNAP_DATA),
358 "apt": "/etc/grafana/grafana.ini",
359 }
360+LDAP_TOML_PATH = {
361+ "snap": "{}/conf/ldap.toml".format(SNAP_DATA),
362+ "apt": "/etc/grafana/ldap.toml",
363+}
364+LDAP_CA_PATH = {
365+ "snap": "{}/ldap_ca.crt".format(SNAP_DATA),
366+ "apt": "/usr/share/ca-certificates/ldap_ca.crt",
367+}
368 GRAFANA_INI_TMPL = "grafana.ini.j2"
369 GRAFANA_DEPS = ["libfontconfig1"]
370 DASHBOARDS_BACKUP_CRON = "/etc/cron.d/juju-dashboards-backup"
371@@ -198,11 +213,9 @@ def install_packages():
372 if not kv.get("install_method", False):
373 kv.set("install_method", source)
374 elif kv.get("install_method") != source:
375- hookenv.status_set(
376- "blocked",
377- "Install method changes are not supported, "
378- "revert install_method option to previous value",
379- )
380+ set_flag("grafana.change-block")
381+ set_flag("install_method.changed")
382+ set_final_status()
383 return
384
385 if source == "apt":
386@@ -259,7 +272,10 @@ def install_packages():
387 set_state("grafana.installed")
388 hookenv.status_set("blocked", "Missing relations: grafana-source")
389 else:
390- hookenv.status_set("blocked", "Unsupported install_method")
391+ set_flag("install_method.unsupported")
392+ return
393+ clear_flag("install_method.changed")
394+ clear_flag("install_method.unsupported")
395
396
397 def data_path():
398@@ -326,12 +342,19 @@ def upgrade_charm():
399 def config_changed():
400 """Run config-changed hook."""
401 config = hookenv.config()
402+ if config.changed("install_method"):
403+ clear_flag("grafana.installed")
404+ clear_flag("grafana.change-block")
405 if (
406 config.changed("snap_channel")
407 and config["snap_channel"]
408 and not config.changed("install_method")
409 and config["install_method"] == "snap"
410 ):
411+ clear_flag("snap_channel.change.latest")
412+ clear_flag("snap_channel.change.invalid")
413+ clear_flag("snap_channel.change.downgrade")
414+ clear_flag("snap_channel.change.upgrade")
415 # NOTE(aluria): install_method change (apt -> snap, and viceversa)
416 # is not supported. However, snap channel change support was missing
417 if snap.is_installed(SNAP_NAME):
418@@ -367,6 +390,7 @@ def config_changed():
419 if config.changed("site_name"):
420 application_dashboard_relation_changed()
421 remove_state("grafana.nagios-setup.completed")
422+ get_ldap_configs_changed(config)
423
424
425 def check_ports(new_port):
426@@ -511,6 +535,8 @@ def setup_grafana():
427 "cert_file": CERT_PATH[config["install_method"]],
428 "cert_key": CERT_KEY_PATH[config["install_method"]],
429 }
430+ if is_flag_set("grafana.ldap.configured"):
431+ settings["ldap_toml_path"] = LDAP_TOML_PATH[source]
432
433 smtp_auth = config.get("smtp_auth", False)
434 if smtp_auth and len(smtp_auth.split(":")) == 2:
435@@ -608,13 +634,15 @@ def restart_grafana():
436
437 svcname = SVCNAME[source]
438 grafana_ini = GRAFANA_INI[source]
439+ ldap_toml = LDAP_TOML_PATH[source]
440+ configs_changed = any_file_changed([grafana_ini, ldap_toml])
441
442 if not host.service_running(svcname):
443 msg = "Starting {}".format(svcname)
444 hookenv.status_set("maintenance", msg)
445 hookenv.log(msg)
446 host.service_start(svcname)
447- elif any_file_changed([grafana_ini]) or is_state("config.changed.install_plugins"):
448+ elif configs_changed or is_state("config.changed.install_plugins"):
449 msg = "Restarting {}".format(svcname)
450 hookenv.log(msg)
451 hookenv.status_set("maintenance", msg)
452@@ -1409,6 +1437,38 @@ def import_dashboards(dashboards):
453 kv.set("dash_digests", dashboard_digests)
454
455
456+@when("grafana.started")
457+@when_not("grafana.ldap.configured")
458+@when_not("grafana.change-block")
459+def configure_ldap_authentication():
460+ """Configure LDAP authentication."""
461+ config = hookenv.config()
462+ source = get_install_source()
463+ if not source:
464+ return
465+ ldap_toml_path = LDAP_TOML_PATH[source]
466+ ldap_ca_path = LDAP_CA_PATH[source]
467+ if config.get("ldap_auth"):
468+ if not check_ldap_config_valid(config):
469+ hookenv.log("Missing required config options for configuring LDAP.")
470+ set_flag("ldap.config.incomplete")
471+ restart_needed = remove_ldap_authentication(ldap_toml_path, ldap_ca_path)
472+ if restart_needed:
473+ setup_grafana()
474+ restart_grafana()
475+ set_final_status()
476+ else:
477+ if config.get("ldap_server_ca") and config.changed("ldap_server_ca"):
478+ render_ldap_config(config, ldap_toml_path, ldap_ca_path)
479+ else:
480+ render_ldap_config(config, ldap_toml_path)
481+ set_flag("grafana.ldap.configured")
482+ else:
483+ if is_flag_set("ldap.config.incomplete"):
484+ clear_flag("ldap.config.incomplete")
485+ remove_ldap_authentication(ldap_toml_path, ldap_ca_path)
486+
487+
488 @when("certificates.available")
489 @when_not("grafana.certificates.configured")
490 @when_not("grafana.certificates.requested")
491@@ -1438,6 +1498,7 @@ def tls_request_certificate(tls):
492
493 @when("tls_client.certs.changed")
494 @when("grafana.certificates.requested")
495+@when_not("grafana.change-block")
496 def tls_certificate_changed():
497 """Request to reconfigure grafana after the certificates were written."""
498 _ensure_certs_permissions()
499@@ -1533,3 +1594,54 @@ def _uninstall_cert_key():
500 hookenv.log("Certificate key {0} removed.".format(key_path), level=hookenv.INFO)
501 except FileNotFoundError:
502 pass
503+
504+
505+@when("grafana.configured")
506+def set_final_status():
507+ """Set the final status of the charm as we leave hook execution."""
508+ config = hookenv.config()
509+
510+ if is_flag_set("install_method.changed"):
511+ hookenv.status_set(
512+ "blocked",
513+ "Install method changes are not supported, "
514+ "revert install_method option to previous value",
515+ )
516+ return
517+
518+ if is_flag_set("install_method.unsupported"):
519+ hookenv.status_set("blocked", "Unsupported install_method")
520+ return
521+
522+ if is_flag_set("ldap.config.incomplete"):
523+ hookenv.status_set("blocked", "LDAP configuration incomplete")
524+ return
525+
526+ if is_flag_set("snap_channel.change.latest"):
527+ msg = (
528+ 'The "latest" track is not supported; revert the channel config '
529+ "change to unblock"
530+ )
531+ hookenv.status_set("blocked", msg)
532+ return
533+
534+ if is_flag_set("snap_channel.change.invalid"):
535+ msg = "Invalid snap channel: {}".format(config["snap_channel"])
536+ hookenv.status_set("blocked", msg)
537+ return
538+
539+ if is_flag_set("snap_channel.change.upgrade"):
540+ msg = "PACKAGE UPGRADE REQUIRES MANUAL INTERVENTION"
541+ hookenv.status_set("blocked", msg)
542+ return
543+
544+ if is_flag_set("snap_channel.change.downgrade"):
545+ installed_ver = parse_snap_major_version(snap.get_installed_channel(SNAP_NAME))
546+ msg = (
547+ "PACKAGE DOWNGRADES ARE NOT SUPPORTED BY THIS CHARM - to clear "
548+ "this message, set config 'snap_channel' to '{}/stable'"
549+ ).format(installed_ver)
550+ hookenv.status_set("blocked", msg)
551+ return
552+
553+ hookenv.status_set("active", "Ready")
554diff --git a/src/templates/grafana.ini.j2 b/src/templates/grafana.ini.j2
555index aed60b2..4d13235 100644
556--- a/src/templates/grafana.ini.j2
557+++ b/src/templates/grafana.ini.j2
558@@ -200,8 +200,13 @@ auto_sign_up = true
559
560 #################################### Auth LDAP ##########################
561 [auth.ldap]
562+{% if config['ldap_auth'] and ldap_toml_path -%}
563+enabled = true
564+config_file = {{ ldap_toml_path }}
565+{% else -%}
566 ;enabled = false
567 ;config_file = /etc/grafana/ldap.toml
568+{% endif %}
569
570 #################################### SMTP / Emailing ##########################
571 [smtp]
572diff --git a/src/templates/ldap.toml.j2 b/src/templates/ldap.toml.j2
573new file mode 100644
574index 0000000..bbf0d27
575--- /dev/null
576+++ b/src/templates/ldap.toml.j2
577@@ -0,0 +1,58 @@
578+[[servers]]
579+host = "{{ host }}"
580+port = {{ port }}
581+bind_dn = "{{ config.ldap_user }}"
582+bind_password = "{{ config.ldap_password }}"
583+{% if ssl or config.ldap_server_ca -%}
584+use_ssl = true
585+{% if config.ldap_server_ca -%}
586+root_ca_cert = {{ ldap_ca_path }}
587+{% endif -%}
588+{% else -%}
589+use_ssl = false
590+{% endif -%}
591+search_base_dns = ["{{ config.ldap_base_dn }}"]
592+
593+{% if 'search_filter' not in ldap_options -%}
594+search_filter = "(cn=%s)"
595+{% endif -%}
596+{% if ldap_options -%}
597+{% for key, value in ldap_options.items() -%}
598+{% if key == 'group_search_base_dns' -%}
599+group_search_base_dns = ["{{ value }}"]
600+{% else -%}
601+{{ key }} = "{{ value }}"
602+{% endif -%}
603+{% endfor -%}
604+{% endif -%}
605+
606+{% if server_attributes -%}
607+[servers.attributes]
608+{% for key, value in server_attributes.items() -%}
609+{{ key }} = "{{ value }}"
610+{% endfor -%}
611+{% endif -%}
612+
613+{% if group_mappings -%}
614+{% for key, value in group_mappings.items() -%}
615+{% if key == "group_mappings_admin" -%}
616+
617+[[servers.group_mappings]]
618+group_dn = "{{ value }}"
619+org_role = "Admin"
620+grafana_admin = true
621+{% endif -%}
622+
623+{% if key == "group_mappings_editor" -%}
624+[[servers.group_mappings]]
625+group_dn = "{{ value }}"
626+org_role = "Editor"
627+{% endif -%}
628+
629+{% if key == "group_mappings_viewer" -%}
630+[[servers.group_mappings]]
631+group_dn = "{{ value }}"
632+org_role = "Viewer"
633+{% endif -%}
634+{% endfor -%}
635+{% endif -%}
636diff --git a/src/tests/functional/tests/bundles/base.yaml b/src/tests/functional/tests/bundles/base.yaml
637index 6b4b218..47354fa 100644
638--- a/src/tests/functional/tests/bundles/base.yaml
639+++ b/src/tests/functional/tests/bundles/base.yaml
640@@ -53,7 +53,10 @@ applications:
641 charm: cs:~llama-charmers-next/prometheus-ceph-exporter
642 num_units: 1
643 prometheus-libvirt-exporter:
644- charm: cs:~llama-charmers-next/prometheus-libvirt-exporter
645+ charm: cs:~llama-charmers/prometheus-libvirt-exporter
646+ ldap-server:
647+ charm: cs:~openstack-charmers/ldap-test-fixture
648+ num_units: 1
649
650 relations:
651 - - keystone:shared-db
652diff --git a/src/tests/functional/tests/bundles/overlays/focal-self-signed-cert.yaml.j2 b/src/tests/functional/tests/bundles/overlays/focal-self-signed-cert.yaml.j2
653index c31d2db..cbe4f51 100644
654--- a/src/tests/functional/tests/bundles/overlays/focal-self-signed-cert.yaml.j2
655+++ b/src/tests/functional/tests/bundles/overlays/focal-self-signed-cert.yaml.j2
656@@ -23,3 +23,5 @@ applications:
657 series: bionic
658 nagios:
659 series: bionic
660+ ldap-server:
661+ series: bionic
662diff --git a/src/tests/functional/tests/bundles/overlays/focal-snap-self-signed-cert.yaml.j2 b/src/tests/functional/tests/bundles/overlays/focal-snap-self-signed-cert.yaml.j2
663index 623d230..fafffe0 100644
664--- a/src/tests/functional/tests/bundles/overlays/focal-snap-self-signed-cert.yaml.j2
665+++ b/src/tests/functional/tests/bundles/overlays/focal-snap-self-signed-cert.yaml.j2
666@@ -25,3 +25,5 @@ applications:
667 series: bionic
668 nagios:
669 series: bionic
670+ ldap-server:
671+ series: bionic
672diff --git a/src/tests/functional/tests/bundles/overlays/focal-snap-tls.yaml.j2 b/src/tests/functional/tests/bundles/overlays/focal-snap-tls.yaml.j2
673index 259f3c5..e0b8218 100644
674--- a/src/tests/functional/tests/bundles/overlays/focal-snap-tls.yaml.j2
675+++ b/src/tests/functional/tests/bundles/overlays/focal-snap-tls.yaml.j2
676@@ -25,6 +25,8 @@ applications:
677 series: bionic
678 nagios:
679 series: bionic
680+ ldap-server:
681+ series: bionic
682 easyrsa:
683 charm: cs:~containers/easyrsa
684 num_units: 1
685diff --git a/src/tests/functional/tests/bundles/overlays/focal-snap.yaml.j2 b/src/tests/functional/tests/bundles/overlays/focal-snap.yaml.j2
686index 9200330..fafffe0 100644
687--- a/src/tests/functional/tests/bundles/overlays/focal-snap.yaml.j2
688+++ b/src/tests/functional/tests/bundles/overlays/focal-snap.yaml.j2
689@@ -24,4 +24,6 @@ applications:
690 prometheus-libvirt-exporter:
691 series: bionic
692 nagios:
693- series: bionic
694\ No newline at end of file
695+ series: bionic
696+ ldap-server:
697+ series: bionic
698diff --git a/src/tests/functional/tests/bundles/overlays/focal-tls.yaml.j2 b/src/tests/functional/tests/bundles/overlays/focal-tls.yaml.j2
699index b6283dc..46465c3 100644
700--- a/src/tests/functional/tests/bundles/overlays/focal-tls.yaml.j2
701+++ b/src/tests/functional/tests/bundles/overlays/focal-tls.yaml.j2
702@@ -24,6 +24,8 @@ applications:
703 series: bionic
704 nagios:
705 series: bionic
706+ ldap-server:
707+ series: bionic
708 easyrsa:
709 charm: cs:~containers/easyrsa
710 num_units: 1
711diff --git a/src/tests/functional/tests/bundles/overlays/focal.yaml.j2 b/src/tests/functional/tests/bundles/overlays/focal.yaml.j2
712index c2a7e8b..139cc4d 100644
713--- a/src/tests/functional/tests/bundles/overlays/focal.yaml.j2
714+++ b/src/tests/functional/tests/bundles/overlays/focal.yaml.j2
715@@ -23,4 +23,6 @@ applications:
716 prometheus-libvirt-exporter:
717 series: bionic
718 nagios:
719- series: bionic
720\ No newline at end of file
721+ series: bionic
722+ ldap-server:
723+ series: bionic
724diff --git a/src/tests/functional/tests/bundles/overlays/xenial-tls.yaml.j2 b/src/tests/functional/tests/bundles/overlays/xenial-tls.yaml.j2
725index 86f7ebf..1c1e894 100644
726--- a/src/tests/functional/tests/bundles/overlays/xenial-tls.yaml.j2
727+++ b/src/tests/functional/tests/bundles/overlays/xenial-tls.yaml.j2
728@@ -8,5 +8,7 @@ applications:
729 series: bionic
730 charm: cs:~containers/easyrsa
731 num_units: 1
732+ ldap-server:
733+ series: bionic
734 relations:
735 - [ grafana:certificates, easyrsa ]
736diff --git a/src/tests/functional/tests/bundles/overlays/xenial.yaml.j2 b/src/tests/functional/tests/bundles/overlays/xenial.yaml.j2
737index 648f471..b3dd868 100644
738--- a/src/tests/functional/tests/bundles/overlays/xenial.yaml.j2
739+++ b/src/tests/functional/tests/bundles/overlays/xenial.yaml.j2
740@@ -3,3 +3,5 @@ applications:
741 grafana:
742 options:
743 install_method: apt
744+ ldap-server:
745+ series: bionic
746diff --git a/src/tests/functional/tests/test_grafana.py b/src/tests/functional/tests/test_grafana.py
747index eb49a09..30dbe9e 100644
748--- a/src/tests/functional/tests/test_grafana.py
749+++ b/src/tests/functional/tests/test_grafana.py
750@@ -1,4 +1,4 @@
751-"""Encapsulate prometheus-openstack-exporter testing."""
752+"""Encapsulate Grafana testing."""
753 import base64
754 import json
755 import logging
756@@ -16,10 +16,21 @@ TEST_TIMEOUT = 600
757 DEFAULT_API_PORT = "3000"
758 DEFAULT_API_URL = "/"
759 DEFAULT_BACKUP_DIRECTORY = "/srv/backups"
760+SNAP_NAME = "grafana"
761+SNAP_DATA = "/var/snap/{}/current".format(SNAP_NAME)
762+SNAP_COMMON_DATA = "/var/snap/{}/common/data".format(SNAP_NAME)
763+LDAP_TOML = {
764+ "snap": "{}/conf/ldap.toml".format(SNAP_DATA),
765+ "apt": "/etc/grafana/ldap.toml",
766+}
767+DATA_DIR = {
768+ "snap": SNAP_COMMON_DATA,
769+ "apt": "/var/lib/grafana",
770+}
771
772
773 class BaseGrafanaTest(unittest.TestCase):
774- """Base for Prometheus-openstack-exporter charm tests.
775+ """Base for Grafana charm tests.
776
777 - Get the CA from easyrsa (when available) and write it on disk.
778 """
779@@ -32,6 +43,9 @@ class BaseGrafanaTest(unittest.TestCase):
780 super(BaseGrafanaTest, cls).setUpClass()
781 cls.model_name = model.get_juju_model()
782 cls.application_name = "grafana"
783+ cls.install_method = model.get_application_config(cls.application_name)[
784+ "install_method"
785+ ].get("value")
786 cls.lead_unit_name = model.get_lead_unit_name(
787 cls.application_name, model_name=cls.model_name
788 )
789@@ -107,15 +121,8 @@ class BaseGrafanaTest(unittest.TestCase):
790 timeout = time.time() + TEST_TIMEOUT
791 dashboards = []
792 while True:
793- req = requests.get(
794- "{protocol}://{host}:{port}"
795- "/api/search?dashboardIds".format(
796- protocol=self.get_protocol(),
797- host=self.grafana_ip,
798- port=DEFAULT_API_PORT,
799- ),
800- auth=("admin", self.admin_password),
801- verify=self.ca_path,
802+ req = self.query_grafana_website(
803+ "admin", self.admin_password, "api/search?dashboardIds"
804 )
805 self.assertEqual(req.status_code, 200)
806 dashboards = json.loads(req.text)
807@@ -124,6 +131,24 @@ class BaseGrafanaTest(unittest.TestCase):
808 time.sleep(30)
809 return dashboards
810
811+ def query_grafana_website(self, user, password, url_path=None):
812+ """Load a url."""
813+ if url_path is None:
814+ url = "{protocol}://{host}:{port}/".format(
815+ protocol=self.get_protocol(),
816+ host=self.grafana_ip,
817+ port=DEFAULT_API_PORT,
818+ )
819+ else:
820+ url = "{protocol}://{host}:{port}/{path}".format(
821+ protocol=self.get_protocol(),
822+ host=self.grafana_ip,
823+ port=DEFAULT_API_PORT,
824+ path=url_path,
825+ )
826+ req = requests.get(url, auth=(user, password), verify=self.ca_path)
827+ return req
828+
829 def remote_sed(self, unit, file, expr):
830 """Run sed on remote unit."""
831 cmd = ["sed", "-i", "-e", expr, file]
832@@ -224,6 +249,32 @@ class CharmOperationTest(BaseGrafanaTest):
833 content = result.get("Stdout")
834 self.assertIn(expected_nrpe_check, content)
835
836+ def test_03_change_install_method(self):
837+ """Test changing install_method results in a blocked state."""
838+ revert = ""
839+ if self.install_method == "apt":
840+ model.set_application_config(
841+ self.application_name,
842+ {"install_method": "snap"},
843+ )
844+ revert = "apt"
845+ else:
846+ model.set_application_config(
847+ self.application_name,
848+ {"install_method": "apt"},
849+ )
850+ revert = "snap"
851+ model.block_until_unit_wl_status(self.lead_unit_name, "blocked")
852+ model.block_until_all_units_idle()
853+ status_message = self.get_unit_status(self.lead_unit_name)
854+ self.assertIn("Install method changes are not support", status_message)
855+ model.set_application_config(
856+ self.application_name,
857+ {"install_method": revert},
858+ )
859+ model.block_until_unit_wl_status(self.lead_unit_name, "active")
860+ model.block_until_all_units_idle()
861+
862 def test_10_grafana_imported_dashboards(self):
863 """Check that Grafana dashboards expected are there."""
864 dashboards = self.get_and_check_dashboards(lambda dashes: len(dashes) >= 4)
865@@ -284,15 +335,7 @@ class CharmOperationTest(BaseGrafanaTest):
866 )
867 self.assertEqual(action.data["results"]["Code"], "0")
868 time.sleep(30) # Dirty hack to overcome race condition
869- req = requests.get(
870- "{protocol}://{host}:{port}/api/org/".format(
871- protocol=self.get_protocol(),
872- host=self.grafana_ip,
873- port=DEFAULT_API_PORT,
874- ),
875- auth=("foouser", "sikkrit"),
876- verify=self.ca_path,
877- )
878+ req = self.query_grafana_website("foouser", "sikkrit", "api/org/")
879 self.assertEqual(req.status_code, 200)
880 self.assertIn("name", req.json())
881
882@@ -337,6 +380,9 @@ class CharmOperationTest(BaseGrafanaTest):
883 )
884 self.assertIn(port, crontab["Stdout"])
885 self.verify_iterative_backups()
886+ # Reset the port so that later tests work without issue
887+ model.reset_application_config(self.application_name, ["port"])
888+ model.block_until_all_units_idle()
889
890 def test_15_grafana_datasource_updates(self):
891 """Change the port on the associated datasource.
892@@ -347,10 +393,159 @@ class CharmOperationTest(BaseGrafanaTest):
893 As per LP #1893320
894 """
895 datasource_application = "prometheus"
896- port_config = {"web-listen-port": "1234"}
897+ new_port = "1234"
898+ port_config = {"web-listen-port": new_port}
899 model.set_application_config(datasource_application, port_config)
900 model.block_until_all_units_idle()
901
902+ # It has been seen that this test may complete due to a slight gap in
903+ # time between prometheus going idle and grafana going executing for
904+ # this change. Therefore we should verify that the port is now
905+ # configured as expected prior to moving on.
906+ db_path = "{}/grafana.db".format(DATA_DIR[self.install_method])
907+ py = """python3 -c 'import sqlite3; output = """
908+ conn = """sqlite3.connect("{}", timeout=30)""".format(db_path)
909+ query = "SELECT * FROM data_source;"
910+ final = ".fetchall(); print(output)"
911+ cmd = """{} {}.cursor().execute("{}"){}'""".format(py, conn, query, final)
912+ # count = 0
913+ timeout = time.time() + TEST_TIMEOUT
914+ while time.time() < timeout:
915+ result = model.run_on_unit(self.lead_unit_name, cmd)
916+ if new_port in result["Stdout"]:
917+ return
918+ logging.info("Port not updated yet.. Retrying in 30s.")
919+ time.sleep(30)
920+ # The port did not change in the allowed time, fail the test
921+ self.fail("The port did not change as expected. \n" "Result: {}".format(result))
922+
923+ def test_20_ldap_not_configured(self):
924+ """Ensure that LDAP authentication is not configured when not set."""
925+ config_file = LDAP_TOML[self.install_method]
926+ expected_output = "cat: {}: No such file or directory".format(config_file)
927+ ldap_cfg = model.run_on_unit(
928+ self.lead_unit_name,
929+ "cat %s" % config_file,
930+ )
931+ self.assertIn(expected_output, ldap_cfg["Stderr"])
932+
933+ def test_21_incomplete_ldap_config_raises_blocked(self):
934+ """Incomplete LDAP configuration results in a blocked state."""
935+ model.set_application_config(
936+ self.application_name,
937+ {
938+ "ldap_auth": "true",
939+ "ldap_password": "crapper",
940+ },
941+ )
942+ model.block_until_unit_wl_status(self.lead_unit_name, "blocked")
943+ model.block_until_all_units_idle()
944+ status_message = self.get_unit_status(self.lead_unit_name)
945+ self.assertIn("LDAP configuration incomplete", status_message)
946+
947+ def test_22_configure_ldap_authentication(self):
948+ """Configure LDAP authentication and validate that a user can login."""
949+ expected_group_mappings = "[[servers.group_mappings]]"
950+ config_file = LDAP_TOML[self.install_method]
951+ ldap_server_ip = model.get_app_ips("ldap-server")[0]
952+ ldap_config_flags = (
953+ "{"
954+ "attribute_member_of: memberOf,"
955+ "attribute_email: email,"
956+ "attribute_name: givenname,"
957+ "attribute_surname: sn,"
958+ "attribute_username: cn,"
959+ "}"
960+ )
961+ model.set_application_config(
962+ self.application_name,
963+ {
964+ "ldap_auth": "true",
965+ "ldap_password": "crapper",
966+ "ldap_user": "cn=admin,dc=test,dc=com",
967+ "ldap_base_dn": "dc=test,dc=com",
968+ "ldap_server": "ldap://{}".format(ldap_server_ip),
969+ "ldap_config_flags": ldap_config_flags,
970+ },
971+ )
972+ model.block_until_unit_wl_status(self.lead_unit_name, "active")
973+ model.block_until_all_units_idle()
974+ ldap_cfg = model.file_contents(self.lead_unit_name, config_file)
975+ self.assertIn("port = 389", ldap_cfg)
976+ self.assertIn('host = "{}"'.format(ldap_server_ip), ldap_cfg)
977+ # Check that server.group_mappings is not in the file.
978+ self.assertNotIn(expected_group_mappings, ldap_cfg)
979+ jane_req = self.query_grafana_website("janedoe", "crapper", "api/org/")
980+ self.assertEqual(jane_req.status_code, 200)
981+ self.assertIn("name", jane_req.json())
982+
983+ def test_23_configure_ldap_group_mappings(self):
984+ """Configure ldap group mappings and validate the correct user can login."""
985+ config_file = LDAP_TOML[self.install_method]
986+ ldap_server_ip = model.get_app_ips("ldap-server")[0]
987+ model.set_application_config(self.application_name, {})
988+ expected_group_mappings_admin = (
989+ "[[servers.group_mappings]]\n"
990+ 'group_dn = "cn=admin,ou=groups,dc=test,dc=com"\n'
991+ 'org_role = "Admin"\n'
992+ "grafana_admin = true\n"
993+ )
994+ expected_group_mappings_viewer = (
995+ "[[servers.group_mappings]]\n"
996+ 'group_dn = "cn=openstack,ou=groups,dc=test,dc=com"\n'
997+ 'org_role = "Viewer"\n'
998+ )
999+ ldap_config_flags = (
1000+ "{"
1001+ "group_mappings_admin: 'cn=admin,ou=groups,dc=test,dc=com',"
1002+ "group_mappings_viewer: 'cn=openstack,ou=groups,dc=test,dc=com',"
1003+ "group_search_base_dns: 'ou=groups,dc=test,dc=com',"
1004+ "group_search_filter_user_attribute: 'uid',"
1005+ "group_search_filter: '(&(objectClass=posixGroup)(memberUid=%s))',"
1006+ "attribute_member_of: memberOf,"
1007+ "attribute_email: email,"
1008+ "attribute_name: givenname,"
1009+ "attribute_surname: sn,"
1010+ "attribute_username: cn,"
1011+ "}"
1012+ )
1013+ model.set_application_config(
1014+ self.application_name,
1015+ {
1016+ "ldap_auth": "true",
1017+ "ldap_password": "crapper",
1018+ "ldap_user": "cn=admin,dc=test,dc=com",
1019+ "ldap_base_dn": "dc=test,dc=com",
1020+ "ldap_server": "ldap://{}".format(ldap_server_ip),
1021+ "ldap_config_flags": ldap_config_flags,
1022+ },
1023+ )
1024+ model.block_until_unit_wl_status(self.lead_unit_name, "active")
1025+ model.block_until_all_units_idle()
1026+ ldap_cfg = model.file_contents(self.lead_unit_name, config_file)
1027+ self.assertIn(expected_group_mappings_admin, ldap_cfg)
1028+ self.assertIn(expected_group_mappings_viewer, ldap_cfg)
1029+ jane_req = self.query_grafana_website("janedoe", "crapper", "api/org/")
1030+ john_req = self.query_grafana_website("johndoe", "crapper", "api/org/")
1031+ self.assertEqual(jane_req.status_code, 401)
1032+ self.assertEqual(john_req.status_code, 200)
1033+ self.assertIn("name", john_req.json())
1034+
1035+ def test_24_remove_ldap_authentication(self):
1036+ """Disable LDAP authentication and validate that LDAP users can't login."""
1037+ config_file = LDAP_TOML[self.install_method]
1038+ model.set_application_config(self.application_name, {"ldap_auth": "false"})
1039+ model.block_until_unit_wl_status(self.lead_unit_name, "active")
1040+ model.block_until_all_units_idle()
1041+ expected_output = "cat: {}: No such file or directory".format(config_file)
1042+ ldap_cfg = model.run_on_unit(
1043+ self.lead_unit_name,
1044+ "cat %s" % config_file,
1045+ )
1046+ self.assertIn(expected_output, ldap_cfg["Stderr"])
1047+ john_req = self.query_grafana_website("johndoe", "crapper", "api/org/")
1048+ self.assertEqual(john_req.status_code, 401)
1049+
1050
1051 class SnappedGrafanaTest(BaseGrafanaTest):
1052 """Verify Grafana installed as a snap."""
1053@@ -365,6 +560,8 @@ class SnappedGrafanaTest(BaseGrafanaTest):
1054 status_message = self.get_unit_status(self.lead_unit_name)
1055 self.assertIn("PACKAGE UPGRADE REQUIRES MANUAL INTERVENTION", status_message)
1056 action = model.run_action(self.lead_unit_name, "do-upgrade")
1057+ model.block_until_unit_wl_status(self.lead_unit_name, "active")
1058+ model.block_until_all_units_idle()
1059 self.assertEqual(action.data["status"], "completed")
1060 self.assertIn("Upgraded", action.data["results"]["result"])
1061
1062diff --git a/src/tests/unit/requirements.txt b/src/tests/unit/requirements.txt
1063index cb975bd..42dfef1 100644
1064--- a/src/tests/unit/requirements.txt
1065+++ b/src/tests/unit/requirements.txt
1066@@ -7,3 +7,4 @@ requests
1067 jsondiff
1068 pbkdf2
1069 pycrypto
1070+netifaces

Subscribers

People subscribed via source and target branches

to all changes: