Merge "Support LDAP config charm" into main

This commit is contained in:
Zuul 2023-10-20 11:49:59 +00:00 committed by Gerrit Code Review
commit 075d750370
4 changed files with 325 additions and 1 deletions
charms/keystone-k8s
lib/charms/keystone_ldap_k8s/v0
metadata.yaml
src
tests/unit

View File

@ -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]

View File

@ -44,6 +44,8 @@ requires:
amqp: amqp:
interface: rabbitmq interface: rabbitmq
optional: true optional: true
domain-config:
interface: keystone-domain-config
peers: peers:
peers: peers:

View File

@ -27,14 +27,19 @@ develop a new k8s charm using the Operator Framework:
import json import json
import logging import logging
from pathlib import (
Path,
)
from typing import ( from typing import (
Callable, Callable,
Dict,
List, List,
) )
import charms.keystone_k8s.v0.identity_credentials as sunbeam_cc_svc 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.v0.identity_resource as sunbeam_ops_svc
import charms.keystone_k8s.v1.identity_service as sunbeam_id_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.charm
import ops.pebble import ops.pebble
import ops_sunbeam.charm as sunbeam_charm 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 "fernet_max_active_keys": 4, # adjusted to make rotation daily
"public_endpoint": self.charm.public_endpoint, "public_endpoint": self.charm.public_endpoint,
"admin_endpoint": self.charm.admin_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", "log_config": "/etc/keystone/logging.conf.j2",
"paste_config_file": "/etc/keystone/keystone-paste.ini", "paste_config_file": "/etc/keystone/keystone-paste.ini",
} }
@ -168,6 +173,48 @@ class IdentityServiceProvidesHandler(sunbeam_rhandlers.RelationHandler):
return True 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): class IdentityCredentialsProvidesHandler(sunbeam_rhandlers.RelationHandler):
"""Handler for identity credentials relation.""" """Handler for identity credentials relation."""
@ -264,6 +311,7 @@ class KeystoneOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
service_name = "keystone" service_name = "keystone"
wsgi_admin_script = "/usr/bin/keystone-wsgi-admin" wsgi_admin_script = "/usr/bin/keystone-wsgi-admin"
wsgi_public_script = "/usr/bin/keystone-wsgi-public" wsgi_public_script = "/usr/bin/keystone-wsgi-public"
domain_config_dir = Path("/etc/keystone/domains")
service_port = 5000 service_port = 5000
mandatory_relations = {"database", "ingress-public"} mandatory_relations = {"database", "ingress-public"}
db_sync_cmds = [ db_sync_cmds = [
@ -724,6 +772,14 @@ export OS_AUTH_VERSION=3
) )
handlers.append(self.ops_svc) 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) return super().get_relation_handlers(handlers)
@property @property
@ -831,6 +887,69 @@ export OS_AUTH_VERSION=3
"supplied" "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: def check_outstanding_identity_ops_requests(self) -> None:
"""Check requests from identity ops relation.""" """Check requests from identity ops relation."""
for relation in self.framework.model.relations[ 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 = self.unit.get_container(self.wsgi_container_name)
container.stop("wsgi-keystone") container.stop("wsgi-keystone")
container.start("wsgi-keystone") container.start("wsgi-keystone")
self.configure_domains(event)
self._state.unit_bootstrapped = True self._state.unit_bootstrapped = True
def bootstrapped(self) -> bool: def bootstrapped(self) -> bool:

View File

@ -18,6 +18,7 @@
import json import json
import os import os
import textwrap
from unittest.mock import ( from unittest.mock import (
ANY, ANY,
MagicMock, MagicMock,
@ -516,3 +517,51 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase):
"openrc": ANY, "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(),
)