Skip to content

Commit

Permalink
[DPE-5318] update user management (#329)
Browse files Browse the repository at this point in the history
* update lib + charm

* revert to already exisitng lib for debug-purposes

* most recent published lib works with k8s charm

* update lib for better user management + add integration tests to verify functionality

* update unit tests
  • Loading branch information
MiaAltieri authored Sep 11, 2024
1 parent db5fe64 commit 7911c38
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 100 deletions.
211 changes: 156 additions & 55 deletions lib/charms/mongodb/v1/mongodb_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
from typing import List, Optional, Set

from charms.data_platform_libs.v0.data_interfaces import DatabaseProvides
from charms.mongodb.v0.mongo import MongoConfiguration, MongoConnection
from charms.mongodb.v1.helpers import generate_password
from charms.mongodb.v1.mongodb import MongoConfiguration, MongoDBConnection
from ops.charm import CharmBase, EventBase, RelationBrokenEvent, RelationChangedEvent
from ops.framework import Object
from ops.model import Relation
Expand All @@ -31,17 +31,15 @@

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 10
LIBPATCH = 14

logger = logging.getLogger(__name__)
REL_NAME = "database"

MONGOS_RELATIONS = "cluster"
MONGOS_CLIENT_RELATIONS = "mongos_proxy"
MANAGED_USERS_KEY = "managed-users-key"

# We expect the MongoDB container to use the default ports
MONGODB_PORT = 27017
MONGODB_VERSION = "5.0"
PEER = "database-peers"

Diff = namedtuple("Diff", "added changed deleted")
Diff.__doc__ = """
Expand All @@ -54,50 +52,54 @@
class MongoDBProvider(Object):
"""In this class, we manage client database relations."""

def __init__(self, charm: CharmBase, substrate="k8s", relation_name: str = "database") -> None:
def __init__(self, charm: CharmBase, substrate="k8s", relation_name: str = REL_NAME) -> None:
"""Constructor for MongoDBProvider object.
Args:
charm: the charm for which this relation is provided
substrate: host type, either "k8s" or "vm"
relation_name: the name of the relation
"""
self.relation_name = relation_name
self.substrate = substrate
self.charm = charm

super().__init__(charm, self.relation_name)
super().__init__(charm, relation_name)
self.framework.observe(
charm.on[self.relation_name].relation_departed,
charm.on[relation_name].relation_departed,
self.charm.check_relation_broken_or_scale_down,
)
self.framework.observe(
charm.on[self.relation_name].relation_broken, self._on_relation_event
)
self.framework.observe(
charm.on[self.relation_name].relation_changed, self._on_relation_event
)
self.framework.observe(charm.on[relation_name].relation_broken, self._on_relation_event)
self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_event)

# Charm events defined in the database provides charm library.
self.database_provides = DatabaseProvides(self.charm, relation_name=self.relation_name)
self.database_provides = DatabaseProvides(self.charm, relation_name=relation_name)
self.framework.observe(
self.database_provides.on.database_requested, self._on_relation_event
)

def pass_hook_checks(self, event: EventBase) -> bool:
"""Runs the pre-hooks checks for MongoDBProvider, returns True if all pass."""
def pass_sanity_hook_checks(self) -> bool:
"""Runs reusable and event agnostic checks."""
# We shouldn't try to create or update users if the database is not
# initialised. We will create users as part of initialisation.
if not self.charm.db_initialised:
return False

if not self.charm.is_relation_feasible(self.relation_name):
if not self.charm.is_role(Config.Role.MONGOS) and not self.charm.is_relation_feasible(
self.get_relation_name()
):
logger.info("Skipping code for relations.")
return False

if not self.charm.unit.is_leader():
return False

return True

def pass_hook_checks(self, event: EventBase) -> bool:
"""Runs the pre-hooks checks for MongoDBProvider, returns True if all pass."""
if not self.pass_sanity_hook_checks():
return False

if self.charm.upgrade_in_progress:
logger.warning(
"Adding relations is not supported during an upgrade. The charm may be in a broken, unrecoverable state."
Expand Down Expand Up @@ -163,16 +165,54 @@ def oversee_users(self, departed_relation_id: Optional[int], event):
When the function is executed in relation departed event, the departed
relation is still on the list of all relations. Therefore, for proper
work of the function, we need to exclude departed relation from the list.
Raises:
PyMongoError
"""
with MongoDBConnection(self.charm.mongodb_config) as mongo:
with MongoConnection(self.charm.mongo_config) as mongo:
database_users = mongo.get_users()
relation_users = self._get_users_from_relations(departed_relation_id)

for username in database_users - relation_users:
users_being_managed = database_users.intersection(self._get_relational_users_to_manage())
expected_current_users = self._get_users_from_relations(departed_relation_id)

self.remove_users(users_being_managed, expected_current_users)
self.add_users(users_being_managed, expected_current_users)
self.update_users(event, users_being_managed, expected_current_users)
self.auto_delete_dbs(departed_relation_id)

def remove_users(
self, users_being_managed: Set[str], expected_current_users: Set[str]
) -> None:
"""Removes users from Charmed MongoDB.
Note this only removes users that this application of Charmed MongoDB is responsible for
managing. It won't remove:
1. users created from other applications
2. users created from other mongos routers.
Raises:
PyMongoError
"""
with MongoConnection(self.charm.mongo_config) as mongo:
for username in users_being_managed - expected_current_users:
logger.info("Remove relation user: %s", username)
if (
self.charm.is_role(Config.Role.MONGOS)
and username == self.charm.mongo_config.username
):
continue

mongo.drop_user(username)
self._remove_from_relational_users_to_manage(username)

def add_users(self, users_being_managed: Set[str], expected_current_users: Set[str]) -> None:
"""Adds users to Charmed MongoDB.
for username in relation_users - database_users:
Raises:
PyMongoError
"""
with MongoConnection(self.charm.mongo_config) as mongo:
for username in expected_current_users - users_being_managed:
config = self._get_config(username, None)
if config.database is None:
# We need to wait for the moment when the provider library
Expand All @@ -181,15 +221,33 @@ def oversee_users(self, departed_relation_id: Optional[int], event):
logger.info("Create relation user: %s on %s", config.username, config.database)

mongo.create_user(config)
self._add_to_relational_users_to_manage(username)
self._set_relation(config)

for username in relation_users.intersection(database_users):
def update_users(
self, event: EventBase, users_being_managed: Set[str], expected_current_users: Set[str]
) -> None:
"""Updates existing users in Charmed MongoDB.
Raises:
PyMongoError
"""
with MongoConnection(self.charm.mongo_config) as mongo:
for username in expected_current_users.intersection(users_being_managed):
config = self._get_config(username, None)
logger.info("Update relation user: %s on %s", config.username, config.database)
mongo.update_user(config)
logger.info("Updating relation data according to diff")
self._diff(event)

def auto_delete_dbs(self, departed_relation_id):
"""Delete's unused dbs if configured to do so.
Raises:
PyMongoError
"""
with MongoConnection(self.charm.mongo_config) as mongo:

if not self.charm.model.config["auto-delete"]:
return

Expand Down Expand Up @@ -240,15 +298,15 @@ def _diff(self, event: RelationChangedEvent) -> Diff:

def update_app_relation_data(self) -> None:
"""Helper function to update application relation data."""
if not self.charm.db_initialised:
if not self.pass_sanity_hook_checks():
return

database_users = set()

with MongoDBConnection(self.charm.mongodb_config) as mongo:
with MongoConnection(self.charm.mongo_config) as mongo:
database_users = mongo.get_users()

for relation in self._get_relations(rel=REL_NAME):
for relation in self._get_relations():
username = self._get_username_from_relation_id(relation.id)
password = self._get_or_set_password(relation)
config = self._get_config(username, password)
Expand Down Expand Up @@ -282,24 +340,34 @@ def _get_or_set_password(self, relation: Relation) -> str:
self.database_provides.update_relation_data(relation.id, {"password": password})
return password

def _get_config(self, username: str, password: Optional[str]) -> MongoConfiguration:
def _get_config(
self, username: str, password: Optional[str], event=None
) -> MongoConfiguration:
"""Construct the config object for future user creation."""
relation = self._get_relation_from_username(username)
if not password:
password = self._get_or_set_password(relation)

database_name = self._get_database_from_relation(relation)

return MongoConfiguration(
replset=self.charm.app.name,
database=database_name,
username=username,
password=password,
hosts=self.charm.mongodb_config.hosts,
roles=self._get_roles_from_relation(relation),
tls_external=False,
tls_internal=False,
)
mongo_args = {
"database": database_name,
"username": username,
"password": password,
"hosts": self.charm.mongo_config.hosts,
"roles": self._get_roles_from_relation(relation),
"tls_external": False,
"tls_internal": False,
}

if self.charm.is_role(Config.Role.MONGOS):
mongo_args["port"] = Config.MONGOS_PORT
if self.substrate == Config.Substrate.K8S:
mongo_args["hosts"] = self.charm.get_mongos_hosts_for_client()
else:
mongo_args["replset"] = self.charm.app.name

return MongoConfiguration(**mongo_args)

def _set_relation(self, config: MongoConfiguration):
"""Save all output fields into application relation."""
Expand All @@ -318,10 +386,11 @@ def _set_relation(self, config: MongoConfiguration):
relation.id,
",".join(config.hosts),
)
self.database_provides.set_replset(
relation.id,
config.replset,
)
if not self.charm.is_role(Config.Role.MONGOS):
self.database_provides.set_replset(
relation.id,
config.replset,
)
self.database_provides.set_uris(
relation.id,
config.uri,
Expand All @@ -332,9 +401,9 @@ def _get_username_from_relation_id(relation_id: int) -> str:
"""Construct username."""
return f"relation-{relation_id}"

def _get_users_from_relations(self, departed_relation_id: Optional[int], rel=REL_NAME):
def _get_users_from_relations(self, departed_relation_id: Optional[int]):
"""Return usernames for all relations except departed relation."""
relations = self._get_relations(rel)
relations = self._get_relations()
return set(
[
self._get_username_from_relation_id(relation.id)
Expand All @@ -351,7 +420,7 @@ def _get_databases_from_relations(self, departed_relation_id: Optional[int]) ->
except for those databases that belong to the departing
relation specified.
"""
relations = self._get_relations(rel=REL_NAME)
relations = self._get_relations()
databases = set()
for relation in relations:
if relation.id == departed_relation_id:
Expand All @@ -370,22 +439,54 @@ def _get_relation_from_username(self, username: str) -> Relation:
assert match is not None, "No relation match"
relation_id = int(match.group(1))
logger.debug("Relation ID: %s", relation_id)
relation_name = (
MONGOS_RELATIONS if self.charm.is_role(Config.Role.CONFIG_SERVER) else REL_NAME
)
relation_name = self.get_relation_name()
return self.model.get_relation(relation_name, relation_id)

def _get_relations(self, rel=REL_NAME) -> List[Relation]:
def _get_relations(self) -> List[Relation]:
"""Return the set of relations for users.
We create users for either direct relations to charm or for relations through the mongos
charm.
"""
return (
self.model.relations[MONGOS_RELATIONS]
if self.charm.is_role(Config.Role.CONFIG_SERVER)
else self.model.relations[rel]
)
return self.model.relations[self.get_relation_name()]

def get_relation_name(self):
"""Returns the name of the relation to use."""
if self.charm.is_role(Config.Role.CONFIG_SERVER):
return MONGOS_RELATIONS
elif self.charm.is_role(Config.Role.MONGOS):
return MONGOS_CLIENT_RELATIONS
else:
return REL_NAME

def _get_relational_users_to_manage(self) -> Set[str]:
"""Returns a set of the users to manage.
Note json cannot serialise sets. Convert from list.
"""
return set(json.loads(self.charm.app_peer_data.get(MANAGED_USERS_KEY, "[]")))

def _update_relational_users_to_manage(self, new_users: Set[str]) -> None:
"""Updates the set of the users to manage.
Note json cannot serialise sets. Convert from list.
"""
if not self.charm.unit.is_leader():
raise Exception("Cannot update relational data on non-leader unit")

self.charm.app_peer_data[MANAGED_USERS_KEY] = json.dumps(list(new_users))

def _remove_from_relational_users_to_manage(self, user_to_remove: str) -> None:
"""Removes the provided user from the set of the users to manage."""
current_users = self._get_relational_users_to_manage()
updated_users = current_users - {user_to_remove}
self._update_relational_users_to_manage(updated_users)

def _add_to_relational_users_to_manage(self, user_to_add: str) -> None:
"""Adds the provided user to the set of the users to manage."""
current_users = self._get_relational_users_to_manage()
current_users.add(user_to_add)
self._update_relational_users_to_manage(current_users)

@staticmethod
def _get_database_from_relation(relation: Relation) -> Optional[str]:
Expand Down
Loading

0 comments on commit 7911c38

Please sign in to comment.