Browse Source

refactor keystone-ldap

* replace singletons with provide_charm_instance (in the code and tests)
* select an openstack release based on keystone package due to the lack
of openstack-origin from the principal layer (this is a subordinate, see
https://git.io/vNTyx)
* use reactive triggers to drop config.complete (new configuration
completeness) and config.rendered (non-stale config is rendered) on
config.changed
* do not check config completeness on every event - only when config has
actually changed
* remove the domain configuration file when relation with keystone is
removed (service restart should be performed on the keystone charm side)
* replace path_hash with file_hash (path_hash returns a new dict)
* modify unit tests to reflect the changes

Change-Id: Ied4b6ed64354e3de3c78e6ac809666ee9ae29d1a
Closes-Bug: #1741661
changes/62/531562/6
Dmitrii Shcherbakov 1 year ago
parent
commit
b5fe0ef6c9

+ 1
- 1
src/layer.yaml View File

@@ -1,4 +1,4 @@
1
-includes: ['layer:openstack', 'interface:keystone-domain-backend']
1
+includes: ['layer:openstack', 'interface:keystone-domain-backend', 'interface:juju-info']
2 2
 options:
3 3
   basic:
4 4
     use_venv: True

+ 36
- 28
src/lib/charm/openstack/keystone_ldap.py View File

@@ -23,11 +23,36 @@ import charmhelpers.contrib.openstack.utils as os_utils
23 23
 import charms_openstack.charm
24 24
 import charms_openstack.adapters
25 25
 
26
+import os
27
+
28
+# release detection is done via keystone package given that
29
+# openstack-origin is not present in the subordinate charm
30
+# see https://github.com/juju/charm-helpers/issues/83
31
+import charmhelpers.core.unitdata as unitdata
32
+from charms_openstack.charm.core import (
33
+    register_os_release_selector
34
+)
35
+OPENSTACK_RELEASE_KEY = 'charmers.openstack-release-version'
26 36
 
27 37
 DOMAIN_CONF = "/etc/keystone/domains/keystone.{}.conf"
28 38
 KEYSTONE_CONF_TEMPLATE = "keystone.conf"
29 39
 
30 40
 
41
+@register_os_release_selector
42
+def select_release():
43
+    """Determine the release based on the keystone package version.
44
+
45
+    Note that this function caches the release after the first install so
46
+    that it doesn't need to keep going and getting it from the package
47
+    information.
48
+    """
49
+    release_version = unitdata.kv().get(OPENSTACK_RELEASE_KEY, None)
50
+    if release_version is None:
51
+        release_version = os_utils.os_release('keystone')
52
+        unitdata.kv().set(OPENSTACK_RELEASE_KEY, release_version)
53
+    return release_version
54
+
55
+
31 56
 class KeystoneLDAPConfigurationAdapter(
32 57
         charms_openstack.adapters.ConfigurationAdapter):
33 58
     '''Charm specific configuration adapter to deal with ldap
@@ -66,7 +91,8 @@ class KeystoneLDAPCharm(charms_openstack.charm.OpenStackCharm):
66 91
         """
67 92
         return hookenv.config('domain-name') or hookenv.service_name()
68 93
 
69
-    def configuration_complete(self):
94
+    @staticmethod
95
+    def configuration_complete():
70 96
         """Determine whether sufficient configuration has been provided
71 97
         to configure keystone for use with a LDAP backend
72 98
 
@@ -98,38 +124,20 @@ class KeystoneLDAPCharm(charms_openstack.charm.OpenStackCharm):
98 124
     def render_config(self, restart_trigger):
99 125
         """Render the domain specific LDAP configuration for the application
100 126
         """
101
-        checksum = ch_host.path_hash(self.configuration_file)
127
+        checksum = ch_host.file_hash(self.configuration_file)
102 128
         core.templating.render(
103 129
             source=KEYSTONE_CONF_TEMPLATE,
104 130
             template_loader=os_templating.get_loader(
105 131
                 'templates/', self.release),
106 132
             target=self.configuration_file,
107 133
             context=self.adapters_instance)
108
-        if checksum != ch_host.path_hash(self.configuration_file):
134
+        if checksum != ch_host.file_hash(self.configuration_file):
109 135
             restart_trigger()
110 136
 
111
-
112
-def render_config(restart_trigger):
113
-    """Render the configuration for the charm
114
-
115
-    :params: restart_trigger: function to call if configuration file
116
-                              changed as a result of rendering
117
-    """
118
-    KeystoneLDAPCharm.singleton.render_config(restart_trigger)
119
-
120
-
121
-def assess_status():
122
-    """Just call the KeystoneLDAPCharm.singleton.assess_status() command
123
-    to update status on the unit.
124
-    """
125
-    KeystoneLDAPCharm.singleton.assess_status()
126
-
127
-
128
-def configuration_complete():
129
-    """Determine whether charm configuration is actually complete"""
130
-    return KeystoneLDAPCharm.singleton.configuration_complete()
131
-
132
-
133
-def configuration_file():
134
-    """Configuration file for current domain configuration"""
135
-    return KeystoneLDAPCharm.singleton.configuration_file
137
+    def remove_config(self):
138
+        """
139
+        Remove the domain-specific LDAP configuration file and trigger
140
+        keystone restart.
141
+        """
142
+        if os.path.exists(self.configuration_file):
143
+            os.unlink(self.configuration_file)

+ 36
- 14
src/reactive/keystone_ldap_handlers.py View File

@@ -13,10 +13,13 @@
13 13
 # See the License for the specific language governing permissions and
14 14
 # limitations under the License.
15 15
 
16
+# import to trigger openstack charm metaclass init
17
+import charm.openstack.keystone_ldap # noqa
18
+
16 19
 import charms_openstack.charm as charm
17 20
 import charms.reactive as reactive
18 21
 
19
-import charm.openstack.keystone_ldap as keystone_ldap  # noqa
22
+import charms.reactive.flags as flags
20 23
 
21 24
 import charmhelpers.core.hookenv as hookenv
22 25
 
@@ -24,35 +27,54 @@ charm.use_defaults(
24 27
     'charm.installed',
25 28
     'update-status')
26 29
 
30
+# if config has been changed we need to re-evaluate flags
31
+# config.changed is set and cleared (atexit) in layer-basic
32
+flags.register_trigger(when='config.changed',
33
+                       clear_flag='config.rendered')
34
+flags.register_trigger(when='config.changed',
35
+                       clear_flag='config.complete')
36
+
27 37
 
28 38
 @reactive.when('domain-backend.connected')
29 39
 @reactive.when_not('domain-name-configured')
30 40
 @reactive.when('config.complete')
31 41
 def configure_domain_name(domain):
32
-    keystone_ldap.render_config(domain.trigger_restart)
33 42
     domain.domain_name(hookenv.config('domain-name') or
34 43
                        hookenv.service_name())
35
-    reactive.set_state('domain-name-configured')
44
+    flags.set_flag('domain-name-configured')
36 45
 
37 46
 
38 47
 @reactive.when_not('domain-backend.connected')
39 48
 @reactive.when('domain-name-configured')
40
-def clear_domain_name_configured(*args):
41
-    reactive.remove_state('domain-name-configured')
49
+def keystone_departed():
50
+    """
51
+    Service restart should be handled on the keystone side
52
+    in this case.
53
+    """
54
+    flags.clear_flag('domain-name-configured')
55
+    with charm.provide_charm_instance() as kldap_charm:
56
+        kldap_charm.remove_config()
57
+
58
+
59
+@reactive.when('domain-backend.connected')
60
+@reactive.when_not('config.complete')
61
+def config_changed(domain):
62
+    with charm.provide_charm_instance() as kldap_charm:
63
+        if kldap_charm.configuration_complete():
64
+            flags.set_flag('config.complete')
42 65
 
43 66
 
44 67
 @reactive.when('domain-backend.connected')
45 68
 @reactive.when('domain-name-configured')
46 69
 @reactive.when('config.complete')
47
-def config_changed(domain):
48
-    keystone_ldap.render_config(domain.trigger_restart)
70
+@reactive.when_not('config.rendered')
71
+def render_config(domain):
72
+    with charm.provide_charm_instance() as kldap_charm:
73
+        kldap_charm.render_config(domain.trigger_restart)
74
+        flags.set_flag('config.rendered')
49 75
 
50 76
 
51 77
 @reactive.when_not('always.run')
52
-def check_configuration():
53
-    '''Validate required configuration options at set state'''
54
-    if keystone_ldap.configuration_complete():
55
-        reactive.set_state('config.complete')
56
-    else:
57
-        reactive.remove_state('config.complete')
58
-    keystone_ldap.assess_status()
78
+def assess_status():
79
+    with charm.provide_charm_instance() as kldap_charm:
80
+        kldap_charm.assess_status()

+ 81
- 47
unit_tests/test_keystone_ldap_handlers.py View File

@@ -32,15 +32,18 @@ class TestRegisteredHooks(test_utils.TestRegisteredHooks):
32 32
             'when': {
33 33
                 'configure_domain_name': ('domain-backend.connected',
34 34
                                           'config.complete'),
35
-                'clear_domain_name_configured': ('domain-name-configured', ),
36
-                'config_changed': ('domain-backend.connected',
37
-                                   'config.complete',
38
-                                   'domain-name-configured'),
35
+                'keystone_departed': ('domain-name-configured',),
36
+                'config_changed': ('domain-backend.connected',),
37
+                'render_config': ('config.complete',
38
+                                  'domain-backend.connected',
39
+                                  'domain-name-configured'),
39 40
             },
40 41
             'when_not': {
41
-                'check_configuration': ('always.run', ),
42
-                'configure_domain_name': ('domain-name-configured', ),
43
-                'clear_domain_name_configured': ('domain-backend.connected', ),
42
+                'assess_status': ('always.run',),
43
+                'configure_domain_name': ('domain-name-configured',),
44
+                'keystone_departed': ('domain-backend.connected',),
45
+                'config_changed': ('config.complete',),
46
+                'render_config': ('config.rendered',),
44 47
             }
45 48
         }
46 49
         # test that the hooks were registered via the
@@ -50,62 +53,93 @@ class TestRegisteredHooks(test_utils.TestRegisteredHooks):
50 53
 
51 54
 class TestKeystoneLDAPCharmHandlers(test_utils.PatchHelper):
52 55
 
53
-    def patch(self, obj, attr, return_value=None, side_effect=None):
54
-        mocked = mock.patch.object(obj, attr)
55
-        self._patches[attr] = mocked
56
-        started = mocked.start()
57
-        started.return_value = return_value
58
-        started.side_effect = side_effect
59
-        self._patches_start[attr] = started
60
-        setattr(self, attr, started)
56
+    def _patch_provide_charm_instance(self):
57
+        kldap_charm = mock.MagicMock()
58
+        self.patch('charms_openstack.charm.provide_charm_instance',
59
+                   name='provide_charm_instance',
60
+                   new=mock.MagicMock())
61
+        self.provide_charm_instance().__enter__.return_value = kldap_charm
62
+        self.provide_charm_instance().__exit__.return_value = None
63
+        return kldap_charm
61 64
 
62 65
     def test_configure_domain_name_application(self):
63
-        self.patch(handlers.keystone_ldap, 'render_config')
64
-        self.patch(handlers.hookenv, 'config')
65
-        self.patch(handlers.hookenv, 'service_name')
66
-        self.patch(handlers.reactive, 'set_state')
66
+        self.patch_object(handlers.hookenv, 'config')
67 67
         self.config.return_value = None
68
+
69
+        self.patch_object(handlers.hookenv, 'service_name')
68 70
         self.service_name.return_value = 'keystone-ldap'
71
+
72
+        self.patch_object(handlers.flags, 'set_flag')
73
+
69 74
         domain = mock.MagicMock()
75
+
70 76
         handlers.configure_domain_name(domain)
71
-        self.render_config.assert_called_with(
72
-            domain.trigger_restart
73
-        )
77
+
74 78
         domain.domain_name.assert_called_with(
75 79
             'keystone-ldap'
76 80
         )
77
-        self.set_state.assert_called_once_with('domain-name-configured')
81
+        self.set_flag.assert_called_once_with('domain-name-configured')
78 82
 
79
-    def test_clear_domain_name_configured(self):
80
-        self.patch(handlers.reactive, 'remove_state')
81
-        domain = mock.MagicMock()
82
-        handlers.clear_domain_name_configured(domain)
83
-        self.remove_state.assert_called_once_with('domain-name-configured')
83
+    def test_keystone_departed(self):
84
+        kldap_charm = self._patch_provide_charm_instance()
85
+        self.patch_object(kldap_charm, 'remove_config')
86
+
87
+        self.patch_object(handlers.flags, 'clear_flag')
88
+
89
+        handlers.keystone_departed()
90
+
91
+        self.clear_flag.assert_called_once_with('domain-name-configured')
92
+
93
+        kldap_charm.remove_config.assert_called_once()
84 94
 
85 95
     def test_configure_domain_name_config(self):
86
-        self.patch(handlers.keystone_ldap, 'render_config')
87
-        self.patch(handlers.hookenv, 'config')
88
-        self.patch(handlers.hookenv, 'service_name')
96
+        self.patch_object(handlers.hookenv, 'config')
89 97
         self.config.return_value = 'mydomain'
90
-        self.service_name.return_value = 'keystone-ldap'
98
+
91 99
         domain = mock.MagicMock()
100
+
92 101
         handlers.configure_domain_name(domain)
93
-        self.render_config.assert_called_with(
94
-            domain.trigger_restart
95
-        )
102
+
96 103
         domain.domain_name.assert_called_with(
97 104
             'mydomain'
98 105
         )
99 106
 
100
-    def test_check_configuration(self):
101
-        self.patch(handlers.keystone_ldap, 'configuration_complete')
102
-        self.patch(handlers.reactive, 'set_state')
103
-        self.patch(handlers.reactive, 'remove_state')
104
-        self.patch(handlers.keystone_ldap, 'assess_status')
105
-        self.configuration_complete.return_value = True
106
-        handlers.check_configuration()
107
-        self.set_state.assert_called_with('config.complete')
108
-        self.configuration_complete.return_value = False
109
-        handlers.check_configuration()
110
-        self.remove_state.assert_called_with('config.complete')
111
-        self.assertTrue(self.assess_status.called)
107
+    def test_config_changed(self):
108
+        kldap_charm = self._patch_provide_charm_instance()
109
+        self.patch_object(kldap_charm, 'render_config')
110
+
111
+        # assume that configuration is complete to test config.rendered
112
+        kldap_charm.configuration_complete.return_value = True
113
+
114
+        self.patch_object(handlers.flags, 'set_flag')
115
+
116
+        domain = mock.MagicMock()
117
+
118
+        handlers.config_changed(domain)
119
+
120
+        self.set_flag.assert_called_once_with('config.complete')
121
+        self.render_config.assert_not_called()
122
+
123
+    def test_render_config(self):
124
+        kldap_charm = self._patch_provide_charm_instance()
125
+        self.patch_object(kldap_charm, 'render_config')
126
+
127
+        self.patch_object(handlers.flags, 'set_flag')
128
+
129
+        domain = mock.MagicMock()
130
+
131
+        handlers.render_config(domain)
132
+
133
+        self.set_flag.assert_called_once_with('config.rendered')
134
+
135
+        kldap_charm.render_config.assert_called_with(
136
+            domain.trigger_restart
137
+        )
138
+
139
+    def test_assess_status(self):
140
+        kldap_charm = self._patch_provide_charm_instance()
141
+        self.patch_object(kldap_charm, 'assess_status')
142
+
143
+        handlers.assess_status()
144
+
145
+        kldap_charm.assess_status.assert_called_once()

+ 94
- 73
unit_tests/test_lib_charm_openstack_keystone_ldap.py View File

@@ -12,35 +12,20 @@
12 12
 from __future__ import absolute_import
13 13
 from __future__ import print_function
14 14
 
15
-import unittest
16
-
17 15
 import mock
18 16
 
19
-from charms_openstack.test_mocks import charmhelpers as ch
20
-ch.contrib.openstack.utils.OPENSTACK_RELEASES = ('mitaka', )
21
-import charm.openstack.keystone_ldap as keystone_ldap
17
+import charms_openstack.test_utils as test_utils
22 18
 
19
+import charm.openstack.keystone_ldap as keystone_ldap
23 20
 
24
-class Helper(unittest.TestCase):
21
+from charms_openstack.charm import provide_charm_instance
25 22
 
26
-    def setUp(self):
27
-        self._patches = {}
28
-        self._patches_start = {}
29 23
 
30
-    def tearDown(self):
31
-        for k, v in self._patches.items():
32
-            v.stop()
33
-            setattr(self, k, None)
34
-        self._patches = None
35
-        self._patches_start = None
24
+class Helper(test_utils.PatchHelper):
36 25
 
37
-    def patch(self, obj, attr, return_value=None, **kwargs):
38
-        mocked = mock.patch.object(obj, attr, **kwargs)
39
-        self._patches[attr] = mocked
40
-        started = mocked.start()
41
-        started.return_value = return_value
42
-        self._patches_start[attr] = started
43
-        setattr(self, attr, started)
26
+    def setUp(self):
27
+        super().setUp()
28
+        self.patch_release(keystone_ldap.KeystoneLDAPCharm.release)
44 29
 
45 30
 
46 31
 class TestKeystoneLDAPCharm(Helper):
@@ -59,15 +44,17 @@ class TestKeystoneLDAPCharm(Helper):
59 44
                 return reply.get(key)
60 45
             return reply
61 46
         config.side_effect = mock_config
62
-        self.assertTrue(keystone_ldap.configuration_complete())
63 47
 
64
-        for required_config in reply:
65
-            orig = reply[required_config]
66
-            reply[required_config] = None
67
-            self.assertFalse(keystone_ldap.configuration_complete())
68
-            reply[required_config] = orig
48
+        with provide_charm_instance() as kldap_charm:
49
+            self.assertTrue(kldap_charm.configuration_complete())
50
+
51
+            for required_config in reply:
52
+                orig = reply[required_config]
53
+                reply[required_config] = None
54
+                self.assertFalse(kldap_charm.configuration_complete())
55
+                reply[required_config] = orig
69 56
 
70
-        self.assertTrue(keystone_ldap.configuration_complete())
57
+            self.assertTrue(kldap_charm.configuration_complete())
71 58
 
72 59
     @mock.patch('charmhelpers.core.hookenv.service_name')
73 60
     @mock.patch('charmhelpers.core.hookenv.config')
@@ -75,18 +62,20 @@ class TestKeystoneLDAPCharm(Helper):
75 62
                          service_name):
76 63
         config.return_value = None
77 64
         service_name.return_value = 'testdomain'
78
-        charm = keystone_ldap.KeystoneLDAPCharm()
79
-        self.assertEqual('testdomain',
80
-                         charm.domain_name)
81
-        self.assertEqual('/etc/keystone/domains/keystone.testdomain.conf',
82
-                         charm.configuration_file)
83
-        config.assert_called_with('domain-name')
84
-
85
-        config.return_value = 'userdomain'
86
-        self.assertEqual('userdomain',
87
-                         charm.domain_name)
88
-        self.assertEqual('/etc/keystone/domains/keystone.userdomain.conf',
89
-                         charm.configuration_file)
65
+        with provide_charm_instance() as kldap_charm:
66
+            self.assertEqual('testdomain',
67
+                             kldap_charm.domain_name)
68
+            self.assertEqual(
69
+                '/etc/keystone/domains/keystone.testdomain.conf',
70
+                kldap_charm.configuration_file)
71
+            config.assert_called_with('domain-name')
72
+
73
+            config.return_value = 'userdomain'
74
+            self.assertEqual('userdomain',
75
+                             kldap_charm.domain_name)
76
+            self.assertEqual(
77
+                '/etc/keystone/domains/keystone.userdomain.conf',
78
+                kldap_charm.configuration_file)
90 79
 
91 80
     @mock.patch('charmhelpers.contrib.openstack.utils.snap_install_requested')
92 81
     @mock.patch('charmhelpers.core.hookenv.config')
@@ -110,25 +99,27 @@ class TestKeystoneLDAPCharm(Helper):
110 99
         config.side_effect = mock_config
111 100
 
112 101
         snap_install_requested.return_value = False
113
-        # Check that active status is set correctly
114
-        keystone_ldap.assess_status()
115
-        status_set.assert_called_with('active', mock.ANY)
116
-        application_version_set.assert_called_with(
117
-            keystone_ldap.KeystoneLDAPCharm.singleton.application_version
118
-        )
119
-
120
-        # Check that blocked status is set correctly
121
-        reply['ldap-server'] = None
122
-        keystone_ldap.assess_status()
123
-        status_set.assert_called_with('blocked', mock.ANY)
124
-        application_version_set.assert_called_with(
125
-            keystone_ldap.KeystoneLDAPCharm.singleton.application_version
126
-        )
102
+
103
+        with provide_charm_instance() as kldap_charm:
104
+            # Check that active status is set correctly
105
+            kldap_charm.assess_status()
106
+            status_set.assert_called_with('active', mock.ANY)
107
+            application_version_set.assert_called_with(
108
+                kldap_charm.application_version
109
+            )
110
+
111
+            # Check that blocked status is set correctly
112
+            reply['ldap-server'] = None
113
+            kldap_charm.assess_status()
114
+            status_set.assert_called_with('blocked', mock.ANY)
115
+            application_version_set.assert_called_with(
116
+                kldap_charm.application_version
117
+            )
127 118
 
128 119
     @mock.patch('charmhelpers.core.hookenv.config')
129 120
     def test_render_config(self, config):
130
-        self.patch(keystone_ldap.ch_host, 'path_hash')
131
-        self.patch(keystone_ldap.core.templating, 'render')
121
+        self.patch_object(keystone_ldap.ch_host, 'file_hash')
122
+        self.patch_object(keystone_ldap.core.templating, 'render')
132 123
 
133 124
         reply = {
134 125
             'ldap-server': 'myserver',
@@ -144,24 +135,54 @@ class TestKeystoneLDAPCharm(Helper):
144 135
             return reply
145 136
         config.side_effect = mock_config
146 137
 
147
-        self.path_hash.side_effect = ['aaa', 'aaa']
138
+        self.file_hash.side_effect = ['aaa', 'aaa']
148 139
         mock_trigger = mock.MagicMock()
149 140
 
150
-        # Ensure a basic level of function from render_config
151
-        keystone_ldap.render_config(mock_trigger)
152
-        self.render.assert_called_with(
153
-            source=keystone_ldap.KEYSTONE_CONF_TEMPLATE,
154
-            template_loader=mock.ANY,
155
-            target='/etc/keystone/domains/keystone.userdomain.conf',
156
-            context=mock.ANY
157
-        )
158
-        self.assertFalse(mock_trigger.called)
159
-
160
-        # Ensure that change in file contents results in call
161
-        # to restart trigger function passed to render_config
162
-        self.path_hash.side_effect = ['aaa', 'bbb']
163
-        keystone_ldap.render_config(mock_trigger)
164
-        self.assertTrue(mock_trigger.called)
141
+        with provide_charm_instance() as kldap_charm:
142
+            # Ensure a basic level of function from render_config
143
+            kldap_charm.render_config(mock_trigger)
144
+            self.render.assert_called_with(
145
+                source=keystone_ldap.KEYSTONE_CONF_TEMPLATE,
146
+                template_loader=mock.ANY,
147
+                target='/etc/keystone/domains/keystone.userdomain.conf',
148
+                context=mock.ANY
149
+            )
150
+            self.assertFalse(mock_trigger.called)
151
+
152
+            # Ensure that change in file contents results in call
153
+            # to restart trigger function passed to render_config
154
+            self.file_hash.side_effect = ['aaa', 'bbb']
155
+            kldap_charm.render_config(mock_trigger)
156
+            self.assertTrue(mock_trigger.called)
157
+
158
+    @mock.patch('charmhelpers.core.hookenv.config')
159
+    @mock.patch('os.path.exists')
160
+    @mock.patch('os.unlink')
161
+    def test_remove_config(self, unlink, exists, config):
162
+        exists.return_value = True
163
+
164
+        self.patch_object(keystone_ldap.ch_host, 'file_hash')
165
+
166
+        reply = {
167
+            'ldap-server': 'myserver',
168
+            'ldap-user': 'myusername',
169
+            'ldap-password': 'mypassword',
170
+            'ldap-suffix': 'suffix',
171
+            'domain-name': 'userdomain',
172
+        }
173
+
174
+        def mock_config(key=None):
175
+            if key:
176
+                return reply.get(key)
177
+            return reply
178
+        config.side_effect = mock_config
179
+
180
+        with provide_charm_instance() as kldap_charm:
181
+            # Ensure a basic level of function from render_config
182
+            cf = keystone_ldap.DOMAIN_CONF.format(reply['domain-name'])
183
+            kldap_charm.remove_config()
184
+            exists.assert_called_once_with(cf)
185
+            unlink.assert_called_once_with(cf)
165 186
 
166 187
 
167 188
 class TestKeystoneLDAPAdapters(Helper):

Loading…
Cancel
Save