From 80d131f18ac9b8812f5fe49cec604118023470a2 Mon Sep 17 00:00:00 2001 From: Amber Charitos Date: Tue, 5 Mar 2024 18:42:39 +0000 Subject: [PATCH] add optional ldap relation --- config.yaml | 11 --- metadata.yaml | 2 + src/charm.py | 24 +++-- src/literals.py | 8 ++ src/relations/ldap.py | 115 ++++++++++++++++++++++ templates/ranger-usersync-config.jinja | 2 +- tests/integration/test_usersync.py | 1 + tests/unit/test_charm.py | 128 +++++++++++++++++++++++-- 8 files changed, 264 insertions(+), 27 deletions(-) create mode 100644 src/relations/ldap.py diff --git a/config.yaml b/config.yaml index 35eeff4..41031cf 100644 --- a/config.yaml +++ b/config.yaml @@ -23,12 +23,10 @@ options: The url of the ldap to synchronize users from. In format `ldap://:`. type: string - default: ldap://comsys-openldap-k8s:389 sync-ldap-bind-dn: description: | The bind domain name for ldap synchronization. type: string - default: cn=admin,dc=canonical,dc=dev,dc=com sync-ldap-bind-password: description: | The bind password for ldap synchronization. @@ -38,7 +36,6 @@ options: description: | Search base for ldap users and groups. type: string - default: dc=canonical,dc=dev,dc=com sync-ldap-user-object-class: description: | The object class corresponding to users for ldapsearch. @@ -53,7 +50,6 @@ options: description: | Search base for ldap users. type: string - default: dc=canonical,dc=dev,dc=com sync-group-user-map-sync-enabled: description: | Set to true to sync groups without users. @@ -69,7 +65,6 @@ options: Search base for ldap groups. If not specified this takes the value of `sync-ldap-search-base`. type: string - default: dc=canonical,dc=dev,dc=com sync-ldap-user-search-scope: description: | Search scope for the users. @@ -113,12 +108,6 @@ options: Enable to incrementally sync as opposed to full sync after initial run. type: boolean default: true - ranger-usersync-password: - description: | - The password for the Ranger usersync user. - Must be updated to match in Ranger admin. - type: string - default: rangerR0cks! policy-mgr-url: type: string default: http://ranger-k8s:6080 diff --git a/metadata.yaml b/metadata.yaml index 60e1acd..6871b11 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -27,6 +27,8 @@ requires: limit: 1 nginx-route: interface: nginx-route + ldap: + interface: ldap provides: policy: diff --git a/src/charm.py b/src/charm.py index e3ad7d6..fe08a42 100755 --- a/src/charm.py +++ b/src/charm.py @@ -22,8 +22,10 @@ ADMIN_ENTRYPOINT, APP_NAME, APPLICATION_PORT, + RELATION_VALUES, USERSYNC_ENTRYPOINT, ) +from relations.ldap import LDAPRelationHandler from relations.postgres import PostgresRelationHandler from relations.provider import RangerProvider from state import State @@ -78,6 +80,7 @@ def __init__(self, *args): ) self.postgres_relation_handler = PostgresRelationHandler(self) self.provider = RangerProvider(self) + self.ldap = LDAPRelationHandler(self) # Handle Ingress self._require_nginx_route() @@ -213,17 +216,20 @@ def _configure_ranger_usersync(self, container): context: Environment variables for pebble plan. """ context = {} + ldap = self._state.ldap or {} for key, value in vars(self.config).items(): - if key.startswith("sync"): - updated_key = key.upper().replace("-", "_") - context[updated_key] = value + if not key.startswith("sync"): + continue + + if key in RELATION_VALUES: + value = ldap.get(key) or self.config[key] + + updated_key = key.upper() + context[updated_key] = value context.update( { "POLICY_MGR_URL": self.config["policy-mgr-url"], - "RANGER_USERSYNC_PASSWORD": self.config[ - "ranger-usersync-password" - ], } ) config = render("ranger-usersync-config.jinja", context) @@ -246,12 +252,18 @@ def validate(self): if self.config["charm-function"].value == "admin": self.postgres_relation_handler.validate() + if self.config["charm-function"].value == "usersync": + self.ldap.validate() + def update(self, event): """Update the Ranger server configuration and re-plan its execution. Args: event: The event triggered when the relation changed. """ + if not self.unit.is_leader(): + return + try: self.validate() except ValueError as err: diff --git a/src/literals.py b/src/literals.py index 18bb846..e7173b8 100644 --- a/src/literals.py +++ b/src/literals.py @@ -25,3 +25,11 @@ APP_NAME = "ranger-k8s" ADMIN_ENTRYPOINT = "/home/ranger/scripts/ranger-admin-entrypoint.sh" USERSYNC_ENTRYPOINT = "/home/ranger/scripts/ranger-usersync-entrypoint.sh" +RELATION_VALUES = [ + "sync_ldap_bind_dn", + "sync_ldap_bind_password", + "sync_ldap_search_base", + "sync_ldap_user_search_base", + "sync_group_search_base", + "sync_ldap_url", +] diff --git a/src/relations/ldap.py b/src/relations/ldap.py new file mode 100644 index 0000000..70e58be --- /dev/null +++ b/src/relations/ldap.py @@ -0,0 +1,115 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Defines ldap relation event handling methods.""" + +import logging + +from ops import framework + +from literals import RELATION_VALUES +from utils import log_event_handler + +logger = logging.getLogger(__name__) + + +class LDAPRelationHandler(framework.Object): + """Client for ldap relations.""" + + def __init__(self, charm, relation_name="ldap"): + """Construct. + + Args: + charm: The charm to attach the hooks to. + relation_name: The name of the relation defaults to ldap. + """ + super().__init__(charm, "ldap") + self.charm = charm + self.relation_name = relation_name + + # Handle database relation. + self.framework.observe( + charm.on[self.relation_name].relation_created, + self._on_relation_created, + ) + self.framework.observe( + charm.on[self.relation_name].relation_changed, + self._on_relation_changed, + ) + self.framework.observe( + charm.on[self.relation_name].relation_broken, + self._on_relation_broken, + ) + + @log_event_handler(logger) + def _on_relation_created(self, event): + """Handle ldap relation created. + + Args: + event: The relation created event. + """ + if self.charm.config["charm-function"] != "usersync": + return + + if event.relation: + event.relation.data[self.charm.app].update({"user": "admin"}) + + @log_event_handler(logger) + def _on_relation_changed(self, event): + """Handle ldap relation changed. + + Args: + event: Relation changed event. + """ + if self.charm.config["charm-function"] != "usersync": + return + + container = self.charm.model.unit.get_container(self.charm.name) + if not container.can_connect(): + event.defer() + return + + event_data = event.relation.data[event.app] + base_dn = event_data.get("base_dn") + self.charm._state.ldap = { + "sync_ldap_bind_password": event_data.get("admin_password"), + "sync_ldap_bind_dn": f"cn=admin,{base_dn}", + "sync_ldap_search_base": base_dn, + "sync_ldap_user_search_base": base_dn, + "sync_group_search_base": base_dn, + "sync_ldap_url": event_data.get("ldap_url"), + } + + self.charm.update(event) + + @log_event_handler(logger) + def _on_relation_broken(self, event): + """Handle ldap relation broken. + + Args: + event: Relation broken event. + """ + if self.charm.config["charm-function"] != "usersync": + return + + container = self.charm.model.unit.get_container(self.charm.name) + if not container.can_connect(): + event.defer() + return + + self.charm._state.ldap = {} + self.charm.update(event) + + def validate(self): + """Check if the required ldap parameters are available. + + Raises: + ValueError: if ldap parameters are not available. + """ + config = vars(self.charm.config) + if not self.charm._state.ldap: + for value in RELATION_VALUES: + if not config.get(value): + raise ValueError( + "Add an LDAP relation or update config values." + ) diff --git a/templates/ranger-usersync-config.jinja b/templates/ranger-usersync-config.jinja index 391fb68..92de7f3 100644 --- a/templates/ranger-usersync-config.jinja +++ b/templates/ranger-usersync-config.jinja @@ -27,7 +27,7 @@ unix_group=ranger # This password should be changed from the default. # Please note that this password should be as per rangerusersync user in ranger admin. -rangerUsersync_password= {{ RANGER_USERSYNC_PASSWORD}} +rangerUsersync_password= rangerR0cks! hadoop_conf=/etc/hadoop/conf diff --git a/tests/integration/test_usersync.py b/tests/integration/test_usersync.py index 06ed93e..161a63d 100644 --- a/tests/integration/test_usersync.py +++ b/tests/integration/test_usersync.py @@ -53,6 +53,7 @@ async def test_user_sync(self, ops_test: OpsTest): config=ranger_config, ) + await ops_test.model.integrate(USERSYNC_NAME, LDAP_NAME) await ops_test.model.set_config({"update-status-hook-interval": "5m"}) await ops_test.model.wait_for_idle( apps=[USERSYNC_NAME, LDAP_NAME], diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 048518a..9181940 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -19,6 +19,21 @@ logger = logging.getLogger(__name__) +LDAP_RELATION_CHANGED_DATA = { + "admin_password": "huedw7uiedw7", + "base_dn": "dc=canonical,dc=dev,dc=com", + "ldap_url": "ldap://comsys-openldap-k8s:389", +} +LDAP_RELATION_BROKEN_DATA: dict = {"comsys-openldap-k8s": {}} +USERSYNC_CONFIG_VALUES = { + "sync-ldap-url": "ldap://config-openldap-k8s:389", + "sync-ldap-bind-password": "admin", + "sync-ldap-search-base": "dc=canonical,dc=dev,dc=com", + "sync-ldap-bind-dn": "dc=canonical,dc=dev,dc=com", + "sync-ldap-user-search-base": "dc=canonical,dc=dev,dc=com", + "sync-group-search-base": "dc=canonical,dc=dev,dc=com", +} + class TestCharm(TestCase): """Unit tests. @@ -67,7 +82,7 @@ def test_waiting_on_peer_relation_not_ready(self): def test_admin_ready(self): """The pebble plan is correctly generated when the charm is ready.""" harness = self.harness - simulate_lifecycle(harness) + simulate_admin_lifecycle(harness) # The plan is generated after pebble is ready. want_plan = { @@ -107,7 +122,7 @@ def test_admin_ready(self): def test_usersync_ready(self): """The pebble plan is correctly generated when the charm is ready.""" harness = self.harness - simulate_lifecycle(harness) + simulate_usersync_lifecycle(harness) harness.update_config({"charm-function": "usersync"}) # The plan is generated after pebble is ready. @@ -120,14 +135,13 @@ def test_usersync_ready(self): "startup": "enabled", "environment": { "POLICY_MGR_URL": "http://ranger-k8s:6080", - "RANGER_USERSYNC_PASSWORD": "rangerR0cks!", "SYNC_GROUP_USER_MAP_SYNC_ENABLED": True, "SYNC_GROUP_SEARCH_ENABLED": True, "SYNC_GROUP_SEARCH_BASE": "dc=canonical,dc=dev,dc=com", "SYNC_GROUP_OBJECT_CLASS": "posixGroup", "SYNC_INTERVAL": 3600000, "SYNC_LDAP_BIND_DN": "cn=admin,dc=canonical,dc=dev,dc=com", - "SYNC_LDAP_BIND_PASSWORD": "admin", + "SYNC_LDAP_BIND_PASSWORD": "huedw7uiedw7", "SYNC_LDAP_GROUP_SEARCH_SCOPE": "sub", "SYNC_LDAP_SEARCH_BASE": "dc=canonical,dc=dev,dc=com", "SYNC_LDAP_USER_SEARCH_FILTER": None, @@ -155,7 +169,7 @@ def test_usersync_ready(self): def test_config_changed(self): """The pebble plan changes according to config changes.""" harness = self.harness - simulate_lifecycle(harness) + simulate_admin_lifecycle(harness) # Update the config. self.harness.update_config({"ranger-admin-password": "secure-pass"}) @@ -178,7 +192,7 @@ def test_ingress(self): """The charm relates correctly to the nginx ingress charm.""" harness = self.harness - simulate_lifecycle(harness) + simulate_admin_lifecycle(harness) nginx_route_relation_id = harness.add_relation( "nginx-route", "ingress" @@ -200,7 +214,7 @@ def test_update_status_up(self): """The charm updates the unit status to active based on UP status.""" harness = self.harness - simulate_lifecycle(harness) + simulate_admin_lifecycle(harness) container = harness.model.unit.get_container("ranger") container.get_check = mock.Mock(status="up") @@ -215,7 +229,7 @@ def test_update_status_up(self): def test_provider(self, _create_ranger_service): """The charm relates correctly to the nginx ingress charm.""" harness = self.harness - simulate_lifecycle(harness) + simulate_admin_lifecycle(harness) rel_id = harness.add_relation("policy", "trino-k8s") harness.add_relation_unit(rel_id, "trino-k8s/0") @@ -229,8 +243,75 @@ def test_provider(self, _create_ranger_service): "service_name": "trino-service", } + def test_ldap_relation_changed(self): + """The charm uses the configuration values from ldap relation.""" + harness = self.harness + simulate_usersync_lifecycle(harness) + + got_plan = harness.get_container_pebble_plan("ranger").to_dict() + self.assertEqual( + got_plan["services"]["ranger"]["environment"]["SYNC_LDAP_URL"], + "ldap://comsys-openldap-k8s:389", + ) + self.assertEqual( + got_plan["services"]["ranger"]["environment"][ + "SYNC_GROUP_OBJECT_CLASS" + ], + "posixGroup", + ) + + def test_ldap_relation_broken(self): + """The charm enters a blocked state if no LDAP parameters.""" + harness = self.harness + rel_id = simulate_usersync_lifecycle(harness) + + data = LDAP_RELATION_BROKEN_DATA + event = make_ldap_relation_event(rel_id, data) + harness.charm.ldap._on_relation_broken(event) + self.assertEqual( + harness.model.unit.status, + BlockedStatus("Add an LDAP relation or update config values."), + ) + + def test_ldap_config_updated(self): + """The charm uses the configuration values from config relation.""" + harness = self.harness + self.test_ldap_relation_broken() + harness.update_config(USERSYNC_CONFIG_VALUES) + got_plan = harness.get_container_pebble_plan("ranger").to_dict() + self.assertEqual( + got_plan["services"]["ranger"]["environment"]["SYNC_LDAP_URL"], + "ldap://config-openldap-k8s:389", + ) + + +def simulate_usersync_lifecycle(harness): + """Simulate a healthy charm life-cycle. + + Args: + harness: ops.testing.Harness object used to simulate charm lifecycle. + + Returns: + rel_id: ldap relation id to be used for subsequent testing. + """ + # Simulate peer relation readiness. + harness.add_relation("peer", "ranger") + + # Simulate pebble readiness. + container = harness.model.unit.get_container("ranger") + harness.charm.on.ranger_pebble_ready.emit(container) + + harness.update_config({"charm-function": "usersync"}) + + # Simulate LDAP readiness. + rel_id = harness.add_relation("ldap", "comsys-openldap-k8s") + harness.add_relation_unit(rel_id, "comsys-openldap-k8s/0") + event = make_ldap_relation_event(rel_id, LDAP_RELATION_CHANGED_DATA) + harness.charm.ldap._on_relation_changed(event) + return rel_id -def simulate_lifecycle(harness): + +def simulate_admin_lifecycle(harness): """Simulate a healthy charm life-cycle. Args: @@ -248,6 +329,35 @@ def simulate_lifecycle(harness): harness.charm.postgres_relation_handler._on_database_changed(event) +def make_ldap_relation_event(rel_id, data): + """Create and return a mock policy created event. + + The event is generated by the relation with postgresql_db + + Args: + rel_id: relation id. + data: relation data. + + Returns: + Event dict. + """ + return type( + "Event", + (), + { + "app": "comsys-openldap-k8s", + "relation": type( + "Relation", + (), + { + "data": {"comsys-openldap-k8s": data}, + "id": rel_id, + }, + ), + }, + ) + + def make_policy_relation_changed_event(rel_id): """Create and return a mock database changed event.