Merge "Support LDAP config charm" into main
This commit is contained in:
commit
075d750370
@ -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]
|
||||
|
@ -44,6 +44,8 @@ requires:
|
||||
amqp:
|
||||
interface: rabbitmq
|
||||
optional: true
|
||||
domain-config:
|
||||
interface: keystone-domain-config
|
||||
|
||||
peers:
|
||||
peers:
|
||||
|
@ -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:
|
||||
|
@ -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(),
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user