diff --git a/lib/charms/mongodb/v0/config_server_interface.py b/lib/charms/mongodb/v0/config_server_interface.py index cdb733d9..44f5b26b 100644 --- a/lib/charms/mongodb/v0/config_server_interface.py +++ b/lib/charms/mongodb/v0/config_server_interface.py @@ -11,10 +11,11 @@ from charms.data_platform_libs.v0.data_interfaces import ( DatabaseProvides, + DatabaseRequestedEvent, DatabaseRequires, ) from charms.mongodb.v1.mongos import MongosConnection -from ops.charm import CharmBase, EventBase, RelationBrokenEvent +from ops.charm import CharmBase, EventBase, RelationBrokenEvent, RelationChangedEvent from ops.framework import Object from ops.model import ( ActiveStatus, @@ -42,7 +43,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 11 +LIBPATCH = 13 class ClusterProvider(Object): @@ -57,6 +58,9 @@ def __init__( self.database_provides = DatabaseProvides(self.charm, relation_name=self.relation_name) super().__init__(charm, self.relation_name) + self.framework.observe( + self.database_provides.on.database_requested, self._on_database_requested + ) self.framework.observe( charm.on[self.relation_name].relation_changed, self._on_relation_changed ) @@ -105,8 +109,14 @@ def is_valid_mongos_integration(self) -> bool: return True - def _on_relation_changed(self, event) -> None: - """Handles providing mongos with KeyFile and hosts.""" + def _on_database_requested(self, event: DatabaseRequestedEvent | RelationChangedEvent) -> None: + """Handles the database requested event. + + The first time secrets are written to relations should be on this event. + + Note: If secrets are written for the first time on other events we risk + the chance of writing secrets in plain sight. + """ if not self.pass_hook_checks(event): if not self.is_valid_mongos_integration(): self.charm.status.set_and_share_status( @@ -116,12 +126,9 @@ def _on_relation_changed(self, event) -> None: ) logger.info("Skipping relation joined event: hook checks did not pass") return - config_server_db = self.generate_config_server_db() - # create user and set secrets for mongos relation self.charm.client_relations.oversee_users(None, None) - relation_data = { KEYFILE_KEY: self.charm.get_secret( Config.Relations.APP_SCOPE, Config.Secrets.SECRET_KEYFILE_NAME @@ -135,9 +142,20 @@ def _on_relation_changed(self, event) -> None: ) 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_changed(self, event: RelationChangedEvent) -> None: + """Handles providing mongos with KeyFile and hosts.""" + # First we need to ensure that the database requested event has run + # otherwise we risk the chance of writing secrets in plain sight. + if not self.database_provides.fetch_relation_field(event.relation.id, "database"): + logger.info("Database Requested has not run yet, skipping.") + event.defer() + return + + # TODO : This workflow is a fix until we have time for a better and complete fix (DPE-5513) + self._on_database_requested(event) + def _on_relation_broken(self, event) -> None: if self.charm.upgrade_in_progress: logger.warning( @@ -300,7 +318,8 @@ def _on_relation_changed(self, event) -> None: return self.charm.status.set_and_share_status(ActiveStatus()) - self.charm.mongos_intialised = True + if self.charm.unit.is_leader(): + self.charm.mongos_initialised = True def _on_relation_broken(self, event: RelationBrokenEvent) -> None: # Only relation_deparated events can check if scaling down diff --git a/lib/charms/mongodb/v0/mongo.py b/lib/charms/mongodb/v0/mongo.py index f8ef0e44..9ae3cd2d 100644 --- a/lib/charms/mongodb/v0/mongo.py +++ b/lib/charms/mongodb/v0/mongo.py @@ -31,7 +31,7 @@ class NotReadyError(PyMongoError): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 1 +LIBPATCH = 2 ADMIN_AUTH_SOURCE = "authSource=admin" SYSTEM_DBS = ("admin", "local", "config") @@ -40,6 +40,7 @@ class NotReadyError(PyMongoError): {"role": "userAdminAnyDatabase", "db": "admin"}, {"role": "readWriteAnyDatabase", "db": "admin"}, {"role": "userAdmin", "db": "admin"}, + {"role": "enableSharding", "db": "admin"}, ], "monitor": [ {"role": "explainRole", "db": "admin"}, @@ -127,7 +128,12 @@ def uri(self): def supported_roles(config: MongoConfiguration): """Return the supported roles for the given configuration.""" - return REGULAR_ROLES | {"default": [{"db": config.database, "role": "readWrite"}]} + return REGULAR_ROLES | { + "default": [ + {"db": config.database, "role": "readWrite"}, + {"db": config.database, "role": "enableSharding"}, + ] + } class MongoConnection: diff --git a/lib/charms/mongodb/v1/helpers.py b/lib/charms/mongodb/v1/helpers.py index 937786b8..1b2f1064 100644 --- a/lib/charms/mongodb/v1/helpers.py +++ b/lib/charms/mongodb/v1/helpers.py @@ -8,7 +8,7 @@ import secrets import string import subprocess -from typing import List +from typing import List, Mapping from charms.mongodb.v1.mongodb import MongoConfiguration from ops.model import ActiveStatus, MaintenanceStatus, StatusBase, WaitingStatus @@ -23,7 +23,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 8 +LIBPATCH = 12 # path to store mongodb ketFile KEY_FILE = "keyFile" @@ -89,7 +89,7 @@ def get_create_user_cmd(config: MongoConfiguration, mongo_path=MONGO_SHELL) -> L "mongodb://localhost/admin", "--quiet", "--eval", - "db.createUser({" + '"db.createUser({' f" user: '{config.username}'," " pwd: passwordPrompt()," " roles:[" @@ -99,7 +99,7 @@ def get_create_user_cmd(config: MongoConfiguration, mongo_path=MONGO_SHELL) -> L " ]," " mechanisms: ['SCRAM-SHA-256']," " passwordDigestor: 'server'," - "})", + '})"', ] @@ -118,7 +118,7 @@ def get_mongos_args( binding_ips = ( "--bind_ip_all" if external_connectivity - else f"--bind_ip {MONGODB_COMMON_DIR}/var/mongodb-27018.sock" + else f"--bind_ip {MONGODB_COMMON_DIR}/var/mongodb-27018.sock --filePermissions 0766" ) # mongos running on the config server communicates through localhost @@ -320,3 +320,25 @@ def add_args_to_env(var: str, args: str): with open(Config.ENV_VAR_PATH, "w") as service_file: service_file.writelines(env_vars) + + +def safe_exec( + command: list[str] | str, + env: Mapping[str, str] | None = None, + working_dir: str | None = None, +) -> str: + """Execs a command on the workload in a safe way.""" + try: + output = subprocess.check_output( + command, + stderr=subprocess.PIPE, + universal_newlines=True, + shell=isinstance(command, str), + env=env, + cwd=working_dir, + ) + logger.debug(f"{output=}") + return output + except subprocess.CalledProcessError as err: + logger.error(f"cmd failed - {err.cmd}, {err.stdout}, {err.stderr}") + raise diff --git a/lib/charms/mongodb/v0/mongodb_tls.py b/lib/charms/mongodb/v1/mongodb_tls.py similarity index 79% rename from lib/charms/mongodb/v0/mongodb_tls.py rename to lib/charms/mongodb/v1/mongodb_tls.py index 6669c1c3..8f00bde7 100644 --- a/lib/charms/mongodb/v0/mongodb_tls.py +++ b/lib/charms/mongodb/v1/mongodb_tls.py @@ -1,4 +1,4 @@ -# Copyright 2023 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. """In this class we manage client database relations. @@ -8,10 +8,11 @@ external relation. """ import base64 +import json import logging import re import socket -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from charms.tls_certificates_interface.v3.tls_certificates import ( CertificateAvailableEvent, @@ -20,25 +21,30 @@ generate_csr, generate_private_key, ) +from cryptography import x509 +from cryptography.hazmat.backends import default_backend from ops.charm import ActionEvent, RelationBrokenEvent, RelationJoinedEvent from ops.framework import Object -from ops.model import ActiveStatus, MaintenanceStatus, Unit, WaitingStatus +from ops.model import ActiveStatus, MaintenanceStatus, WaitingStatus from config import Config UNIT_SCOPE = Config.Relations.UNIT_SCOPE Scopes = Config.Relations.Scopes - +SANS_DNS_KEY = "sans_dns" +SANS_IPS_KEY = "sans_ips" # The unique Charmhub library identifier, never change it LIBID = "e02a50f0795e4dd292f58e93b4f493dd" # Increment this major API version when introducing breaking changes -LIBAPI = 0 +LIBAPI = 1 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 15 +LIBPATCH = 4 + +WAIT_CERT_UPDATE = "wait-cert-updated" logger = logging.getLogger(__name__) @@ -99,17 +105,21 @@ def request_certificate( internal: bool, ): """Request TLS certificate.""" + if not self.charm.model.get_relation(Config.TLS.TLS_PEER_RELATION): + return + if param is None: key = generate_private_key() else: key = self._parse_tls_file(param) + sans = self.get_new_sans() csr = generate_csr( private_key=key, subject=self._get_subject_name(), organization=self._get_subject_name(), - sans=self._get_sans(), - sans_ip=[str(self.charm.model.get_binding(self.peer_relation).network.bind_address)], + sans=sans[SANS_DNS_KEY], + sans_ip=sans[SANS_IPS_KEY], ) self.set_tls_secret(internal, Config.TLS.SECRET_KEY_LABEL, key.decode("utf-8")) self.set_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL, csr.decode("utf-8")) @@ -118,9 +128,8 @@ def request_certificate( label = "int" if internal else "ext" self.charm.unit_peer_data[f"{label}_certs_subject"] = self._get_subject_name() self.charm.unit_peer_data[f"{label}_certs_subject"] = self._get_subject_name() - - if self.charm.model.get_relation(Config.TLS.TLS_PEER_RELATION): - self.certs.request_certificate_creation(certificate_signing_request=csr) + self.certs.request_certificate_creation(certificate_signing_request=csr) + self.set_waiting_for_cert_to_update(internal=internal, waiting=True) @staticmethod def _parse_tls_file(raw_content: str) -> bytes: @@ -158,12 +167,18 @@ def _on_tls_relation_joined(self, event: RelationJoinedEvent) -> None: def _on_tls_relation_broken(self, event: RelationBrokenEvent) -> None: """Disable TLS when TLS relation broken.""" - logger.debug("Disabling external and internal TLS for unit: %s", self.charm.unit.name) + if not self.charm.db_initialised: + logger.info("Deferring %s. db is not initialised.", str(type(event))) + event.defer() + return + if self.charm.upgrade_in_progress: logger.warning( "Disabling TLS is not supported during an upgrade. The charm may be in a broken, unrecoverable state." ) + logger.debug("Disabling external and internal TLS for unit: %s", self.charm.unit.name) + for internal in [True, False]: self.set_tls_secret(internal, Config.TLS.SECRET_CA_LABEL, None) self.set_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL, None) @@ -188,6 +203,11 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: event.defer() return + if not self.charm.db_initialised: + logger.info("Deferring %s. db is not initialised.", str(type(event))) + 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) @@ -208,12 +228,13 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: ) self.set_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL, event.certificate) self.set_tls_secret(internal, Config.TLS.SECRET_CA_LABEL, event.ca) + self.set_waiting_for_cert_to_update(internal=internal, waiting=False) 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(): + if self.waiting_for_both_certs(): logger.debug( "Defer till both internal and external TLS certificates available to avoid second restart." ) @@ -235,7 +256,7 @@ def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: # clear waiting status if db service is ready self.charm.status.set_and_share_status(ActiveStatus()) - def waiting_for_certs(self): + def waiting_for_both_certs(self): """Returns a boolean indicating whether additional certs are needed.""" if not self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CERT_LABEL): logger.debug("Waiting for internal certificate.") @@ -268,7 +289,6 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: == self.get_tls_secret(internal=True, label_name=Config.TLS.SECRET_CERT_LABEL).rstrip() ): logger.debug("The internal TLS certificate expiring.") - internal = True else: logger.error("An unknown certificate expiring.") @@ -277,12 +297,13 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: logger.debug("Generating a new Certificate Signing Request.") key = self.get_tls_secret(internal, Config.TLS.SECRET_KEY_LABEL).encode("utf-8") old_csr = self.get_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL).encode("utf-8") + sans = self.get_new_sans() new_csr = generate_csr( private_key=key, subject=self._get_subject_name(), organization=self._get_subject_name(), - sans=self._get_sans(), - sans_ip=[str(self.charm.model.get_binding(self.peer_relation).network.bind_address)], + sans=sans[SANS_DNS_KEY], + sans_ip=sans[SANS_IPS_KEY], ) logger.debug("Requesting a certificate renewal.") @@ -293,20 +314,51 @@ def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: self.set_tls_secret(internal, Config.TLS.SECRET_CSR_LABEL, new_csr.decode("utf-8")) - def _get_sans(self) -> List[str]: + def get_new_sans(self) -> Dict: """Create a list of DNS names for a MongoDB unit. Returns: A list representing the hostnames of the MongoDB unit. """ unit_id = self.charm.unit.name.split("/")[1] - return [ - f"{self.charm.app.name}-{unit_id}", - 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", - ] + + sans = { + SANS_DNS_KEY: [ + f"{self.charm.app.name}-{unit_id}", + socket.getfqdn(), + "localhost", + f"{self.charm.app.name}-{unit_id}.{self.charm.app.name}-endpoints", + ], + SANS_IPS_KEY: [ + str(self.charm.model.get_binding(self.peer_relation).network.bind_address) + ], + } + + if self.charm.is_role(Config.Role.MONGOS) and self.charm.is_external_client: + sans[SANS_IPS_KEY].append( + self.charm.get_ext_mongos_host(self.charm.unit, incl_port=False) + ) + + return sans + + def get_current_sans(self, internal: bool) -> List[str] | None: + """Gets the current SANs for the unit cert.""" + # if unit has no certificates do not proceed. + if not self.is_tls_enabled(internal=internal): + return + + pem_file = self.get_tls_secret(internal, Config.TLS.SECRET_CERT_LABEL) + + try: + cert = x509.load_pem_x509_certificate(pem_file.encode(), default_backend()) + sans = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + sans_ip = [str(san) for san in sans.get_values_for_type(x509.IPAddress)] + sans_dns = [str(san) for san in sans.get_values_for_type(x509.DNSName)] + except x509.ExtensionNotFound: + sans_ip = [] + sans_dns = [] + + return {SANS_IPS_KEY: sorted(sans_ip), SANS_DNS_KEY: sorted(sans_dns)} def get_tls_files(self, internal: bool) -> Tuple[Optional[str], Optional[str]]: """Prepare TLS files in special MongoDB way. @@ -333,13 +385,6 @@ def get_tls_files(self, internal: bool) -> Tuple[Optional[str], Optional[str]]: return ca_file, pem_file - def get_host(self, unit: Unit): - """Retrieves the hostname of the unit based on the substrate.""" - if self.substrate == "vm": - return self.charm.unit_ip(unit) - else: - return self.charm.get_hostname_for_unit(unit) - def set_tls_secret(self, internal: bool, label_name: str, contents: str) -> None: """Sets TLS secret, based on whether or not it is related to internal connections.""" scope = "int" if internal else "ext" @@ -363,3 +408,23 @@ def _get_subject_name(self) -> str: return self.charm.get_config_server_name() or self.charm.app.name return self.charm.app.name + + def is_set_waiting_for_cert_to_update( + self, + internal=False, + ) -> bool: + """Returns True we are waiting for a cert to update.""" + scope = "int" if internal else "ext" + label_name = f"{scope}-{WAIT_CERT_UPDATE}" + + return json.loads(self.charm.unit_peer_data.get(label_name, "false")) + + def set_waiting_for_cert_to_update( + self, + waiting: bool, + internal: bool, + ) -> None: + """Sets a boolean indicator, for whether or not we are waiting for a cert to update.""" + scope = "int" if internal else "ext" + label_name = f"{scope}-{WAIT_CERT_UPDATE}" + self.charm.unit_peer_data[label_name] = json.dumps(waiting) diff --git a/lib/charms/mongos/v0/set_status.py b/lib/charms/mongos/v0/set_status.py index e70bedf0..3f57015e 100644 --- a/lib/charms/mongos/v0/set_status.py +++ b/lib/charms/mongos/v0/set_status.py @@ -61,6 +61,7 @@ def clear_status(self, status_to_clear): status_to_clear, self.charm.unit.status, ) + return # TODO: In the future compute the next highest priority status. self.charm.unit.status = ActiveStatus() diff --git a/src/charm.py b/src/charm.py index 7fbe2355..2183fb54 100755 --- a/src/charm.py +++ b/src/charm.py @@ -24,7 +24,7 @@ from charms.mongodb.v0.mongo import MongoConfiguration, MongoConnection from charms.mongos.v0.set_status import MongosStatusHandler from charms.mongodb.v1.mongodb_provider import MongoDBProvider, FailedToGetHostsError -from charms.mongodb.v0.mongodb_tls import MongoDBTLS +from charms.mongodb.v1.mongodb_tls import MongoDBTLS from charms.mongodb.v0.mongodb_secrets import SecretCache from charms.mongodb.v0.mongodb_secrets import generate_secret_label from charms.mongodb.v1.helpers import get_mongos_args