Skip to content

Commit 7911c38

Browse files
authored
[DPE-5318] update user management (#329)
* 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
1 parent db5fe64 commit 7911c38

File tree

7 files changed

+262
-100
lines changed

7 files changed

+262
-100
lines changed

lib/charms/mongodb/v1/mongodb_provider.py

Lines changed: 156 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
from typing import List, Optional, Set
1515

1616
from charms.data_platform_libs.v0.data_interfaces import DatabaseProvides
17+
from charms.mongodb.v0.mongo import MongoConfiguration, MongoConnection
1718
from charms.mongodb.v1.helpers import generate_password
18-
from charms.mongodb.v1.mongodb import MongoConfiguration, MongoDBConnection
1919
from ops.charm import CharmBase, EventBase, RelationBrokenEvent, RelationChangedEvent
2020
from ops.framework import Object
2121
from ops.model import Relation
@@ -31,17 +31,15 @@
3131

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

3636
logger = logging.getLogger(__name__)
3737
REL_NAME = "database"
38-
3938
MONGOS_RELATIONS = "cluster"
39+
MONGOS_CLIENT_RELATIONS = "mongos_proxy"
40+
MANAGED_USERS_KEY = "managed-users-key"
4041

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

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

57-
def __init__(self, charm: CharmBase, substrate="k8s", relation_name: str = "database") -> None:
55+
def __init__(self, charm: CharmBase, substrate="k8s", relation_name: str = REL_NAME) -> None:
5856
"""Constructor for MongoDBProvider object.
5957
6058
Args:
6159
charm: the charm for which this relation is provided
6260
substrate: host type, either "k8s" or "vm"
6361
relation_name: the name of the relation
6462
"""
65-
self.relation_name = relation_name
6663
self.substrate = substrate
6764
self.charm = charm
6865

69-
super().__init__(charm, self.relation_name)
66+
super().__init__(charm, relation_name)
7067
self.framework.observe(
71-
charm.on[self.relation_name].relation_departed,
68+
charm.on[relation_name].relation_departed,
7269
self.charm.check_relation_broken_or_scale_down,
7370
)
74-
self.framework.observe(
75-
charm.on[self.relation_name].relation_broken, self._on_relation_event
76-
)
77-
self.framework.observe(
78-
charm.on[self.relation_name].relation_changed, self._on_relation_event
79-
)
71+
self.framework.observe(charm.on[relation_name].relation_broken, self._on_relation_event)
72+
self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_event)
8073

8174
# Charm events defined in the database provides charm library.
82-
self.database_provides = DatabaseProvides(self.charm, relation_name=self.relation_name)
75+
self.database_provides = DatabaseProvides(self.charm, relation_name=relation_name)
8376
self.framework.observe(
8477
self.database_provides.on.database_requested, self._on_relation_event
8578
)
8679

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

94-
if not self.charm.is_relation_feasible(self.relation_name):
87+
if not self.charm.is_role(Config.Role.MONGOS) and not self.charm.is_relation_feasible(
88+
self.get_relation_name()
89+
):
9590
logger.info("Skipping code for relations.")
9691
return False
9792

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

96+
return True
97+
98+
def pass_hook_checks(self, event: EventBase) -> bool:
99+
"""Runs the pre-hooks checks for MongoDBProvider, returns True if all pass."""
100+
if not self.pass_sanity_hook_checks():
101+
return False
102+
101103
if self.charm.upgrade_in_progress:
102104
logger.warning(
103105
"Adding relations is not supported during an upgrade. The charm may be in a broken, unrecoverable state."
@@ -163,16 +165,54 @@ def oversee_users(self, departed_relation_id: Optional[int], event):
163165
When the function is executed in relation departed event, the departed
164166
relation is still on the list of all relations. Therefore, for proper
165167
work of the function, we need to exclude departed relation from the list.
168+
169+
Raises:
170+
PyMongoError
166171
"""
167-
with MongoDBConnection(self.charm.mongodb_config) as mongo:
172+
with MongoConnection(self.charm.mongo_config) as mongo:
168173
database_users = mongo.get_users()
169-
relation_users = self._get_users_from_relations(departed_relation_id)
170174

171-
for username in database_users - relation_users:
175+
users_being_managed = database_users.intersection(self._get_relational_users_to_manage())
176+
expected_current_users = self._get_users_from_relations(departed_relation_id)
177+
178+
self.remove_users(users_being_managed, expected_current_users)
179+
self.add_users(users_being_managed, expected_current_users)
180+
self.update_users(event, users_being_managed, expected_current_users)
181+
self.auto_delete_dbs(departed_relation_id)
182+
183+
def remove_users(
184+
self, users_being_managed: Set[str], expected_current_users: Set[str]
185+
) -> None:
186+
"""Removes users from Charmed MongoDB.
187+
188+
Note this only removes users that this application of Charmed MongoDB is responsible for
189+
managing. It won't remove:
190+
1. users created from other applications
191+
2. users created from other mongos routers.
192+
193+
Raises:
194+
PyMongoError
195+
"""
196+
with MongoConnection(self.charm.mongo_config) as mongo:
197+
for username in users_being_managed - expected_current_users:
172198
logger.info("Remove relation user: %s", username)
199+
if (
200+
self.charm.is_role(Config.Role.MONGOS)
201+
and username == self.charm.mongo_config.username
202+
):
203+
continue
204+
173205
mongo.drop_user(username)
206+
self._remove_from_relational_users_to_manage(username)
207+
208+
def add_users(self, users_being_managed: Set[str], expected_current_users: Set[str]) -> None:
209+
"""Adds users to Charmed MongoDB.
174210
175-
for username in relation_users - database_users:
211+
Raises:
212+
PyMongoError
213+
"""
214+
with MongoConnection(self.charm.mongo_config) as mongo:
215+
for username in expected_current_users - users_being_managed:
176216
config = self._get_config(username, None)
177217
if config.database is None:
178218
# We need to wait for the moment when the provider library
@@ -181,15 +221,33 @@ def oversee_users(self, departed_relation_id: Optional[int], event):
181221
logger.info("Create relation user: %s on %s", config.username, config.database)
182222

183223
mongo.create_user(config)
224+
self._add_to_relational_users_to_manage(username)
184225
self._set_relation(config)
185226

186-
for username in relation_users.intersection(database_users):
227+
def update_users(
228+
self, event: EventBase, users_being_managed: Set[str], expected_current_users: Set[str]
229+
) -> None:
230+
"""Updates existing users in Charmed MongoDB.
231+
232+
Raises:
233+
PyMongoError
234+
"""
235+
with MongoConnection(self.charm.mongo_config) as mongo:
236+
for username in expected_current_users.intersection(users_being_managed):
187237
config = self._get_config(username, None)
188238
logger.info("Update relation user: %s on %s", config.username, config.database)
189239
mongo.update_user(config)
190240
logger.info("Updating relation data according to diff")
191241
self._diff(event)
192242

243+
def auto_delete_dbs(self, departed_relation_id):
244+
"""Delete's unused dbs if configured to do so.
245+
246+
Raises:
247+
PyMongoError
248+
"""
249+
with MongoConnection(self.charm.mongo_config) as mongo:
250+
193251
if not self.charm.model.config["auto-delete"]:
194252
return
195253

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

241299
def update_app_relation_data(self) -> None:
242300
"""Helper function to update application relation data."""
243-
if not self.charm.db_initialised:
301+
if not self.pass_sanity_hook_checks():
244302
return
245303

246304
database_users = set()
247305

248-
with MongoDBConnection(self.charm.mongodb_config) as mongo:
306+
with MongoConnection(self.charm.mongo_config) as mongo:
249307
database_users = mongo.get_users()
250308

251-
for relation in self._get_relations(rel=REL_NAME):
309+
for relation in self._get_relations():
252310
username = self._get_username_from_relation_id(relation.id)
253311
password = self._get_or_set_password(relation)
254312
config = self._get_config(username, password)
@@ -282,24 +340,34 @@ def _get_or_set_password(self, relation: Relation) -> str:
282340
self.database_provides.update_relation_data(relation.id, {"password": password})
283341
return password
284342

285-
def _get_config(self, username: str, password: Optional[str]) -> MongoConfiguration:
343+
def _get_config(
344+
self, username: str, password: Optional[str], event=None
345+
) -> MongoConfiguration:
286346
"""Construct the config object for future user creation."""
287347
relation = self._get_relation_from_username(username)
288348
if not password:
289349
password = self._get_or_set_password(relation)
290350

291351
database_name = self._get_database_from_relation(relation)
292352

293-
return MongoConfiguration(
294-
replset=self.charm.app.name,
295-
database=database_name,
296-
username=username,
297-
password=password,
298-
hosts=self.charm.mongodb_config.hosts,
299-
roles=self._get_roles_from_relation(relation),
300-
tls_external=False,
301-
tls_internal=False,
302-
)
353+
mongo_args = {
354+
"database": database_name,
355+
"username": username,
356+
"password": password,
357+
"hosts": self.charm.mongo_config.hosts,
358+
"roles": self._get_roles_from_relation(relation),
359+
"tls_external": False,
360+
"tls_internal": False,
361+
}
362+
363+
if self.charm.is_role(Config.Role.MONGOS):
364+
mongo_args["port"] = Config.MONGOS_PORT
365+
if self.substrate == Config.Substrate.K8S:
366+
mongo_args["hosts"] = self.charm.get_mongos_hosts_for_client()
367+
else:
368+
mongo_args["replset"] = self.charm.app.name
369+
370+
return MongoConfiguration(**mongo_args)
303371

304372
def _set_relation(self, config: MongoConfiguration):
305373
"""Save all output fields into application relation."""
@@ -318,10 +386,11 @@ def _set_relation(self, config: MongoConfiguration):
318386
relation.id,
319387
",".join(config.hosts),
320388
)
321-
self.database_provides.set_replset(
322-
relation.id,
323-
config.replset,
324-
)
389+
if not self.charm.is_role(Config.Role.MONGOS):
390+
self.database_provides.set_replset(
391+
relation.id,
392+
config.replset,
393+
)
325394
self.database_provides.set_uris(
326395
relation.id,
327396
config.uri,
@@ -332,9 +401,9 @@ def _get_username_from_relation_id(relation_id: int) -> str:
332401
"""Construct username."""
333402
return f"relation-{relation_id}"
334403

335-
def _get_users_from_relations(self, departed_relation_id: Optional[int], rel=REL_NAME):
404+
def _get_users_from_relations(self, departed_relation_id: Optional[int]):
336405
"""Return usernames for all relations except departed relation."""
337-
relations = self._get_relations(rel)
406+
relations = self._get_relations()
338407
return set(
339408
[
340409
self._get_username_from_relation_id(relation.id)
@@ -351,7 +420,7 @@ def _get_databases_from_relations(self, departed_relation_id: Optional[int]) ->
351420
except for those databases that belong to the departing
352421
relation specified.
353422
"""
354-
relations = self._get_relations(rel=REL_NAME)
423+
relations = self._get_relations()
355424
databases = set()
356425
for relation in relations:
357426
if relation.id == departed_relation_id:
@@ -370,22 +439,54 @@ def _get_relation_from_username(self, username: str) -> Relation:
370439
assert match is not None, "No relation match"
371440
relation_id = int(match.group(1))
372441
logger.debug("Relation ID: %s", relation_id)
373-
relation_name = (
374-
MONGOS_RELATIONS if self.charm.is_role(Config.Role.CONFIG_SERVER) else REL_NAME
375-
)
442+
relation_name = self.get_relation_name()
376443
return self.model.get_relation(relation_name, relation_id)
377444

378-
def _get_relations(self, rel=REL_NAME) -> List[Relation]:
445+
def _get_relations(self) -> List[Relation]:
379446
"""Return the set of relations for users.
380447
381448
We create users for either direct relations to charm or for relations through the mongos
382449
charm.
383450
"""
384-
return (
385-
self.model.relations[MONGOS_RELATIONS]
386-
if self.charm.is_role(Config.Role.CONFIG_SERVER)
387-
else self.model.relations[rel]
388-
)
451+
return self.model.relations[self.get_relation_name()]
452+
453+
def get_relation_name(self):
454+
"""Returns the name of the relation to use."""
455+
if self.charm.is_role(Config.Role.CONFIG_SERVER):
456+
return MONGOS_RELATIONS
457+
elif self.charm.is_role(Config.Role.MONGOS):
458+
return MONGOS_CLIENT_RELATIONS
459+
else:
460+
return REL_NAME
461+
462+
def _get_relational_users_to_manage(self) -> Set[str]:
463+
"""Returns a set of the users to manage.
464+
465+
Note json cannot serialise sets. Convert from list.
466+
"""
467+
return set(json.loads(self.charm.app_peer_data.get(MANAGED_USERS_KEY, "[]")))
468+
469+
def _update_relational_users_to_manage(self, new_users: Set[str]) -> None:
470+
"""Updates the set of the users to manage.
471+
472+
Note json cannot serialise sets. Convert from list.
473+
"""
474+
if not self.charm.unit.is_leader():
475+
raise Exception("Cannot update relational data on non-leader unit")
476+
477+
self.charm.app_peer_data[MANAGED_USERS_KEY] = json.dumps(list(new_users))
478+
479+
def _remove_from_relational_users_to_manage(self, user_to_remove: str) -> None:
480+
"""Removes the provided user from the set of the users to manage."""
481+
current_users = self._get_relational_users_to_manage()
482+
updated_users = current_users - {user_to_remove}
483+
self._update_relational_users_to_manage(updated_users)
484+
485+
def _add_to_relational_users_to_manage(self, user_to_add: str) -> None:
486+
"""Adds the provided user to the set of the users to manage."""
487+
current_users = self._get_relational_users_to_manage()
488+
current_users.add(user_to_add)
489+
self._update_relational_users_to_manage(current_users)
389490

390491
@staticmethod
391492
def _get_database_from_relation(relation: Relation) -> Optional[str]:

0 commit comments

Comments
 (0)