From c0e45537e8701d912f5133a09c109d5a5546f887 Mon Sep 17 00:00:00 2001 From: Liam Young Date: Mon, 25 Sep 2023 06:27:17 +0000 Subject: [PATCH] Support LDAP config charm Support ldap configuration supplied via the domain config relation. Change-Id: Ie87a00c32609ee96cb6af11264cf6726d7840f69 --- .../keystone_ldap_k8s/v0/domain_config.py | 153 ++++++++++++++++++ charms/keystone-k8s/metadata.yaml | 2 + charms/keystone-k8s/src/charm.py | 122 +++++++++++++- .../tests/unit/test_keystone_charm.py | 49 ++++++ 4 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 charms/keystone-k8s/lib/charms/keystone_ldap_k8s/v0/domain_config.py diff --git a/charms/keystone-k8s/lib/charms/keystone_ldap_k8s/v0/domain_config.py b/charms/keystone-k8s/lib/charms/keystone_ldap_k8s/v0/domain_config.py new file mode 100644 index 00000000..e87037f0 --- /dev/null +++ b/charms/keystone-k8s/lib/charms/keystone_ldap_k8s/v0/domain_config.py @@ -0,0 +1,153 @@ +"""Interface for passing domain configuration.""" + +import logging +from typing import ( + Optional, +) + +from ops.charm import ( + CharmBase, + RelationBrokenEvent, + RelationChangedEvent, + RelationEvent, +) +from ops.framework import ( + EventSource, + Object, + ObjectEvents, +) +from ops.model import ( + Relation, +) +import base64 +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "589e0b16e4164e829aa8eb232628429c" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + +class DomainConfigRequestEvent(RelationEvent): + """DomainConfigRequest Event.""" + pass + +class DomainConfigProviderEvents(ObjectEvents): + """Events class for `on`.""" + + remote_ready = EventSource(DomainConfigRequestEvent) + +class DomainConfigProvides(Object): + """DomainConfigProvides class.""" + + on = DomainConfigProviderEvents() + + def __init__(self, charm: CharmBase, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_domain_config_relation_changed, + ) + + def _on_domain_config_relation_changed( + self, event: RelationChangedEvent + ): + """Handle DomainConfig relation changed.""" + logging.debug("DomainConfig relation changed") + self.on.remote_ready.emit(event.relation) + + def set_domain_info( + self, domain_name: str, config_contents: str + ) -> None: + """Set ceilometer configuration on the relation.""" + if not self.charm.unit.is_leader(): + logging.debug("Not a leader unit, skipping set config") + return + for relation in self.relations: + relation.data[self.charm.app]["domain-name"] = domain_name + relation.data[self.charm.app]["config-contents"] = base64.b64encode(config_contents.encode()).decode() + + @property + def relations(self): + return self.framework.model.relations[self.relation_name] + +class DomainConfigChangedEvent(RelationEvent): + """DomainConfigChanged Event.""" + + pass + + +class DomainConfigGoneAwayEvent(RelationBrokenEvent): + """DomainConfigGoneAway Event.""" + + pass + + +class DomainConfigRequirerEvents(ObjectEvents): + """Events class for `on`.""" + + config_changed = EventSource(DomainConfigChangedEvent) + goneaway = EventSource(DomainConfigGoneAwayEvent) + + +class DomainConfigRequires(Object): + """DomainConfigRequires class.""" + + on = DomainConfigRequirerEvents() + + def __init__(self, charm: CharmBase, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_domain_config_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_domain_config_relation_broken, + ) + + def _on_domain_config_relation_changed( + self, event: RelationChangedEvent + ): + """Handle DomainConfig relation changed.""" + logging.debug("DomainConfig config data changed") + self.on.config_changed.emit(event.relation) + + def _on_domain_config_relation_broken( + self, event: RelationBrokenEvent + ): + """Handle DomainConfig relation changed.""" + logging.debug("DomainConfig on_broken") + self.on.goneaway.emit(event.relation) + + def get_domain_configs(self, exclude=None): + exclude = exclude or [] + configs = [] + for relation in self.relations: + if relation in exclude: + continue + try: + domain_name = relation.data[relation.app].get("domain-name") + except KeyError: + logging.debug("Key error accessing app data") + continue + raw_config_contents = relation.data[relation.app].get("config-contents") + if not all([domain_name, raw_config_contents]): + continue + configs.append({ + "domain-name": domain_name, + "config-contents": base64.b64decode(raw_config_contents).decode()}) + return configs + + @property + def relations(self): + return self.framework.model.relations[self.relation_name] + diff --git a/charms/keystone-k8s/metadata.yaml b/charms/keystone-k8s/metadata.yaml index ea4ff9e3..37b1fac6 100644 --- a/charms/keystone-k8s/metadata.yaml +++ b/charms/keystone-k8s/metadata.yaml @@ -44,6 +44,8 @@ requires: amqp: interface: rabbitmq optional: true + domain-config: + interface: keystone-domain-config peers: peers: diff --git a/charms/keystone-k8s/src/charm.py b/charms/keystone-k8s/src/charm.py index 45e07fbb..3fbfa947 100755 --- a/charms/keystone-k8s/src/charm.py +++ b/charms/keystone-k8s/src/charm.py @@ -27,14 +27,19 @@ develop a new k8s charm using the Operator Framework: import json import logging +from pathlib import ( + Path, +) from typing import ( Callable, + Dict, List, ) import charms.keystone_k8s.v0.identity_credentials as sunbeam_cc_svc import charms.keystone_k8s.v0.identity_resource as sunbeam_ops_svc import charms.keystone_k8s.v1.identity_service as sunbeam_id_svc +import charms.keystone_ldap_k8s.v0.domain_config as sunbeam_dc_svc import ops.charm import ops.pebble import ops_sunbeam.charm as sunbeam_charm @@ -126,7 +131,7 @@ class KeystoneConfigAdapter(sunbeam_contexts.ConfigContext): "fernet_max_active_keys": 4, # adjusted to make rotation daily "public_endpoint": self.charm.public_endpoint, "admin_endpoint": self.charm.admin_endpoint, - "domain_config_dir": "/etc/keystone/domains", + "domain_config_dir": self.charm.domain_config_dir, "log_config": "/etc/keystone/logging.conf.j2", "paste_config_file": "/etc/keystone/keystone-paste.ini", } @@ -168,6 +173,48 @@ class IdentityServiceProvidesHandler(sunbeam_rhandlers.RelationHandler): return True +class DomainConfigHandler(sunbeam_rhandlers.RelationHandler): + """Handler for domain config relation.""" + + def __init__( + self, + charm: ops.charm.CharmBase, + relation_name: str, + callback_f: Callable, + ): + super().__init__(charm, relation_name, callback_f) + + def setup_event_handler(self) -> ops.charm.Object: + """Configure event handlers for an Identity service relation.""" + logger.debug("Setting up Identity Service event handler") + self.dc = sunbeam_dc_svc.DomainConfigRequires( + self.charm, + self.relation_name, + ) + self.framework.observe( + self.dc.on.config_changed, + self._on_dc_config_changed, + ) + self.framework.observe( + self.dc.on.goneaway, + self._on_dc_config_changed, + ) + return self.dc + + def _on_dc_config_changed(self, event) -> Dict: + """Handles relation data changed events.""" + self.callback_f(event) + + def get_domain_configs(self, exclude=None): + """Return domain config from relations.""" + return self.dc.get_domain_configs(exclude=exclude) + + @property + def ready(self) -> bool: + """Report if relation is ready.""" + return bool(self.get_domain_configs()) + + class IdentityCredentialsProvidesHandler(sunbeam_rhandlers.RelationHandler): """Handler for identity credentials relation.""" @@ -264,6 +311,7 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): service_name = "keystone" wsgi_admin_script = "/usr/bin/keystone-wsgi-admin" wsgi_public_script = "/usr/bin/keystone-wsgi-public" + domain_config_dir = Path("/etc/keystone/domains") service_port = 5000 mandatory_relations = {"database", "ingress-public"} db_sync_cmds = [ @@ -724,6 +772,14 @@ export OS_AUTH_VERSION=3 ) handlers.append(self.ops_svc) + if self.can_add_handler("domain-config", handlers): + self.dc = DomainConfigHandler( + self, + "domain-config", + self.configure_charm, + ) + handlers.append(self.dc) + return super().get_relation_handlers(handlers) @property @@ -831,6 +887,69 @@ export OS_AUTH_VERSION=3 "supplied" ) + def remove_old_domains( + self, domain_configs: dict, container: ops.model.Container + ) -> List[str]: + """Remove domain files from domains no longer related.""" + active_domains = [c["domain-name"] for c in domain_configs] + removed_domains = [] + for domain_file in container.list_files(self.domain_config_dir): + domain_on_disk = domain_file.name.split(".")[1] + if domain_on_disk in active_domains: + logger.debug("Keeping {}".format(domain_file.name)) + else: + container.remove_path(domain_file.path) + removed_domains.append(domain_on_disk) + return removed_domains + + def update_domain_config( + self, domain_configs: dict, container: ops.model.Container + ) -> List[str]: + """Update domain configuration.""" + updated_domains = [] + for domain_config in domain_configs: + domain_name = domain_config["domain-name"] + domain = self.keystone_manager.ksclient.get_domain_object( + domain_name + ) + if not domain: + self.keystone_manager.ksclient.create_domain(name=domain_name) + domain_config_file = ( + self.domain_config_dir / f"keystone.{domain_name}.conf" + ) + try: + original_contents = container.pull(domain_config_file).read() + except (ops.pebble.PathError, FileNotFoundError): + original_contents = None + if original_contents != domain_config["config-contents"]: + container.push( + domain_config_file, + domain_config["config-contents"], + **{ + "user": "keystone", + "group": "keystone", + "permissions": 0o600, + }, + ) + updated_domains.append(domain_name) + return updated_domains + + def configure_domains(self, event: ops.framework.EventBase = None) -> None: + """Configure LDAP backed domains.""" + if isinstance(event, sunbeam_dc_svc.DomainConfigGoneAwayEvent): + exclude = [event.relation] + else: + exclude = [] + container = self.unit.get_container(KEYSTONE_CONTAINER) + if not container.isdir(self.domain_config_dir): + container.make_dir(self.domain_config_dir, make_parents=True) + domain_configs = self.dc.get_domain_configs(exclude=exclude) + removed_domains = self.remove_old_domains(domain_configs, container) + updated_domains = self.update_domain_config(domain_configs, container) + if removed_domains or updated_domains: + ph = self.get_named_pebble_handler(KEYSTONE_CONTAINER) + ph.start_all(restart=True) + def check_outstanding_identity_ops_requests(self) -> None: """Check requests from identity ops relation.""" for relation in self.framework.model.relations[ @@ -1340,6 +1459,7 @@ export OS_AUTH_VERSION=3 container = self.unit.get_container(self.wsgi_container_name) container.stop("wsgi-keystone") container.start("wsgi-keystone") + self.configure_domains(event) self._state.unit_bootstrapped = True def bootstrapped(self) -> bool: diff --git a/charms/keystone-k8s/tests/unit/test_keystone_charm.py b/charms/keystone-k8s/tests/unit/test_keystone_charm.py index 73a2d625..d6fdf0f4 100644 --- a/charms/keystone-k8s/tests/unit/test_keystone_charm.py +++ b/charms/keystone-k8s/tests/unit/test_keystone_charm.py @@ -18,6 +18,7 @@ import json import os +import textwrap from unittest.mock import ( ANY, MagicMock, @@ -516,3 +517,51 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase): "openrc": ANY, } ) + + def test_domain_config(self): + """Test domain config.""" + test_utils.add_complete_ingress_relation(self.harness) + self.harness.set_leader() + rel_id = self.harness.add_relation("peers", "keystone-k8s") + self.harness.add_relation_unit(rel_id, "keystone-k8s/1") + self.harness.container_pebble_ready("keystone") + test_utils.add_db_relation_credentials( + self.harness, test_utils.add_base_db_relation(self.harness) + ) + dc_id = self.harness.add_relation("domain-config", "keystone-ldap-k8s") + self.harness.add_relation_unit(dc_id, "keystone-ldap-k8s/0") + b64file = ( + "W2xkYXBdCmdyb3VwX21lbWJlcl9hdHRyaWJ1dGUgPSBtZW1iZXJVaWQKZ3JvdXBf" + "bWVtYmVyc19hcmVfaWRzID0gdHJ1ZQpncm91cF9uYW1lX2F0dHJpYnV0ZSA9IGNu" + "Cmdyb3VwX29iamVjdGNsYXNzID0gcG9zaXhHcm91cApncm91cF90cmVlX2RuID0g" + "b3U9Z3JvdXBzLGRjPXRlc3QsZGM9Y29tCnBhc3N3b3JkID0gY3JhcHBlcgpzdWZm" + "aXggPSBkYz10ZXN0LGRjPWNvbQp1cmwgPSBsZGFwOi8vMTAuMS4xNzYuMTg0CnVz" + "ZXIgPSBjbj1hZG1pbixkYz10ZXN0LGRjPWNvbQpbaWRlbnRpdHldCmRyaXZlciA9" + "IGxkYXA=" + ) + domain_config = { + "domain-name": "mydomain", + "config-contents": b64file, + } + self.harness.update_relation_data( + dc_id, "keystone-ldap-k8s", domain_config + ) + expect_entries = """ + [ldap] + group_member_attribute = memberUid + group_members_are_ids = true + group_name_attribute = cn + group_objectclass = posixGroup + group_tree_dn = ou=groups,dc=test,dc=com + password = crapper + suffix = dc=test,dc=com + url = ldap://10.1.176.184 + user = cn=admin,dc=test,dc=com + [identity] + driver = ldap""" + self.maxDiff = None + self.check_file( + "keystone", + "/etc/keystone/domains/keystone.mydomain.conf", + contents=textwrap.dedent(expect_entries).lstrip(), + )