diff --git a/lib/charms/data_platform_libs/v0/data_interfaces.py b/lib/charms/data_platform_libs/v0/data_interfaces.py index 7314689c38..4f3e79a2cd 100644 --- a/lib/charms/data_platform_libs/v0/data_interfaces.py +++ b/lib/charms/data_platform_libs/v0/data_interfaces.py @@ -16,7 +16,7 @@ This library contains the Requires and Provides classes for handling the relation between an application and multiple managed application supported by the data-team: -MySQL, Postgresql, MongoDB, Redis, and Kafka. +MySQL, Postgresql, MongoDB, Redis, Kafka, and Karapace. ### Database (MySQL, Postgresql, MongoDB, and Redis) @@ -34,6 +34,7 @@ from charms.data_platform_libs.v0.data_interfaces import ( DatabaseCreatedEvent, DatabaseRequires, + DatabaseEntityCreatedEvent, ) class ApplicationCharm(CharmBase): @@ -45,6 +46,7 @@ def __init__(self, *args): # Charm events defined in the database requires charm library. self.database = DatabaseRequires(self, relation_name="database", database_name="database") self.framework.observe(self.database.on.database_created, self._on_database_created) + self.framework.observe(self.database.on.database_entity_created, self._on_database_entity_created) def _on_database_created(self, event: DatabaseCreatedEvent) -> None: # Handle the created database @@ -61,12 +63,17 @@ def _on_database_created(self, event: DatabaseCreatedEvent) -> None: # Set active status self.unit.status = ActiveStatus("received database credentials") + + def _on_database_entity_created(self, event: DatabaseEntityCreatedEvent) -> None: + # Handle the created entity + ... ``` As shown above, the library provides some custom events to handle specific situations, which are listed below: - database_created: event emitted when the requested database is created. +- database_entity_created: event emitted when the requested entity is created. - endpoints_changed: event emitted when the read/write endpoints of the database have changed. - read_only_endpoints_changed: event emitted when the read-only endpoints of the database have changed. Event is not triggered if read/write endpoints changed too. @@ -141,7 +148,6 @@ def _on_cluster2_database_created(self, event: DatabaseCreatedEvent) -> None: event.endpoints, ) ... - ``` When it's needed to check whether a plugin (extension) is enabled on the PostgreSQL @@ -154,7 +160,6 @@ def _on_cluster2_database_created(self, event: DatabaseCreatedEvent) -> None: charm: charm-binary-python-packages: - psycopg[binary] - ``` ### Provider Charm @@ -187,6 +192,7 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: self.provided_database.set_credentials(event.relation.id, username, password) # set other variables for the relation event.set_tls("False") ``` + As shown above, the library provides a custom event (database_requested) to handle the situation when an application charm requests a new database to be created. It's preferred to subscribe to this event instead of relation changed event to avoid @@ -207,6 +213,7 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: BootstrapServerChangedEvent, KafkaRequires, TopicCreatedEvent, + TopicEntityCreatedEvent, ) class ApplicationCharm(CharmBase): @@ -220,6 +227,9 @@ def __init__(self, *args): self.framework.observe( self.kafka.on.topic_created, self._on_kafka_topic_created ) + self.framework.observe( + self.kafka.on.topic_entity_created, self._on_kafka_topic_entity_created + ) def _on_kafka_bootstrap_server_changed(self, event: BootstrapServerChangedEvent): # Event triggered when a bootstrap server was changed for this application @@ -238,6 +248,9 @@ def _on_kafka_topic_created(self, event: TopicCreatedEvent): zookeeper_uris = event.zookeeper_uris ... + def _on_kafka_topic_entity_created(self, event: TopicEntityCreatedEvent): + # Event triggered when an entity was created for this application + ... ``` As shown above, the library provides some custom events to handle specific situations, @@ -268,6 +281,7 @@ def __init__(self, *args): # Charm events defined in the Kafka Provides charm library. self.kafka_provider = KafkaProvides(self, relation_name="kafka_client") self.framework.observe(self.kafka_provider.on.topic_requested, self._on_topic_requested) + self.framework.observe(self.kafka_provider.on.topic_entity_requested, self._on_entity_requested) # Kafka generic helper self.kafka = KafkaHelper() @@ -283,12 +297,114 @@ def _on_topic_requested(self, event: TopicRequestedEvent): self.kafka_provider.set_tls(relation_id, "False") self.kafka_provider.set_zookeeper_uris(relation_id, ...) + def _on_entity_requested(self, event: EntityRequestedEvent): + # Handle the on_topic_entity_requested event. + ... ``` As shown above, the library provides a custom event (topic_requested) to handle the situation when an application charm requests a new topic to be created. It is preferred to subscribe to this event instead of relation changed event to avoid creating a new topic when other information other than a topic name is exchanged in the relation databag. + +### Karapace + +This library is the interface to use and interact with the Karapace charm. This library contains +custom events that add convenience to manage Karapace, and provides methods to consume the +application related data. + +#### Requirer Charm + +```python + +from charms.data_platform_libs.v0.data_interfaces import ( + EndpointsChangedEvent, + KarapaceRequires, + SubjectAllowedEvent, +) + +class ApplicationCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.karapace = KarapaceRequires(self, relation_name="karapace_client", subject="test-subject") + self.framework.observe( + self.karapace.on.server_changed, self._on_karapace_server_changed + ) + self.framework.observe( + self.karapace.on.subject_allowed, self._on_karapace_subject_allowed + ) + self.framework.observe( + self.karapace.on.subject_entity_created, self._on_subject_entity_created + ) + + + def _on_karapace_server_changed(self, event: EndpointsChangedEvent): + # Event triggered when a server endpoint was changed for this application + new_server = event.endpoints + ... + + def _on_karapace_subject_allowed(self, event: SubjectAllowedEvent): + # Event triggered when a subject was allowed for this application + username = event.username + password = event.password + tls = event.tls + endpoints = event.endpoints + ... + + def _on_subject_entity_created(self, event: SubjectEntityCreatedEvent): + # Event triggered when a subject entity was created this application + entity_name = event.entity_name + entity_password = event.entity_password + ... +``` + +As shown above, the library provides some custom events to handle specific situations, +which are listed below: + +- subject_allowed: event emitted when the requested subject is allowed. +- server_changed: event emitted when the server endpoints have changed. + +#### Provider Charm + +Following the previous example, this is an example of the provider charm. + +```python +class SampleCharm(CharmBase): + +from charms.data_platform_libs.v0.data_interfaces import ( + KarapaceProvides, + SubjectRequestedEvent, +) + + def __init__(self, *args): + super().__init__(*args) + + # Default charm events. + self.framework.observe(self.on.start, self._on_start) + + # Charm events defined in the Karapace Provides charm library. + self.karapace_provider = KarapaceProvides(self, relation_name="karapace_client") + self.framework.observe(self.karapace_provider.on.subject_requested, self._on_subject_requested) + # Karapace generic helper + self.karapace = KarapaceHelper() + + def _on_subject_requested(self, event: SubjectRequestedEvent): + # Handle the on_subject_requested event. + + subject = event.subject + relation_id = event.relation.id + # set connection info in the databag relation + self.karapace_provider.set_endpoint(relation_id, self.karapace.get_endpoint()) + self.karapace_provider.set_credentials(relation_id, username=username, password=password) + self.karapace_provider.set_tls(relation_id, "False") +``` + +As shown above, the library provides a custom event (subject_requested) to handle +the situation when an application charm requests a new subject to be created. +It is preferred to subscribe to this event instead of relation changed event to avoid +creating a new subject when other information other than a subject name is +exchanged in the relation databag. """ import copy @@ -301,6 +417,7 @@ def _on_topic_requested(self, event: TopicRequestedEvent): from typing import ( Callable, Dict, + Final, ItemsView, KeysView, List, @@ -331,7 +448,7 @@ def _on_topic_requested(self, event: TopicRequestedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 47 +LIBPATCH = 54 PYDEPS = ["ops>=2.0.0"] @@ -349,6 +466,8 @@ def _on_topic_requested(self, event: TopicRequestedEvent): changed - keys that still exist but have new values deleted - key that were deleted""" +ENTITY_USER = "USER" +ENTITY_GROUP = "GROUP" PROV_SECRET_PREFIX = "secret-" PROV_SECRET_FIELDS = "provided-secrets" @@ -587,6 +706,7 @@ def __init__(self): self.USER = SecretGroup("user") self.TLS = SecretGroup("tls") self.MTLS = SecretGroup("mtls") + self.ENTITY = SecretGroup("entity") self.EXTRA = SecretGroup("extra") def __setattr__(self, name, value): @@ -953,7 +1073,7 @@ def get(self, key: str, default: Optional[str] = None) -> Optional[str]: class Data(ABC): - """Base relation data mainpulation (abstract) class.""" + """Base relation data manipulation (abstract) class.""" SCOPE = Scope.APP @@ -966,6 +1086,8 @@ class Data(ABC): "tls": SECRET_GROUPS.TLS, "tls-ca": SECRET_GROUPS.TLS, "mtls-cert": SECRET_GROUPS.MTLS, + "entity-name": SECRET_GROUPS.ENTITY, + "entity-password": SECRET_GROUPS.ENTITY, } SECRET_FIELDS = [] @@ -1729,6 +1851,24 @@ def set_credentials(self, relation_id: int, username: str, password: str) -> Non """ self.update_relation_data(relation_id, {"username": username, "password": password}) + def set_entity_credentials( + self, relation_id: int, entity_name: str, entity_password: Optional[str] = None + ) -> None: + """Set entity credentials. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + entity_name: name of the created entity + entity_password: password of the created entity. + """ + self.update_relation_data( + relation_id, + {"entity-name": entity_name, "entity-password": entity_password}, + ) + def set_tls(self, relation_id: int, tls: str) -> None: """Set whether TLS is enabled. @@ -1766,7 +1906,16 @@ def _load_secrets_from_databag(self, relation: Relation) -> None: class RequirerData(Data): """Requirer-side of the relation.""" - SECRET_FIELDS = ["username", "password", "tls", "tls-ca", "uris", "read-only-uris"] + SECRET_FIELDS = [ + "username", + "password", + "tls", + "tls-ca", + "uris", + "read-only-uris", + "entity-name", + "entity-password", + ] def __init__( self, @@ -1774,10 +1923,34 @@ def __init__( relation_name: str, extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, + requested_entity_secret: Optional[str] = None, + requested_entity_name: Optional[str] = None, + requested_entity_password: Optional[str] = None, ): """Manager of base client relations.""" super().__init__(model, relation_name) self.extra_user_roles = extra_user_roles + self.extra_group_roles = extra_group_roles + self.entity_type = entity_type + self.entity_permissions = entity_permissions + self.requested_entity_secret = requested_entity_secret + self.requested_entity_name = requested_entity_name + self.requested_entity_password = requested_entity_password + + if ( + self.requested_entity_secret or self.requested_entity_name + ) and not self.secrets_enabled: + raise SecretsUnavailableError("Secrets unavailable on current Juju version") + + if self.requested_entity_secret and self.requested_entity_name: + raise IllegalOperationError("Unable to use provided and automated entity name secret") + + self._validate_entity_type() + self._validate_entity_permissions() + self._remote_secret_fields = list(self.SECRET_FIELDS) self._local_secret_fields = [ field @@ -1788,18 +1961,52 @@ def __init__( self._remote_secret_fields += additional_secret_fields self.data_component = self.local_unit - # Internal helper functions + # Internal functions def _is_resource_created_for_relation(self, relation: Relation) -> bool: if not relation.app: return False - data = self.fetch_relation_data([relation.id], ["username", "password"]).get( - relation.id, {} + data = self.fetch_relation_data( + [relation.id], + ["username", "password", "entity-name", "entity-password"], + ).get(relation.id, {}) + + return any( + [ + all(bool(data.get(field)) for field in ("username", "password")), + all(bool(data.get(field)) for field in ("entity-name",)), + ] ) - return bool(data.get("username")) and bool(data.get("password")) + + def _validate_entity_type(self) -> None: + """Validates the consistency of the provided entity-type and its extra roles.""" + if self.entity_type and self.entity_type not in {ENTITY_USER, ENTITY_GROUP}: + raise ValueError("Invalid entity-type. Possible values are USER and GROUP") + + if self.entity_type == ENTITY_USER and self.extra_group_roles: + raise ValueError("Inconsistent entity information. Use extra_user_roles instead") + + if self.entity_type == ENTITY_GROUP and self.extra_user_roles: + raise ValueError("Inconsistent entity information. Use extra_group_roles instead") + + def _validate_entity_permissions(self) -> None: + """Validates whether the provided entity permissions follow the right JSON format.""" + if not self.entity_permissions: + return + + accepted_keys = {"resource_name", "resource_type", "privileges"} + + try: + permissions = json.loads(self.entity_permissions) + for permission in permissions: + if permission.keys() != accepted_keys: + raise ValueError("Invalid entity permissions format. See accepted keys") + except json.decoder.JSONDecodeError: + raise ValueError("Invalid entity permissions format. It must be JSON format") # Public functions + def is_resource_created(self, relation_id: Optional[int] = None) -> bool: """Check if the resource has been created. @@ -1856,6 +2063,26 @@ def __init__(self, charm: CharmBase, relation_data: RequirerData, unique_key: st """Manager of base client relations.""" super().__init__(charm, relation_data, unique_key) + def _main_credentials_shared(self, diff: Diff) -> bool: + """Whether the relation data-bag contains username / password keys.""" + user_secret = self.relation_data._generate_secret_field_name(SECRET_GROUPS.USER) + return any( + [ + user_secret in diff.added, + "username" in diff.added and "password" in diff.added, + ] + ) + + def _entity_credentials_shared(self, diff: Diff) -> bool: + """Whether the relation data-bag contains rolename / password keys.""" + entity_secret = self.relation_data._generate_secret_field_name(SECRET_GROUPS.ENTITY) + return any( + [ + entity_secret in diff.added, + "entity-name" in diff.added, + ] + ) + # Event handlers def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: @@ -1902,6 +2129,21 @@ def __init__(self, charm: CharmBase, relation_data: ProviderData, unique_key: st """Manager of base client relations.""" super().__init__(charm, relation_data, unique_key) + @staticmethod + def _validate_entity_consistency(event: RelationEvent, diff: Diff) -> None: + """Validates that entity information is not changed after relation is established. + + - When entity-type changes, backwards compatibility is broken. + - When extra-user-roles changes, role membership checks become incredibly complex. + - When extra-group-roles changes, role membership checks become incredibly complex. + """ + if not isinstance(event, RelationChangedEvent): + return + + for key in ["entity-type", "extra-user-roles", "extra-group-roles"]: + if key in diff.changed: + raise ValueError(f"Cannot change {key} after relation has already been created") + # Event handlers def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: @@ -1931,7 +2173,6 @@ def __init__( self, model, relation_name: str, - extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], additional_secret_group_mapping: Dict[str, str] = {}, secret_field_name: Optional[str] = None, @@ -1939,10 +2180,9 @@ def __init__( ): RequirerData.__init__( self, - model, - relation_name, - extra_user_roles, - additional_secret_fields, + model=model, + relation_name=relation_name, + additional_secret_fields=additional_secret_fields, ) self.secret_field_name = secret_field_name if secret_field_name else self.SECRET_FIELD_NAME self.deleted_label = deleted_label @@ -2006,6 +2246,7 @@ def current_secret_fields(self) -> List[str]: SECRET_GROUPS.get_group("user"), SECRET_GROUPS.get_group("tls"), SECRET_GROUPS.get_group("mtls"), + SECRET_GROUPS.get_group("entity"), ] for group in SECRET_GROUPS.groups(): if group in ignores: @@ -2458,7 +2699,6 @@ def __init__( self, charm, relation_name: str, - extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], additional_secret_group_mapping: Dict[str, str] = {}, secret_field_name: Optional[str] = None, @@ -2469,7 +2709,6 @@ def __init__( self, charm.model, relation_name, - extra_user_roles, additional_secret_fields, additional_secret_group_mapping, secret_field_name, @@ -2494,7 +2733,6 @@ def __init__( self, charm, relation_name: str, - extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], additional_secret_group_mapping: Dict[str, str] = {}, secret_field_name: Optional[str] = None, @@ -2505,7 +2743,6 @@ def __init__( self, charm.model, relation_name, - extra_user_roles, additional_secret_fields, additional_secret_group_mapping, secret_field_name, @@ -2548,7 +2785,6 @@ def __init__( unit: Unit, charm: CharmBase, relation_name: str, - extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], additional_secret_group_mapping: Dict[str, str] = {}, secret_field_name: Optional[str] = None, @@ -2559,7 +2795,6 @@ def __init__( unit, charm.model, relation_name, - extra_user_roles, additional_secret_fields, additional_secret_group_mapping, secret_field_name, @@ -2575,18 +2810,6 @@ def __init__( # Generic events -class ExtraRoleEvent(RelationEvent): - """Base class for data events.""" - - @property - def extra_user_roles(self) -> Optional[str]: - """Returns the extra user roles that were requested.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("extra-user-roles") - - class RelationEventWithSecret(RelationEvent): """Base class for Relation Events that need to handle secrets.""" @@ -2618,6 +2841,76 @@ def secrets_enabled(self): return JujuVersion.from_environ().has_secrets +class EntityProvidesEvent(RelationEvent): + """Base class for data events.""" + + @property + def extra_user_roles(self) -> Optional[str]: + """Returns the extra user roles that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("extra-user-roles") + + @property + def extra_group_roles(self) -> Optional[str]: + """Returns the extra group roles that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("extra-group-roles") + + @property + def entity_type(self) -> Optional[str]: + """Returns the entity_type that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("entity-type") + + @property + def entity_permissions(self) -> Optional[str]: + """Returns the entity_permissions that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("entity-permissions") + + +class EntityRequiresEvent(RelationEventWithSecret): + """Base class for authentication fields for events. + + The amount of logic added here is not ideal -- but this was the only way to preserve + the interface when moving to Juju Secrets + """ + + @property + def entity_name(self) -> Optional[str]: + """Returns the name for the created entity.""" + if not self.relation.app: + return None + + if self.secrets_enabled: + secret = self._get_secret("entity") + if secret: + return secret.get("entity-name") + + return self.relation.data[self.relation.app].get("entity-name") + + @property + def entity_password(self) -> Optional[str]: + """Returns the password for the created entity.""" + if not self.relation.app: + return None + + if self.secrets_enabled: + secret = self._get_secret("entity") + if secret: + return secret.get("entity-password") + + return self.relation.data[self.relation.app].get("entity-password") + + class AuthenticationEvent(RelationEventWithSecret): """Base class for authentication fields for events. @@ -2693,9 +2986,17 @@ def database(self) -> Optional[str]: return self.relation.data[self.relation.app].get("database") -class DatabaseRequestedEvent(DatabaseProvidesEvent, ExtraRoleEvent): +class DatabaseRequestedEvent(DatabaseProvidesEvent): """Event emitted when a new database is requested for use on this relation.""" + @property + def extra_user_roles(self) -> Optional[str]: + """Returns the extra user roles that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("extra-user-roles") + @property def external_node_connectivity(self) -> bool: """Returns the requested external_node_connectivity field.""" @@ -2707,6 +3008,24 @@ def external_node_connectivity(self) -> bool: == "true" ) + @property + def requested_entity_secret_content(self) -> Optional[dict[str, Optional[str]]]: + """Returns the content of the requested entity secret.""" + names = None + if secret_uri := self.relation.data[self.relation.app].get("requested-entity-secret"): + secret = self.framework.model.get_secret(id=secret_uri) + if content := secret.get_content(refresh=True): + names = {key: val if val != "None" else None for key, val in content.items()} + return names + + +class DatabaseEntityRequestedEvent(DatabaseProvidesEvent, EntityProvidesEvent): + """Event emitted when a new entity is requested for use on this relation.""" + + +class DatabaseEntityPermissionsChangedEvent(DatabaseProvidesEvent, EntityProvidesEvent): + """Event emitted when existing entity permissions are changed on this relation.""" + class DatabaseProvidesEvents(CharmEvents): """Database events. @@ -2715,6 +3034,8 @@ class DatabaseProvidesEvents(CharmEvents): """ database_requested = EventSource(DatabaseRequestedEvent) + database_entity_requested = EventSource(DatabaseEntityRequestedEvent) + database_entity_permissions_changed = EventSource(DatabaseEntityPermissionsChangedEvent) class DatabaseRequiresEvent(RelationEventWithSecret): @@ -2808,6 +3129,10 @@ class DatabaseCreatedEvent(AuthenticationEvent, DatabaseRequiresEvent): """Event emitted when a new database is created for use on this relation.""" +class DatabaseEntityCreatedEvent(EntityRequiresEvent, DatabaseRequiresEvent): + """Event emitted when a new entity is created for use on this relation.""" + + class DatabaseEndpointsChangedEvent(AuthenticationEvent, DatabaseRequiresEvent): """Event emitted when the read/write endpoints are changed.""" @@ -2823,6 +3148,7 @@ class DatabaseRequiresEvents(CharmEvents): """ database_created = EventSource(DatabaseCreatedEvent) + database_entity_created = EventSource(DatabaseEntityCreatedEvent) endpoints_changed = EventSource(DatabaseEndpointsChangedEvent) read_only_endpoints_changed = EventSource(DatabaseReadOnlyEndpointsChangedEvent) @@ -2944,16 +3270,47 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: # Leader only if not self.relation_data.local_unit.is_leader(): return + # Check which data has changed to emit customs events. diff = self._diff(event) - # Emit a database requested event if the setup key (database name and optional - # extra user roles) was added to the relation databag by the application. - if "database" in diff.added: + # Validate entity information is not dynamically changed + self._validate_entity_consistency(event, diff) + + # Emit a database requested event if the setup key (database name) + # was added to the relation databag, but the entity-type key was not. + if "database" in diff.added and "entity-type" not in diff.added: getattr(self.on, "database_requested").emit( event.relation, app=event.app, unit=event.unit ) + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit an entity requested event if the setup key (database name) + # was added to the relation databag, in addition to the entity-type key. + if "database" in diff.added and "entity-type" in diff.added: + getattr(self.on, "database_entity_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit a permissions changed event if the setup key (database name) + # was added to the relation databag, and the entity-permissions key changed. + if ( + "database" not in diff.added + and "entity-type" not in diff.added + and ("entity-permissions" in diff.added or "entity-permissions" in diff.changed) + ): + getattr(self.on, "database_entity_permissions_changed").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + def _on_secret_changed_event(self, event: SecretChangedEvent) -> None: """Event emitted when the secret has changed.""" pass @@ -2979,9 +3336,26 @@ def __init__( relations_aliases: Optional[List[str]] = None, additional_secret_fields: Optional[List[str]] = [], external_node_connectivity: bool = False, + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, + requested_entity_secret: Optional[str] = None, + requested_entity_name: Optional[str] = None, + requested_entity_password: Optional[str] = None, ): """Manager of database client relations.""" - super().__init__(model, relation_name, extra_user_roles, additional_secret_fields) + super().__init__( + model, + relation_name, + extra_user_roles, + additional_secret_fields, + extra_group_roles, + entity_type, + entity_permissions, + requested_entity_secret, + requested_entity_name, + requested_entity_password, + ) self.database = database_name self.relations_aliases = relations_aliases self.external_node_connectivity = external_node_connectivity @@ -3064,9 +3438,17 @@ def __init__( if self.relation_data.relations_aliases: for relation_alias in self.relation_data.relations_aliases: - self.on.define_event(f"{relation_alias}_database_created", DatabaseCreatedEvent) self.on.define_event( - f"{relation_alias}_endpoints_changed", DatabaseEndpointsChangedEvent + f"{relation_alias}_database_created", + DatabaseCreatedEvent, + ) + self.on.define_event( + f"{relation_alias}_database_entity_created", + DatabaseEntityCreatedEvent, + ) + self.on.define_event( + f"{relation_alias}_endpoints_changed", + DatabaseEndpointsChangedEvent, ) self.on.define_event( f"{relation_alias}_read_only_endpoints_changed", @@ -3156,6 +3538,32 @@ def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: if self.relation_data.extra_user_roles: event_data["extra-user-roles"] = self.relation_data.extra_user_roles + if self.relation_data.extra_group_roles: + event_data["extra-group-roles"] = self.relation_data.extra_group_roles + if self.relation_data.entity_type: + event_data["entity-type"] = self.relation_data.entity_type + if self.relation_data.entity_permissions: + event_data["entity-permissions"] = self.relation_data.entity_permissions + if self.relation_data.requested_entity_secret: + event_data["requested-entity-secret"] = self.relation_data.requested_entity_secret + + # Create helper secret if needed + if ( + self.relation_data.requested_entity_name + and "requested-entity-secret" not in event_data + ): + secret = self.charm.app.add_secret( + { + self.relation_data.requested_entity_name: str( + self.relation_data.requested_entity_password + ) + }, + label=f"{self.charm.app}-requested-entity", + ) + secret.grant(event.relation) + if not secret.id: + raise SecretError("Secret helper missing Id") + event_data["requested-entity-secret"] = secret.id # set external-node-connectivity field if self.relation_data.external_node_connectivity: @@ -3163,6 +3571,19 @@ def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: self.relation_data.update_relation_data(event.relation.id, event_data) + def _clear_helper_secret(self, event: RelationChangedEvent, app_databag: Dict) -> None: + """Remove helper secret if set.""" + if ( + self.relation_data.local_unit.is_leader() + and self.relation_data.requested_entity_name + and (secret_uri := app_databag.get("requested-entity-secret")) + ): + try: + secret = self.framework.model.get_secret(id=secret_uri) + secret.remove_all_revisions() + except ModelError: + logger.debug("Unable to remove helper secret") + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: """Event emitted when the database relation has changed.""" is_subordinate = False @@ -3174,10 +3595,7 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: is_subordinate = event.relation.data[key].get("subordinated") == "true" if is_subordinate: - if not remote_unit_data: - return - - if remote_unit_data.get("state") != "ready": + if not remote_unit_data or remote_unit_data.get("state") != "ready": return # Check which data has changed to emit customs events. @@ -3187,12 +3605,13 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): self.relation_data._register_secrets_to_relation(event.relation, diff.added) + app_databag = get_encoded_dict(event.relation, event.app, "data") + if app_databag is None: + app_databag = {} + # Check if the database is created # (the database charm shared the credentials). - secret_field_user = self.relation_data._generate_secret_field_name(SECRET_GROUPS.USER) - if ( - "username" in diff.added and "password" in diff.added - ) or secret_field_user in diff.added: + if self._main_credentials_shared(diff) and "entity-type" not in app_databag: # Emit the default event (the one without an alias). logger.info("database created at %s", datetime.now()) getattr(self.on, "database_created").emit( @@ -3201,9 +3620,23 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: # Emit the aliased event (if any). self._emit_aliased_event(event, "database_created") + self._clear_helper_secret(event, app_databag) + + # To avoid unnecessary application restarts do not trigger other events. + return + + if self._entity_credentials_shared(diff) and "entity-type" in app_databag: + # Emit the default event (the one without an alias). + logger.info("entity created at %s", datetime.now()) + getattr(self.on, "database_entity_created").emit( + event.relation, app=event.app, unit=event.unit + ) + + # Emit the aliased event (if any). + self._emit_aliased_event(event, "database_entity_created") + self._clear_helper_secret(event, app_databag) - # To avoid unnecessary application restarts do not trigger - # “endpoints_changed“ event if “database_created“ is triggered. + # To avoid unnecessary application restarts do not trigger other events. return # Emit an endpoints changed event if the database @@ -3218,8 +3651,7 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: # Emit the aliased event (if any). self._emit_aliased_event(event, "endpoints_changed") - # To avoid unnecessary application restarts do not trigger - # “read_only_endpoints_changed“ event if “endpoints_changed“ is triggered. + # To avoid unnecessary application restarts do not trigger other events. return # Emit a read only endpoints changed event if the database @@ -3247,6 +3679,12 @@ def __init__( relations_aliases: Optional[List[str]] = None, additional_secret_fields: Optional[List[str]] = [], external_node_connectivity: bool = False, + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, + requested_entity_secret: Optional[str] = None, + requested_entity_name: Optional[str] = None, + requested_entity_password: Optional[str] = None, ): DatabaseRequirerData.__init__( self, @@ -3257,6 +3695,12 @@ def __init__( relations_aliases, additional_secret_fields, external_node_connectivity, + extra_group_roles, + entity_type, + entity_permissions, + requested_entity_secret, + requested_entity_name, + requested_entity_password, ) DatabaseRequirerEventHandlers.__init__(self, charm, self) @@ -3322,9 +3766,25 @@ def restore(self, snapshot): self.old_mtls_cert = snapshot["old_mtls_cert"] -class TopicRequestedEvent(KafkaProvidesEvent, ExtraRoleEvent): +class TopicRequestedEvent(KafkaProvidesEvent): """Event emitted when a new topic is requested for use on this relation.""" + @property + def extra_user_roles(self) -> Optional[str]: + """Returns the extra user roles that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("extra-user-roles") + + +class TopicEntityRequestedEvent(KafkaProvidesEvent, EntityProvidesEvent): + """Event emitted when a new entity is requested for use on this relation.""" + + +class TopicEntityPermissionsChangedEvent(KafkaProvidesEvent, EntityProvidesEvent): + """Event emitted when existing entity permissions are changed on this relation.""" + class KafkaProvidesEvents(CharmEvents): """Kafka events. @@ -3333,6 +3793,8 @@ class KafkaProvidesEvents(CharmEvents): """ topic_requested = EventSource(TopicRequestedEvent) + topic_entity_requested = EventSource(TopicEntityRequestedEvent) + topic_entity_permissions_changed = EventSource(TopicEntityPermissionsChangedEvent) mtls_cert_updated = EventSource(KafkaClientMtlsCertUpdatedEvent) @@ -3376,6 +3838,10 @@ class TopicCreatedEvent(AuthenticationEvent, KafkaRequiresEvent): """Event emitted when a new topic is created for use on this relation.""" +class TopicEntityCreatedEvent(EntityRequiresEvent, KafkaRequiresEvent): + """Event emitted when a new entity is created for use on this relation.""" + + class BootstrapServerChangedEvent(AuthenticationEvent, KafkaRequiresEvent): """Event emitted when the bootstrap server is changed.""" @@ -3387,6 +3853,7 @@ class KafkaRequiresEvents(CharmEvents): """ topic_created = EventSource(TopicCreatedEvent) + topic_entity_created = EventSource(TopicEntityCreatedEvent) bootstrap_server_changed = EventSource(BootstrapServerChangedEvent) @@ -3465,13 +3932,43 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: # Check which data has changed to emit customs events. diff = self._diff(event) - # Emit a topic requested event if the setup key (topic name and optional - # extra user roles) was added to the relation databag by the application. - if "topic" in diff.added: + # Validate entity information is not dynamically changed + self._validate_entity_consistency(event, diff) + + # Emit a topic requested event if the setup key (topic name) + # was added to the relation databag, but the entity-type key was not. + if "topic" in diff.added and "entity-type" not in diff.added: getattr(self.on, "topic_requested").emit( event.relation, app=event.app, unit=event.unit ) + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit an entity requested event if the setup key (topic name) + # was added to the relation databag, in addition to the entity-type key. + if "topic" in diff.added and "entity-type" in diff.added: + getattr(self.on, "topic_entity_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit a permissions changed event if the setup key (topic name) + # was added to the relation databag, and the entity-permissions key changed. + if ( + "topic" not in diff.added + and "entity-type" not in diff.added + and ("entity-permissions" in diff.added or "entity-permissions" in diff.changed) + ): + getattr(self.on, "topic_entity_permissions_changed").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + def _on_secret_changed_event(self, event: SecretChangedEvent): """Event notifying about a new value of a secret.""" if not event.secret.label: @@ -3520,13 +4017,29 @@ def __init__( consumer_group_prefix: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], mtls_cert: Optional[str] = None, + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, ): """Manager of Kafka client relations.""" - super().__init__(model, relation_name, extra_user_roles, additional_secret_fields) + super().__init__( + model, + relation_name, + extra_user_roles, + additional_secret_fields, + extra_group_roles, + entity_type, + entity_permissions, + ) self.topic = topic self.consumer_group_prefix = consumer_group_prefix or "" self.mtls_cert = mtls_cert + @staticmethod + def is_topic_value_acceptable(topic_value: str) -> bool: + """Check whether the given Kafka topic value is acceptable.""" + return "*" not in topic_value[:3] + @property def topic(self): """Topic to use in Kafka.""" @@ -3534,9 +4047,8 @@ def topic(self): @topic.setter def topic(self, value): - # Avoid wildcards - if value == "*": - raise ValueError(f"Error on topic '{value}', cannot be a wildcard.") + if not self.is_topic_value_acceptable(value): + raise ValueError(f"Error on topic '{value}', unacceptable value.") self._topic = value def set_mtls_cert(self, relation_id: int, mtls_cert: str) -> None: @@ -3572,12 +4084,18 @@ def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: if self.relation_data.mtls_cert: relation_data["mtls-cert"] = self.relation_data.mtls_cert - if self.relation_data.extra_user_roles: - relation_data["extra-user-roles"] = self.relation_data.extra_user_roles - if self.relation_data.consumer_group_prefix: relation_data["consumer-group-prefix"] = self.relation_data.consumer_group_prefix + if self.relation_data.extra_user_roles: + relation_data["extra-user-roles"] = self.relation_data.extra_user_roles + if self.relation_data.extra_group_roles: + relation_data["extra-group-roles"] = self.relation_data.extra_group_roles + if self.relation_data.entity_type: + relation_data["entity-type"] = self.relation_data.entity_type + if self.relation_data.entity_permissions: + relation_data["entity-permissions"] = self.relation_data.entity_permissions + self.relation_data.update_relation_data(event.relation.id, relation_data) def _on_secret_changed_event(self, event: SecretChangedEvent): @@ -3596,16 +4114,26 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): self.relation_data._register_secrets_to_relation(event.relation, diff.added) - secret_field_user = self.relation_data._generate_secret_field_name(SECRET_GROUPS.USER) - if ( - "username" in diff.added and "password" in diff.added - ) or secret_field_user in diff.added: + app_databag = get_encoded_dict(event.relation, event.app, "data") + if app_databag is None: + app_databag = {} + + if self._main_credentials_shared(diff) and "entity-type" not in app_databag: # Emit the default event (the one without an alias). logger.info("topic created at %s", datetime.now()) getattr(self.on, "topic_created").emit(event.relation, app=event.app, unit=event.unit) - # To avoid unnecessary application restarts do not trigger - # “endpoints_changed“ event if “topic_created“ is triggered. + # To avoid unnecessary application restarts do not trigger other events. + return + + if self._entity_credentials_shared(diff) and "entity-type" in app_databag: + # Emit the default event (the one without an alias). + logger.info("entity created at %s", datetime.now()) + getattr(self.on, "topic_entity_created").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. return # Emit an endpoints (bootstrap-server) changed event if the Kafka endpoints @@ -3616,6 +4144,8 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: getattr(self.on, "bootstrap_server_changed").emit( event.relation, app=event.app, unit=event.unit ) # here check if this is the right design + + # To avoid unnecessary application restarts do not trigger other events. return @@ -3631,6 +4161,9 @@ def __init__( consumer_group_prefix: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], mtls_cert: Optional[str] = None, + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, ) -> None: KafkaRequirerData.__init__( self, @@ -3641,10 +4174,561 @@ def __init__( consumer_group_prefix=consumer_group_prefix, additional_secret_fields=additional_secret_fields, mtls_cert=mtls_cert, + extra_group_roles=extra_group_roles, + entity_type=entity_type, + entity_permissions=entity_permissions, ) KafkaRequirerEventHandlers.__init__(self, charm, self) +# Karapace related events + + +class KarapaceProvidesEvent(RelationEvent): + """Base class for Karapace events.""" + + @property + def subject(self) -> Optional[str]: + """Returns the subject that was requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("subject") + + +class SubjectRequestedEvent(KarapaceProvidesEvent): + """Event emitted when a new subject is requested for use on this relation.""" + + @property + def extra_user_roles(self) -> Optional[str]: + """Returns the extra user roles that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("extra-user-roles") + + +class SubjectEntityRequestedEvent(KarapaceProvidesEvent, EntityProvidesEvent): + """Event emitted when a new entity is requested for use on this relation.""" + + +class SubjectEntityPermissionsChangedEvent(KarapaceProvidesEvent, EntityProvidesEvent): + """Event emitted when existing entity permissions are changed on this relation.""" + + +class KarapaceProvidesEvents(CharmEvents): + """Karapace events. + + This class defines the events that the Karapace can emit. + """ + + subject_requested = EventSource(SubjectRequestedEvent) + subject_entity_requested = EventSource(SubjectEntityRequestedEvent) + subject_entity_permissions_changed = EventSource(SubjectEntityPermissionsChangedEvent) + + +class KarapaceRequiresEvent(RelationEvent): + """Base class for Karapace events.""" + + @property + def subject(self) -> Optional[str]: + """Returns the subject.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("subject") + + @property + def endpoints(self) -> Optional[str]: + """Returns a comma-separated list of broker uris.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("endpoints") + + +class SubjectAllowedEvent(AuthenticationEvent, KarapaceRequiresEvent): + """Event emitted when a new subject ACL is created for use on this relation.""" + + +class SubjectEntityCreatedEvent(EntityRequiresEvent, KarapaceRequiresEvent): + """Event emitted when a new entity is created for use on this relation.""" + + +class EndpointsChangedEvent(AuthenticationEvent, KarapaceRequiresEvent): + """Event emitted when the endpoints are changed.""" + + +class KarapaceRequiresEvents(CharmEvents): + """Karapace events. + + This class defines the events that Karapace can emit. + """ + + subject_allowed = EventSource(SubjectAllowedEvent) + subject_entity_created = EventSource(SubjectEntityCreatedEvent) + server_changed = EventSource(EndpointsChangedEvent) + + +# Karapace Provides and Requires + + +class KarapaceProviderData(ProviderData): + """Provider-side of the Karapace relation.""" + + RESOURCE_FIELD = "subject" + + def __init__(self, model: Model, relation_name: str) -> None: + super().__init__(model, relation_name) + + def set_subject(self, relation_id: int, subject: str) -> None: + """Set subject name in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + subject: the subject name. + """ + self.update_relation_data(relation_id, {"subject": subject}) + + def set_endpoint(self, relation_id: int, endpoint: str) -> None: + """Set the endpoint in the application relation databag. + + Args: + relation_id: the identifier for a particular relation. + endpoint: the server address. + """ + self.update_relation_data(relation_id, {"endpoints": endpoint}) + + +class KarapaceProviderEventHandlers(ProviderEventHandlers): + """Provider-side of the Karapace relation.""" + + on = KarapaceProvidesEvents() # pyright: ignore [reportAssignmentType] + + def __init__(self, charm: CharmBase, relation_data: KarapaceProviderData) -> None: + super().__init__(charm, relation_data) + # Just to keep lint quiet, can't resolve inheritance. The same happened in super().__init__() above + self.relation_data = relation_data + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + super()._on_relation_changed_event(event) + + # Leader only + if not self.relation_data.local_unit.is_leader(): + return + + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Validate entity information is not dynamically changed + self._validate_entity_consistency(event, diff) + + # Emit a subject requested event if the setup key (subject name) + # was added to the relation databag, but the entity-type key was not. + if "subject" in diff.added and "entity-type" not in diff.added: + getattr(self.on, "subject_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit an entity requested event if the setup key (subject name) + # was added to the relation databag, in addition to the entity-type key. + if "subject" in diff.added and "entity-type" in diff.added: + getattr(self.on, "subject_entity_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit a permissions changed event if the setup key (subject name) + # was added to the relation databag, and the entity-permissions key changed. + if ( + "subject" not in diff.added + and "entity-type" not in diff.added + and ("entity-permissions" in diff.added or "entity-permissions" in diff.changed) + ): + getattr(self.on, "subject_entity_permissions_changed").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + def _on_secret_changed_event(self, event: SecretChangedEvent): + """Event notifying about a new value of a secret.""" + pass + + +class KarapaceProvides(KarapaceProviderData, KarapaceProviderEventHandlers): + """Provider-side of the Karapace relation.""" + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + KarapaceProviderData.__init__(self, charm.model, relation_name) + KarapaceProviderEventHandlers.__init__(self, charm, self) + + +class KarapaceRequirerData(RequirerData): + """Requirer-side of the Karapace relation.""" + + def __init__( + self, + model: Model, + relation_name: str, + subject: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, + ): + """Manager of Karapace client relations.""" + super().__init__( + model, + relation_name, + extra_user_roles, + additional_secret_fields, + extra_group_roles, + entity_type, + entity_permissions, + ) + self.subject = subject + + @property + def subject(self): + """Topic to use in Karapace.""" + return self._subject + + @subject.setter + def subject(self, value): + # Avoid wildcards + if value == "*": + raise ValueError(f"Error on subject '{value}', cannot be a wildcard.") + self._subject = value + + +class KarapaceRequirerEventHandlers(RequirerEventHandlers): + """Requires-side of the Karapace relation.""" + + on = KarapaceRequiresEvents() # pyright: ignore [reportAssignmentType] + + def __init__(self, charm: CharmBase, relation_data: KarapaceRequirerData) -> None: + super().__init__(charm, relation_data) + # Just to keep lint quiet, can't resolve inheritance. The same happened in super().__init__() above + self.relation_data = relation_data + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + """Event emitted when the Karapace relation is created.""" + super()._on_relation_created_event(event) + + if not self.relation_data.local_unit.is_leader(): + return + + # Sets subject and extra user roles + relation_data = {"subject": self.relation_data.subject} + + if self.relation_data.extra_user_roles: + relation_data["extra-user-roles"] = self.relation_data.extra_user_roles + if self.relation_data.extra_group_roles: + relation_data["extra-group-roles"] = self.relation_data.extra_group_roles + if self.relation_data.entity_type: + relation_data["entity-type"] = self.relation_data.entity_type + if self.relation_data.entity_permissions: + relation_data["entity-permissions"] = self.relation_data.entity_permissions + + self.relation_data.update_relation_data(event.relation.id, relation_data) + + def _on_secret_changed_event(self, event: SecretChangedEvent): + """Event notifying about a new value of a secret.""" + pass + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the Karapace relation has changed.""" + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Check if the subject ACLs are created + # (the Karapace charm shared the credentials). + + # Register all new secrets with their labels + if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): + self.relation_data._register_secrets_to_relation(event.relation, diff.added) + + app_databag = get_encoded_dict(event.relation, event.app, "data") + if app_databag is None: + app_databag = {} + + if self._main_credentials_shared(diff) and "entity-type" not in app_databag: + # Emit the default event (the one without an alias). + logger.info("subject ACL created at %s", datetime.now()) + getattr(self.on, "subject_allowed").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + if self._entity_credentials_shared(diff) and "entity-type" in app_databag: + # Emit the default event (the one without an alias). + logger.info("entity created at %s", datetime.now()) + getattr(self.on, "subject_entity_created").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit an endpoints changed event if the Karapace endpoints added or changed + # this info in the relation databag. + if "endpoints" in diff.added or "endpoints" in diff.changed: + # Emit the default event (the one without an alias). + logger.info("endpoints changed on %s", datetime.now()) + getattr(self.on, "server_changed").emit( + event.relation, app=event.app, unit=event.unit + ) # here check if this is the right design + + # To avoid unnecessary application restarts do not trigger other events. + return + + +class KarapaceRequires(KarapaceRequirerData, KarapaceRequirerEventHandlers): + """Provider-side of the Karapace relation.""" + + def __init__( + self, + charm: CharmBase, + relation_name: str, + subject: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, + ) -> None: + KarapaceRequirerData.__init__( + self, + charm.model, + relation_name, + subject, + extra_user_roles, + additional_secret_fields, + extra_group_roles, + entity_type, + entity_permissions, + ) + KarapaceRequirerEventHandlers.__init__(self, charm, self) + + +# Kafka Connect Events + + +class KafkaConnectProvidesEvent(RelationEvent): + """Base class for Kafka Connect Provider events.""" + + @property + def plugin_url(self) -> Optional[str]: + """Returns the REST endpoint URL which serves the connector plugin.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("plugin-url") + + +class IntegrationRequestedEvent(KafkaConnectProvidesEvent): + """Event emitted when a new integrator boots up and is ready to serve the connector plugin.""" + + +class KafkaConnectProvidesEvents(CharmEvents): + """Kafka Connect Provider Events.""" + + integration_requested = EventSource(IntegrationRequestedEvent) + + +class KafkaConnectRequiresEvent(AuthenticationEvent): + """Base class for Kafka Connect Requirer events.""" + + @property + def plugin_url(self) -> Optional[str]: + """Returns the REST endpoint URL which serves the connector plugin.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("plugin-url") + + +class IntegrationCreatedEvent(KafkaConnectRequiresEvent): + """Event emitted when the credentials are created for this integrator.""" + + +class IntegrationEndpointsChangedEvent(KafkaConnectRequiresEvent): + """Event emitted when Kafka Connect REST endpoints change.""" + + +class KafkaConnectRequiresEvents(CharmEvents): + """Kafka Connect Requirer Events.""" + + integration_created = EventSource(IntegrationCreatedEvent) + integration_endpoints_changed = EventSource(IntegrationEndpointsChangedEvent) + + +class KafkaConnectProviderData(ProviderData): + """Provider-side of the Kafka Connect relation.""" + + RESOURCE_FIELD = "plugin-url" + + def __init__(self, model: Model, relation_name: str) -> None: + super().__init__(model, relation_name) + + def set_endpoints(self, relation_id: int, endpoints: str) -> None: + """Sets REST endpoints of the Kafka Connect service.""" + self.update_relation_data(relation_id, {"endpoints": endpoints}) + + +class KafkaConnectProviderEventHandlers(EventHandlers): + """Provider-side implementation of the Kafka Connect event handlers.""" + + on = KafkaConnectProvidesEvents() # pyright: ignore [reportAssignmentType] + + def __init__(self, charm: CharmBase, relation_data: KafkaConnectProviderData) -> None: + super().__init__(charm, relation_data) + self.relation_data = relation_data + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + # Leader only + if not self.relation_data.local_unit.is_leader(): + return + + # Check which data has changed to emit customs events. + diff = self._diff(event) + + if "plugin-url" in diff.added: + getattr(self.on, "integration_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + def _on_secret_changed_event(self, event: SecretChangedEvent): + """Event notifying about a new value of a secret.""" + pass + + +class KafkaConnectProvides(KafkaConnectProviderData, KafkaConnectProviderEventHandlers): + """Provider-side implementation of the Kafka Connect relation.""" + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + KafkaConnectProviderData.__init__(self, charm.model, relation_name) + KafkaConnectProviderEventHandlers.__init__(self, charm, self) + + +# Sentinel value passed from Kafka Connect requirer side when it does not need to serve any plugins. +PLUGIN_URL_NOT_REQUIRED: Final[str] = "NOT-REQUIRED" + + +class KafkaConnectRequirerData(RequirerData): + """Requirer-side of the Kafka Connect relation.""" + + def __init__( + self, + model: Model, + relation_name: str, + plugin_url: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + ): + """Manager of Kafka client relations.""" + super().__init__( + model, + relation_name, + extra_user_roles=extra_user_roles, + additional_secret_fields=additional_secret_fields, + ) + self.plugin_url = plugin_url + + @property + def plugin_url(self): + """The REST endpoint URL which serves the connector plugin.""" + return self._plugin_url + + @plugin_url.setter + def plugin_url(self, value): + self._plugin_url = value + + +class KafkaConnectRequirerEventHandlers(RequirerEventHandlers): + """Requirer-side of the Kafka Connect relation.""" + + on = KafkaConnectRequiresEvents() # pyright: ignore [reportAssignmentType] + + def __init__(self, charm: CharmBase, relation_data: KafkaConnectRequirerData) -> None: + super().__init__(charm, relation_data) + self.relation_data = relation_data + + def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: + """Event emitted when the Kafka Connect relation is created.""" + super()._on_relation_created_event(event) + + if not self.relation_data.local_unit.is_leader(): + return + + relation_data = {"plugin-url": self.relation_data.plugin_url} + self.relation_data.update_relation_data(event.relation.id, relation_data) + + def _on_secret_changed_event(self, event: SecretChangedEvent): + """Event notifying about a new value of a secret.""" + pass + + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: + """Event emitted when the Kafka Connect relation has changed.""" + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Register all new secrets with their labels + if any(newval for newval in diff.added if self.relation_data._is_secret_field(newval)): + self.relation_data._register_secrets_to_relation(event.relation, diff.added) + + if self._main_credentials_shared(diff): + logger.info("integration created at %s", datetime.now()) + getattr(self.on, "integration_created").emit( + event.relation, app=event.app, unit=event.unit + ) + return + + # Emit an endpoints changed event if the provider added or + # changed this info in the relation databag. + if "endpoints" in diff.added or "endpoints" in diff.changed: + # Emit the default event (the one without an alias). + logger.info("endpoints changed on %s", datetime.now()) + getattr(self.on, "integration_endpoints_changed").emit( + event.relation, app=event.app, unit=event.unit + ) + return + + +class KafkaConnectRequires(KafkaConnectRequirerData, KafkaConnectRequirerEventHandlers): + """Requirer-side implementation of the Kafka Connect relation.""" + + def __init__( + self, + charm: CharmBase, + relation_name: str, + plugin_url: str, + extra_user_roles: Optional[str] = None, + additional_secret_fields: Optional[List[str]] = [], + ) -> None: + KafkaConnectRequirerData.__init__( + self, + charm.model, + relation_name, + plugin_url, + extra_user_roles=extra_user_roles, + additional_secret_fields=additional_secret_fields, + ) + KafkaConnectRequirerEventHandlers.__init__(self, charm, self) + + # Opensearch related events @@ -3660,9 +4744,25 @@ def index(self) -> Optional[str]: return self.relation.data[self.relation.app].get("index") -class IndexRequestedEvent(OpenSearchProvidesEvent, ExtraRoleEvent): +class IndexRequestedEvent(OpenSearchProvidesEvent): """Event emitted when a new index is requested for use on this relation.""" + @property + def extra_user_roles(self) -> Optional[str]: + """Returns the extra user roles that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("extra-user-roles") + + +class IndexEntityRequestedEvent(OpenSearchProvidesEvent, EntityProvidesEvent): + """Event emitted when a new entity is requested for use on this relation.""" + + +class IndexEntityPermissionsChangedEvent(OpenSearchProvidesEvent, EntityProvidesEvent): + """Event emitted when existing entity permissions are changed on this relation.""" + class OpenSearchProvidesEvents(CharmEvents): """OpenSearch events. @@ -3671,6 +4771,8 @@ class OpenSearchProvidesEvents(CharmEvents): """ index_requested = EventSource(IndexRequestedEvent) + index_entity_requested = EventSource(IndexEntityRequestedEvent) + index_entity_permissions_changed = EventSource(IndexEntityPermissionsChangedEvent) class OpenSearchRequiresEvent(DatabaseRequiresEvent): @@ -3681,6 +4783,10 @@ class IndexCreatedEvent(AuthenticationEvent, OpenSearchRequiresEvent): """Event emitted when a new index is created for use on this relation.""" +class IndexEntityCreatedEvent(EntityRequiresEvent, OpenSearchRequiresEvent): + """Event emitted when a new index is created for use on this relation.""" + + class OpenSearchRequiresEvents(CharmEvents): """OpenSearch events. @@ -3688,6 +4794,7 @@ class OpenSearchRequiresEvents(CharmEvents): """ index_created = EventSource(IndexCreatedEvent) + index_entity_created = EventSource(IndexEntityCreatedEvent) endpoints_changed = EventSource(DatabaseEndpointsChangedEvent) authentication_updated = EventSource(AuthenticationEvent) @@ -3750,16 +4857,51 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: # Leader only if not self.relation_data.local_unit.is_leader(): return + # Check which data has changed to emit customs events. diff = self._diff(event) - # Emit an index requested event if the setup key (index name and optional extra user roles) - # have been added to the relation databag by the application. - if "index" in diff.added: + # Validate entity information is not dynamically changed + self._validate_entity_consistency(event, diff) + + # Emit an index requested event if the setup key (index name) + # was added to the relation databag, but the entity-type key was not. + if "index" in diff.added and "entity-type" not in diff.added: getattr(self.on, "index_requested").emit( event.relation, app=event.app, unit=event.unit ) + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit an entity requested event if the setup key (index name) + # was added to the relation databag, in addition to the entity-type key. + if "index" in diff.added and "entity-type" in diff.added: + getattr(self.on, "index_entity_requested").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit a permissions changed event if the setup key (index name) + # was added to the relation databag, and the entity-permissions key changed. + if ( + "index" not in diff.added + and "entity-type" not in diff.added + and ("entity-permissions" in diff.added or "entity-permissions" in diff.changed) + ): + getattr(self.on, "index_entity_permissions_changed").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + def _on_secret_changed_event(self, event: SecretChangedEvent) -> None: + """Event emitted when the relation data has changed.""" + pass + class OpenSearchProvides(OpenSearchProvidesData, OpenSearchProvidesEventHandlers): """Provider-side of the OpenSearch relation.""" @@ -3779,9 +4921,20 @@ def __init__( index: str, extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, ): """Manager of OpenSearch client relations.""" - super().__init__(model, relation_name, extra_user_roles, additional_secret_fields) + super().__init__( + model, + relation_name, + extra_user_roles, + additional_secret_fields, + extra_group_roles, + entity_type, + entity_permissions, + ) self.index = index @@ -3805,8 +4958,15 @@ def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: # Sets both index and extra user roles in the relation if the roles are provided. # Otherwise, sets only the index. data = {"index": self.relation_data.index} + if self.relation_data.extra_user_roles: data["extra-user-roles"] = self.relation_data.extra_user_roles + if self.relation_data.extra_group_roles: + data["extra-group-roles"] = self.relation_data.extra_group_roles + if self.relation_data.entity_type: + data["entity-type"] = self.relation_data.entity_type + if self.relation_data.entity_permissions: + data["entity-permissions"] = self.relation_data.entity_permissions self.relation_data.update_relation_data(event.relation.id, data) @@ -3856,27 +5016,40 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: event.relation, app=event.app, unit=event.unit ) + app_databag = get_encoded_dict(event.relation, event.app, "data") + if app_databag is None: + app_databag = {} + # Check if the index is created # (the OpenSearch charm shares the credentials). - if ( - "username" in diff.added and "password" in diff.added - ) or secret_field_user in diff.added: + if self._main_credentials_shared(diff) and "entity-type" not in app_databag: # Emit the default event (the one without an alias). logger.info("index created at: %s", datetime.now()) getattr(self.on, "index_created").emit(event.relation, app=event.app, unit=event.unit) - # To avoid unnecessary application restarts do not trigger - # “endpoints_changed“ event if “index_created“ is triggered. + # To avoid unnecessary application restarts do not trigger other events. return - # Emit a endpoints changed event if the OpenSearch application added or changed this info - # in the relation databag. + if self._entity_credentials_shared(diff) and "entity-type" in app_databag: + # Emit the default event (the one without an alias). + logger.info("entity created at: %s", datetime.now()) + getattr(self.on, "index_entity_created").emit( + event.relation, app=event.app, unit=event.unit + ) + + # To avoid unnecessary application restarts do not trigger other events. + return + + # Emit a endpoints changed event if the OpenSearch application + # added or changed this info in the relation databag. if "endpoints" in diff.added or "endpoints" in diff.changed: # Emit the default event (the one without an alias). logger.info("endpoints changed on %s", datetime.now()) getattr(self.on, "endpoints_changed").emit( event.relation, app=event.app, unit=event.unit - ) # here check if this is the right design + ) + + # To avoid unnecessary application restarts do not trigger other events. return @@ -3890,6 +5063,9 @@ def __init__( index: str, extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, ) -> None: OpenSearchRequiresData.__init__( self, @@ -3898,6 +5074,9 @@ def __init__( index, extra_user_roles, additional_secret_fields, + extra_group_roles, + entity_type, + entity_permissions, ) OpenSearchRequiresEventHandlers.__init__(self, charm, self) @@ -4040,6 +5219,12 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: if any(newval for newval in new_data_keys if self.relation_data._is_secret_field(newval)): self.relation_data._register_secrets_to_relation(event.relation, new_data_keys) + # Check which data has changed to emit customs events. + diff = self._diff(event) + + # Validate entity information is not dynamically changed + self._validate_entity_consistency(event, diff) + getattr(self.on, "mtls_cert_updated").emit(event.relation, app=event.app, unit=event.unit) return @@ -4092,9 +5277,20 @@ def __init__( mtls_cert: Optional[str], extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, ): """Manager of Etcd client relations.""" - super().__init__(model, relation_name, extra_user_roles, additional_secret_fields) + super().__init__( + model, + relation_name, + extra_user_roles, + additional_secret_fields, + extra_group_roles, + entity_type, + entity_permissions, + ) self.prefix = prefix self.mtls_cert = mtls_cert @@ -4204,6 +5400,9 @@ def __init__( mtls_cert: Optional[str], extra_user_roles: Optional[str] = None, additional_secret_fields: Optional[List[str]] = [], + extra_group_roles: Optional[str] = None, + entity_type: Optional[str] = None, + entity_permissions: Optional[str] = None, ) -> None: EtcdRequirerData.__init__( self, @@ -4213,6 +5412,9 @@ def __init__( mtls_cert, extra_user_roles, additional_secret_fields, + extra_group_roles, + entity_type, + entity_permissions, ) EtcdRequirerEventHandlers.__init__(self, charm, self) if not self.secrets_enabled: diff --git a/src/charm.py b/src/charm.py index 93d6cbd34d..923ec35c6c 100755 --- a/src/charm.py +++ b/src/charm.py @@ -2380,7 +2380,9 @@ def relations_user_databases_map(self) -> dict: # Copy relations users directly instead of waiting for them to be created for relation in self.model.relations[self.postgresql_client_relation.relation_name]: - user = f"relation_id_{relation.id}" + user = self.postgresql_client_relation.database_provides.fetch_my_relation_field( + relation.id, "username" + ) if user not in user_database_map and ( database := self.postgresql_client_relation.database_provides.fetch_relation_field( relation.id, "database" diff --git a/src/relations/postgresql_provider.py b/src/relations/postgresql_provider.py index 0c94c798d3..6dc005972a 100644 --- a/src/relations/postgresql_provider.py +++ b/src/relations/postgresql_provider.py @@ -18,9 +18,9 @@ PostgreSQLDeleteUserError, PostgreSQLGetPostgreSQLVersionError, ) +from ops import ActiveStatus, BlockedStatus, ModelError, Relation from ops.charm import CharmBase, RelationBrokenEvent, RelationDepartedEvent from ops.framework import Object -from ops.model import ActiveStatus, BlockedStatus, Relation from constants import DATABASE_PORT from utils import new_password @@ -28,6 +28,10 @@ logger = logging.getLogger(__name__) +# Label not a secret +NO_ACCESS_TO_SECRET_MSG = "Missing grant to requested entity secret." # noqa: S105 + + class PostgreSQLProvider(Object): """Defines functionality for the 'provides' side of the 'postgresql-client' relation. @@ -71,6 +75,15 @@ def _sanitize_extra_roles(extra_roles: str | None) -> list[str]: extra_roles_list = [role for role in extra_roles_list if role not in ACCESS_GROUPS] return extra_roles_list + def clear_no_access_block(self): + """Clear no access to secret blocking message.""" + # TODO Actually implement + if ( + isinstance(self.charm.unit.status, BlockedStatus) + and self.charm.unit.status.message == NO_ACCESS_TO_SECRET_MSG + ): + self.charm.unit.status = ActiveStatus() + def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: """Handle the legacy postgresql-client relation changed event. @@ -84,6 +97,21 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: event.defer() return + user = None + password = None + try: + if requested_entities := event.requested_entity_secret_content: + for key, val in requested_entities.items(): + user = key + password = val + break + except ModelError: + self.charm.unit.status = BlockedStatus(NO_ACCESS_TO_SECRET_MSG) + event.defer() + return + + self.clear_no_access_block() + self.charm.update_config() for key in self.charm._peers.data: # We skip the leader so we don't have to wait on the defer @@ -106,8 +134,8 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: try: # Creates the user and the database for this specific relation. - user = f"relation_id_{event.relation.id}" - password = new_password() + user = user or f"relation_id_{event.relation.id}" + password = password or new_password() self.charm.postgresql.create_user(user, password, extra_user_roles=extra_user_roles) plugins = self.charm.get_plugins() @@ -254,7 +282,7 @@ def update_read_only_endpoint( ) ) and "read-only-uris" in secret_fields: if not user or not password or not database: - user = f"relation_id_{relation.id}" + user = self.database_provides.fetch_my_relation_field(relation.id, "username") database = self.database_provides.fetch_relation_field(relation.id, "database") password = self.database_provides.fetch_my_relation_field( relation.id, "password"