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:
|
amqp:
|
||||||
interface: rabbitmq
|
interface: rabbitmq
|
||||||
optional: true
|
optional: true
|
||||||
|
domain-config:
|
||||||
|
interface: keystone-domain-config
|
||||||
|
|
||||||
peers:
|
peers:
|
||||||
peers:
|
peers:
|
||||||
|
@ -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:
|
||||||
|
@ -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(),
|
||||||
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user