From 93b8f1123fd35cbfa601ac497b2cd32f46dc30e2 Mon Sep 17 00:00:00 2001 From: Mia Altieri <32723809+MiaAltieri@users.noreply.github.com> Date: Mon, 25 Mar 2024 14:26:55 +0100 Subject: [PATCH] [DPE-3753] Support mongos tls (#386) ## Issue mongos charm does not support TLS and we want to re-use some of the shared libs owned by the mongodb chamr ## Solution update the shared lib owned by the mongodb charm so that mongos charm can easily support TLS ## Future PRs: 1. add libs to mongos + add necessary changes to mongos src/charm 2. add sanity checks for mongos checking valid TLS configuration 3. Int tests --------- Co-authored-by: Mehdi Bendriss --- .../mongodb/v0/config_server_interface.py | 47 +++++++++++---- lib/charms/mongodb/v0/mongodb_tls.py | 57 ++++++++++++++----- lib/charms/mongodb/v1/mongodb_provider.py | 2 +- .../mongodb/v1/mongodb_vm_legacy_provider.py | 2 +- lib/charms/mongodb/v1/mongos.py | 2 +- lib/charms/mongodb/v1/shards_interface.py | 2 +- src/charm.py | 23 +++++++- src/config.py | 1 + tests/unit/test_tls_lib.py | 24 ++++---- 9 files changed, 118 insertions(+), 42 deletions(-) diff --git a/lib/charms/mongodb/v0/config_server_interface.py b/lib/charms/mongodb/v0/config_server_interface.py index 9e05e6a1f..c47afc9c7 100644 --- a/lib/charms/mongodb/v0/config_server_interface.py +++ b/lib/charms/mongodb/v0/config_server_interface.py @@ -7,6 +7,7 @@ shards. """ import logging +from typing import Optional from charms.data_platform_libs.v0.data_interfaces import ( DatabaseProvides, @@ -26,6 +27,7 @@ HOSTS_KEY = "host" CONFIG_SERVER_DB_KEY = "config-server-db" MONGOS_SOCKET_URI_FMT = "%2Fvar%2Fsnap%2Fcharmed-mongodb%2Fcommon%2Fvar%2Fmongodb-27018.sock" +INT_TLS_CA_KEY = f"int-{Config.TLS.SECRET_CA_LABEL}" # The unique Charmhub library identifier, never change it LIBID = "58ad1ccca4974932ba22b97781b9b2a0" @@ -35,7 +37,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 7 +LIBPATCH = 8 class ClusterProvider(Object): @@ -106,16 +108,21 @@ def _on_relation_changed(self, event) -> None: # create user and set secrets for mongos relation self.charm.client_relations.oversee_users(None, None) - if self.charm.unit.is_leader(): - self.database_provides.update_relation_data( - event.relation.id, - { - KEYFILE_KEY: self.charm.get_secret( - Config.Relations.APP_SCOPE, Config.Secrets.SECRET_KEYFILE_NAME - ), - CONFIG_SERVER_DB_KEY: config_server_db, - }, - ) + relation_data = { + KEYFILE_KEY: self.charm.get_secret( + Config.Relations.APP_SCOPE, Config.Secrets.SECRET_KEYFILE_NAME + ), + CONFIG_SERVER_DB_KEY: config_server_db, + } + + # if tls enabled + int_tls_ca = self.charm.tls.get_tls_secret( + internal=True, label_name=Config.TLS.SECRET_CA_LABEL + ) + if int_tls_ca: + relation_data[INT_TLS_CA_KEY] = int_tls_ca + + self.database_provides.update_relation_data(event.relation.id, relation_data) def _on_relation_broken(self, event) -> None: # Only relation_deparated events can check if scaling down @@ -166,6 +173,14 @@ def generate_config_server_db(self) -> str: hosts = ",".join(hosts) return f"{replica_set_name}/{hosts}" + def update_ca_secret(self, new_ca: str) -> None: + """Updates the new CA for all related shards.""" + for relation in self.charm.model.relations[self.relation_name]: + if new_ca is None: + self.database_provides.delete_relation_data(relation.id, {INT_TLS_CA_KEY: new_ca}) + else: + self.database_provides.update_relation_data(relation.id, {INT_TLS_CA_KEY: new_ca}) + class ClusterRequirer(Object): """Manage relations between the config server and mongos router on the mongos side.""" @@ -182,7 +197,7 @@ def __init__( relations_aliases=[self.relation_name], database_name=self.charm.database, extra_user_roles=self.charm.extra_user_roles, - additional_secret_fields=[KEYFILE_KEY], + additional_secret_fields=[KEYFILE_KEY, INT_TLS_CA_KEY], ) super().__init__(charm, self.relation_name) @@ -319,4 +334,12 @@ def update_keyfile(self, key_file_contents: str) -> bool: return True + def get_config_server_name(self) -> Optional[str]: + """Returns the name of the Juju Application that mongos is using as a config server.""" + if not self.model.get_relation(self.relation_name): + return None + + # metadata.yaml prevents having multiple config servers + return self.model.get_relation(self.relation_name).app.name + # END: helper functions diff --git a/lib/charms/mongodb/v0/mongodb_tls.py b/lib/charms/mongodb/v0/mongodb_tls.py index c15b94855..a26e116be 100644 --- a/lib/charms/mongodb/v0/mongodb_tls.py +++ b/lib/charms/mongodb/v0/mongodb_tls.py @@ -13,7 +13,6 @@ import socket from typing import List, Optional, Tuple -from charms.mongodb.v0.mongodb import MongoDBConnection from charms.tls_certificates_interface.v1.tls_certificates import ( CertificateAvailableEvent, CertificateExpiringEvent, @@ -39,7 +38,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 10 +LIBPATCH = 11 logger = logging.getLogger(__name__) @@ -75,6 +74,13 @@ def is_tls_enabled(self, internal: bool): def _on_set_tls_private_key(self, event: ActionEvent) -> None: """Set the TLS private key, which will be used for requesting the certificate.""" logger.debug("Request to set TLS private key received.") + if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): + logger.error( + "mongos is not running (not integrated to config-server) deferring renewal of certificates." + ) + event.fail("Mongos cannot set TLS keys until integrated to config-server.") + return + try: self.request_certificate(event.params.get("external-key", None), internal=False) self.request_certificate(event.params.get("internal-key", None), internal=True) @@ -126,8 +132,15 @@ def _parse_tls_file(raw_content: str) -> bytes: ) return base64.b64decode(raw_content) - def _on_tls_relation_joined(self, _: RelationJoinedEvent) -> None: + def _on_tls_relation_joined(self, event: RelationJoinedEvent) -> None: """Request certificate when TLS relation joined.""" + if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): + logger.info( + "mongos is not running (not integrated to config-server) deferring renewal of certificates." + ) + event.defer() + return + self.request_certificate(None, internal=True) self.request_certificate(None, internal=False) @@ -141,16 +154,24 @@ def _on_tls_relation_broken(self, event: RelationBrokenEvent) -> None: self.set_tls_secret(internal, Config.TLS.SECRET_CHAIN_LABEL, None) if self.charm.is_role(Config.Role.CONFIG_SERVER): + self.charm.cluster.update_ca_secret(new_ca=None) self.charm.config_server.update_ca_secret(new_ca=None) logger.info("Restarting mongod with TLS disabled.") self.charm.unit.status = MaintenanceStatus("disabling TLS") self.charm.delete_tls_certificate_from_workload() - self.charm.restart_mongod_service() + self.charm.restart_charm_services() self.charm.unit.status = ActiveStatus() def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: """Enable TLS when TLS certificate available.""" + if self.charm.is_role(Config.Role.MONGOS) and not self.charm.config_server_db: + logger.debug( + "mongos requires config-server in order to start, do not restart with TLS until integrated to config-server" + ) + event.defer() + return + int_csr = self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CSR_LABEL) ext_csr = self.get_tls_secret(internal=False, label_name=Config.TLS.SECRET_CSR_LABEL) @@ -173,6 +194,7 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: self.set_tls_secret(internal, Config.TLS.SECRET_CA_LABEL, event.ca) if self.charm.is_role(Config.Role.CONFIG_SERVER) and internal: + self.charm.cluster.update_ca_secret(new_ca=event.ca) self.charm.config_server.update_ca_secret(new_ca=event.ca) if self.waiting_for_certs(): @@ -187,13 +209,13 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: self.charm.delete_tls_certificate_from_workload() self.charm.push_tls_certificate_to_workload() self.charm.unit.status = MaintenanceStatus("enabling TLS") - self.charm.restart_mongod_service() + self.charm.restart_charm_services() - with MongoDBConnection(self.charm.mongodb_config) as mongo: - if not mongo.is_ready: - self.charm.unit.status = WaitingStatus("Waiting for MongoDB to start") - else: - self.charm.unit.status = ActiveStatus() + if not self.charm.is_db_service_ready(): + self.charm.unit.status = WaitingStatus("Waiting for MongoDB to start") + elif self.charm.unit.status == WaitingStatus("Waiting for MongoDB to start"): + # clear waiting status if db service is ready + self.charm.unit.status = ActiveStatus() def waiting_for_certs(self): """Returns a boolean indicating whether additional certs are needed.""" @@ -208,6 +230,13 @@ def waiting_for_certs(self): def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: """Request the new certificate when old certificate is expiring.""" + if self.charm.is_role(Config.Role.MONGOS) and not self.charm.has_config_server(): + logger.info( + "mongos is not running (not integrated to config-server) deferring renewal of certificates." + ) + event.defer() + return + if ( event.certificate.rstrip() == self.get_tls_secret( @@ -258,6 +287,7 @@ def _get_sans(self) -> List[str]: socket.getfqdn(), f"{self.charm.app.name}-{unit_id}.{self.charm.app.name}-endpoints", str(self.charm.model.get_binding(self.peer_relation).network.bind_address), + "localhost", ] def get_tls_files(self, internal: bool) -> Tuple[Optional[str], Optional[str]]: @@ -307,10 +337,11 @@ def get_tls_secret(self, internal: bool, label_name: str) -> str: def _get_subject_name(self) -> str: """Generate the subject name for CSR.""" # In sharded MongoDB deployments it is a requirement that all subject names match across - # all cluster components - if self.charm.is_role(Config.Role.SHARD): + # all cluster components. The config-server name is the source of truth across mongos and + # shard deployments. + if not self.charm.is_role(Config.Role.CONFIG_SERVER): # until integrated with config-server use current app name as # subject name - return self.charm.shard.get_config_server_name() or self.charm.app.name + return self.charm.get_config_server_name() or self.charm.app.name return self.charm.app.name diff --git a/lib/charms/mongodb/v1/mongodb_provider.py b/lib/charms/mongodb/v1/mongodb_provider.py index ff9bf688c..7d8c9b340 100644 --- a/lib/charms/mongodb/v1/mongodb_provider.py +++ b/lib/charms/mongodb/v1/mongodb_provider.py @@ -124,7 +124,7 @@ def _on_relation_event(self, event): if self.substrate == "vm" and not self.charm.auth_enabled(): logger.debug("Enabling authentication.") self.charm.unit.status = MaintenanceStatus("re-enabling authentication") - self.charm.restart_mongod_service(auth=True) + self.charm.restart_charm_services(auth=True) self.charm.unit.status = ActiveStatus() departed_relation_id = None diff --git a/lib/charms/mongodb/v1/mongodb_vm_legacy_provider.py b/lib/charms/mongodb/v1/mongodb_vm_legacy_provider.py index 6eeb38600..fa3bf9a13 100644 --- a/lib/charms/mongodb/v1/mongodb_vm_legacy_provider.py +++ b/lib/charms/mongodb/v1/mongodb_vm_legacy_provider.py @@ -77,7 +77,7 @@ def _on_legacy_relation_created(self, event): try: logger.debug("Disabling authentication.") self.charm.unit.status = MaintenanceStatus("disabling authentication") - self.charm.restart_mongod_service(auth=False) + self.charm.restart_charm_services(auth=False) self.charm.unit.status = ActiveStatus() except (systemd.SystemdError, snap.SnapError) as e: logger.debug("Error disabling authentication %s", e) diff --git a/lib/charms/mongodb/v1/mongos.py b/lib/charms/mongodb/v1/mongos.py index 68b2d4d0e..f37442ce1 100644 --- a/lib/charms/mongodb/v1/mongos.py +++ b/lib/charms/mongodb/v1/mongos.py @@ -61,7 +61,7 @@ def uri(self): # Auth DB should be specified while user connects to application DB. auth_source = "" if self.database != "admin": - auth_source = "&authSource=admin" + auth_source = "authSource=admin" return ( f"mongodb://{quote_plus(self.username)}:" f"{quote_plus(self.password)}@" diff --git a/lib/charms/mongodb/v1/shards_interface.py b/lib/charms/mongodb/v1/shards_interface.py index bfdd38eb0..1326b3bc3 100644 --- a/lib/charms/mongodb/v1/shards_interface.py +++ b/lib/charms/mongodb/v1/shards_interface.py @@ -954,7 +954,7 @@ def update_keyfile(self, key_file_contents: str) -> None: ) # when the contents of the keyfile change, we must restart the service - self.charm.restart_mongod_service() + self.charm.restart_charm_services() if not self.charm.unit.is_leader(): return diff --git a/src/charm.py b/src/charm.py index 618a8929a..32e30d47b 100755 --- a/src/charm.py +++ b/src/charm.py @@ -1238,7 +1238,7 @@ def stop_mongod_service(self): if self.is_role(Config.Role.CONFIG_SERVER): mongodb_snap.stop(services=["mongos"]) - def restart_mongod_service(self, auth=None): + def restart_charm_services(self, auth=None): """Restarts the mongod service with its associated configuration.""" if auth is None: auth = self.auth_enabled() @@ -1486,6 +1486,27 @@ def is_sharding_component(self) -> bool: """Returns true if charm is running as a sharded component.""" return self.is_role(Config.Role.SHARD) or self.is_role(Config.Role.CONFIG_SERVER) + def get_config_server_name(self) -> Optional[str]: + """Returns the name of the Juju Application that the shard is using as a config server.""" + if not self.is_role(Config.Role.SHARD): + logger.info( + "Component %s is not a shard, cannot be integrated to a config-server.", self.role + ) + return None + + return self.shard.get_config_server_name() + + def is_db_service_ready(self) -> bool: + """Returns True if the underlying database service is ready.""" + with MongoDBConnection(self.mongodb_config) as mongod: + mongod_ready = mongod.is_ready + + if not self.is_role(Config.Role.CONFIG_SERVER): + return mongod_ready + + with MongoDBConnection(self.mongos_config) as mongos: + return mongod_ready and mongos.is_ready + # END: helper functions diff --git a/src/config.py b/src/config.py index 9a8ce4d51..ef5a0cd1c 100644 --- a/src/config.py +++ b/src/config.py @@ -87,6 +87,7 @@ class Role: CONFIG_SERVER = "config-server" REPLICATION = "replication" SHARD = "shard" + MONGOS = "mongos" class Secrets: """Secrets related constants.""" diff --git a/tests/unit/test_tls_lib.py b/tests/unit/test_tls_lib.py index 1aaed6260..83abbcd2e 100644 --- a/tests/unit/test_tls_lib.py +++ b/tests/unit/test_tls_lib.py @@ -63,9 +63,9 @@ def test_tls_relation_joined(self, leader): self.verify_external_rsa_csr() @parameterized.expand([True, False]) - @patch("charm.MongodbOperatorCharm.restart_mongod_service") + @patch("charm.MongodbOperatorCharm.restart_charm_services") @patch_network_get(private_address="1.1.1.1") - def test_tls_relation_broken(self, leader, restart_mongod_service): + def test_tls_relation_broken(self, leader, restart_charm_services): """Test removes both external and internal certificates.""" self.harness.set_leader(leader) # set initial certificate values @@ -83,7 +83,7 @@ def test_tls_relation_broken(self, leader, restart_mongod_service): self.assertIsNone(chain_secret) # units should be restarted after updating TLS settings - restart_mongod_service.assert_called() + restart_charm_services.assert_called() @patch_network_get(private_address="1.1.1.1") def test_external_certificate_expiring(self): @@ -139,8 +139,8 @@ def test_unknown_certificate_expiring(self): @patch_network_get(private_address="1.1.1.1") @patch("charm.MongodbOperatorCharm.push_tls_certificate_to_workload") - @patch("charm.MongodbOperatorCharm.restart_mongod_service") - def test_external_certificate_available(self, restart_mongod_service, _): + @patch("charm.MongodbOperatorCharm.restart_charm_services") + def test_external_certificate_available(self, restart_charm_services, _): """Tests behavior when external certificate is made available.""" # assume relation exists with a current certificate self.relate_to_tls_certificates_operator() @@ -163,12 +163,12 @@ def test_external_certificate_available(self, restart_mongod_service, _): self.assertEqual(unit_secret, "unit-cert") self.assertEqual(ca_secret, "unit-ca") - restart_mongod_service.assert_called() + restart_charm_services.assert_called() @patch_network_get(private_address="1.1.1.1") @patch("charm.MongodbOperatorCharm.push_tls_certificate_to_workload") - @patch("charm.MongodbOperatorCharm.restart_mongod_service") - def test_internal_certificate_available(self, restart_mongod_service, _): + @patch("charm.MongodbOperatorCharm.restart_charm_services") + def test_internal_certificate_available(self, restart_charm_services, _): """Tests behavior when internal certificate is made available.""" # assume relation exists with a current certificate self.relate_to_tls_certificates_operator() @@ -191,12 +191,12 @@ def test_internal_certificate_available(self, restart_mongod_service, _): self.assertEqual(unit_secret, "int-cert") self.assertEqual(ca_secret, "int-ca") - restart_mongod_service.assert_called() + restart_charm_services.assert_called() @patch_network_get(private_address="1.1.1.1") @patch("charm.MongodbOperatorCharm.push_tls_certificate_to_workload") - @patch("charm.MongodbOperatorCharm.restart_mongod_service") - def test_unknown_certificate_available(self, restart_mongod_service, _): + @patch("charm.MongodbOperatorCharm.restart_charm_services") + def test_unknown_certificate_available(self, restart_charm_services, _): """Tests that when an unknown certificate is available, nothing is updated.""" # assume relation exists with a current certificate self.relate_to_tls_certificates_operator() @@ -221,7 +221,7 @@ def test_unknown_certificate_available(self, restart_mongod_service, _): self.assertEqual(unit_secret, "app-cert-old") self.assertEqual(ca_secret, "app-ca-old") - restart_mongod_service.assert_not_called() + restart_charm_services.assert_not_called() # Helper functions def relate_to_tls_certificates_operator(self) -> int: