diff --git a/lib/charms/mysql/v0/mysql.py b/lib/charms/mysql/v0/mysql.py index 53b251508..57f32dbe8 100644 --- a/lib/charms/mysql/v0/mysql.py +++ b/lib/charms/mysql/v0/mysql.py @@ -85,6 +85,7 @@ def wait_until_mysql_connection(self) -> None: List, Literal, Optional, + Set, Tuple, Union, get_args, @@ -133,7 +134,7 @@ def wait_until_mysql_connection(self) -> None: # Increment this major API version when introducing breaking changes LIBAPI = 0 -LIBPATCH = 89 +LIBPATCH = 90 UNIT_TEARDOWN_LOCKNAME = "unit-teardown" UNIT_ADD_LOCKNAME = "unit-add" @@ -151,6 +152,24 @@ def wait_until_mysql_connection(self) -> None: SECRET_INTERNAL_LABEL = "secret-id" SECRET_DELETED_LABEL = "None" +ROLE_DBA = "charmed_dba" +ROLE_DDL = "charmed_ddl" +ROLE_DML = "charmed_dml" +ROLE_READ = "charmed_read" +ROLE_STATS = "charmed_stats" +ROLE_BACKUP = "charmed_backup" +ROLE_MAX_LENGTH = 32 + +# TODO: +# Remove legacy role when migrating to MySQL 8.4 +# (when breaking changes are allowed) +LEGACY_ROLE_ROUTER = "mysqlrouter" +MODERN_ROLE_ROUTER = "charmed_router" + +FORBIDDEN_EXTRA_ROLES = { + ROLE_BACKUP, +} + APP_SCOPE = "app" UNIT_SCOPE = "unit" Scopes = Literal["app", "unit"] @@ -178,10 +197,18 @@ def name(self): return "<{}.{}>".format(type(self).__module__, type(self).__name__) +class MySQLConfigureMySQLRolesError(Error): + """Exception raised when creating a role fails.""" + + class MySQLConfigureMySQLUsersError(Error): """Exception raised when creating a user fails.""" +class MySQLListMySQLRolesError(Error): + """Exception raised when there is an issue listing database roles.""" + + class MySQLCheckUserExistenceError(Error): """Exception raised when checking for the existence of a MySQL user.""" @@ -190,8 +217,12 @@ class MySQLConfigureRouterUserError(Error): """Exception raised when configuring the MySQLRouter user.""" -class MySQLCreateApplicationDatabaseAndScopedUserError(Error): - """Exception raised when creating application database and scoped user.""" +class MySQLCreateApplicationDatabaseError(Error): + """Exception raised when creating application database.""" + + +class MySQLCreateApplicationScopedUserError(Error): + """Exception raised when creating application scoped user.""" class MySQLGetRouterUsersError(Error): @@ -271,10 +302,6 @@ class MySQLSetClusterPrimaryError(Error): """Exception raised when there is an issue setting the primary instance.""" -class MySQLGrantPrivilegesToUserError(Error): - """Exception raised when there is an issue granting privileges to user.""" - - class MySQLNoMemberStateError(Error): """Exception raised when there is no member state.""" @@ -955,6 +982,7 @@ def __init__( self.socket_uri = f"({socket_path})" self.cluster_name = cluster_name self.cluster_set_name = cluster_set_name + self.root_user = ROOT_USERNAME self.root_password = root_password self.server_config_user = server_config_user self.server_config_password = server_config_password @@ -1055,8 +1083,8 @@ def render_mysqld_configuration( # noqa: C901 # do not enable slow query logs, but specify a log file path in case # the admin enables them manually config["mysqld"] = { - "bind-address": "0.0.0.0", - "mysqlx-bind-address": "0.0.0.0", + "bind_address": "0.0.0.0", + "mysqlx_bind_address": "0.0.0.0", "admin_address": self.instance_address, "report_host": self.instance_address, "max_connections": str(max_connections), @@ -1072,6 +1100,7 @@ def render_mysqld_configuration( # noqa: C901 "loose-audit_log_file": f"{snap_common}/var/log/mysql/audit.log", "gtid_mode": "ON", "enforce_gtid_consistency": "ON", + "activate_all_roles_on_login": "ON", } if audit_log_enabled: @@ -1096,52 +1125,137 @@ def render_mysqld_configuration( # noqa: C901 config.write(string_io) return string_io.getvalue(), dict(config["mysqld"]) - def configure_mysql_users(self) -> None: - """Configure the MySQL users for the instance.""" - # SYSTEM_USER and SUPER privileges to revoke from the root users - # Reference: https://dev.mysql.com/doc/refman/8.0/en/privileges-provided.html#priv_super - privileges_to_revoke = ( - "SYSTEM_USER", - "SYSTEM_VARIABLES_ADMIN", - "SUPER", - "REPLICATION_SLAVE_ADMIN", - "GROUP_REPLICATION_ADMIN", - "BINLOG_ADMIN", - "SET_USER_ID", - "ENCRYPTION_KEY_ADMIN", - "VERSION_TOKEN_ADMIN", - "CONNECTION_ADMIN", - ) + def configure_mysql_router_roles(self) -> None: + """Configure the MySQL Router roles for the instance.""" + for role in (LEGACY_ROLE_ROUTER, MODERN_ROLE_ROUTER): + existing_roles = self.list_mysql_roles(role) + if role in existing_roles: + continue + + logger.debug(f"Missing MySQL role {role}") + configure_role_commands = [ + f"CREATE ROLE {role}", + f"GRANT CREATE ON *.* TO {role}", + f"GRANT CREATE USER ON *.* TO {role}", + # The granting of all privileges to the MySQL Router role + # can only be restricted when the privileges to the users + # created by such role are restricted as well + # https://github.com/canonical/mysql-router-operator/blob/main/src/mysql_shell/__init__.py#L134-L136 + f"GRANT ALL ON *.* TO {role} WITH GRANT OPTION", + ] + + try: + logger.debug(f"Configuring Router role for {self.instance_address}") + self._run_mysqlcli_script( + configure_role_commands, + user=self.root_user, + password=self.root_password, + ) + except MySQLClientError: + logger.error(f"Failed to configure Router role for {self.instance_address}") + raise MySQLConfigureMySQLRolesError + + def configure_mysql_system_roles(self) -> None: + """Configure the MySQL system roles for the instance.""" + role_to_queries = { + ROLE_READ: [ + f"CREATE ROLE {ROLE_READ}", + ], + ROLE_DML: [ + f"CREATE ROLE {ROLE_DML}", + ], + ROLE_STATS: [ + f"CREATE ROLE {ROLE_STATS}", + f"GRANT SELECT ON performance_schema.* TO {ROLE_STATS}", + f"GRANT PROCESS, RELOAD, REPLICATION CLIENT ON *.* TO {ROLE_STATS}", + ], + ROLE_BACKUP: [ + f"CREATE ROLE {ROLE_BACKUP}", + f"GRANT charmed_stats TO {ROLE_BACKUP}", + f"GRANT EXECUTE, LOCK TABLES, PROCESS, RELOAD ON *.* TO {ROLE_BACKUP}", + f"GRANT BACKUP_ADMIN, CONNECTION_ADMIN ON *.* TO {ROLE_BACKUP}", + ], + ROLE_DDL: [ + f"CREATE ROLE {ROLE_DDL}", + f"GRANT charmed_dml TO {ROLE_DDL}", + f"GRANT ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE TABLESPACE, CREATE VIEW, DROP, INDEX, LOCK TABLES, REFERENCES, SHOW_ROUTINE, SHOW VIEW, TRIGGER ON *.* TO {ROLE_DDL}", + ], + ROLE_DBA: [ + f"CREATE ROLE {ROLE_DBA}", + f"GRANT charmed_dml TO {ROLE_DBA}", + f"GRANT charmed_stats TO {ROLE_DBA}", + f"GRANT charmed_backup TO {ROLE_DBA}", + f"GRANT charmed_ddl TO {ROLE_DBA}", + f"GRANT EVENT, SHOW DATABASES, SHUTDOWN ON *.* TO {ROLE_DBA}", + f"GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON *.* TO {ROLE_DBA}", + f"GRANT AUDIT_ADMIN, CONNECTION_ADMIN, SYSTEM_VARIABLES_ADMIN ON *.* TO {ROLE_DBA}", + ], + } - # privileges for the backups user: - # https://docs.percona.com/percona-xtrabackup/8.0/using_xtrabackup/privileges.html#permissions-and-privileges-needed - # CONNECTION_ADMIN added to provide it privileges to connect to offline_mode node - configure_users_commands = ( + configure_roles_commands = [] + existing_roles = self.list_mysql_roles("charmed_%") + + for role, queries in role_to_queries.items(): + if role not in existing_roles: + logger.debug(f"Missing MySQL role {role}") + configure_roles_commands.extend(queries) + + try: + logger.debug(f"Configuring MySQL roles for {self.instance_address}") + self._run_mysqlcli_script( + configure_roles_commands, + user=self.root_user, + password=self.root_password, + ) + except MySQLClientError: + logger.error(f"Failed to configure roles for {self.instance_address}") + raise MySQLConfigureMySQLRolesError + + def configure_mysql_system_users(self) -> None: + """Configure the MySQL system users for the instance.""" + configure_users_commands = [ + f"UPDATE mysql.user SET authentication_string=null WHERE User='{self.root_user}' and Host='localhost'", + f"ALTER USER '{self.root_user}'@'localhost' IDENTIFIED BY '{self.root_password}'", f"CREATE USER '{self.server_config_user}'@'%' IDENTIFIED BY '{self.server_config_password}'", - f"GRANT ALL ON *.* TO '{self.server_config_user}'@'%' WITH GRANT OPTION", f"CREATE USER '{self.monitoring_user}'@'%' IDENTIFIED BY '{self.monitoring_password}' WITH MAX_USER_CONNECTIONS 3", - f"GRANT SYSTEM_USER, SELECT, PROCESS, SUPER, REPLICATION CLIENT, RELOAD ON *.* TO '{self.monitoring_user}'@'%'", f"CREATE USER '{self.backups_user}'@'%' IDENTIFIED BY '{self.backups_password}'", - f"GRANT CONNECTION_ADMIN, BACKUP_ADMIN, PROCESS, RELOAD, LOCK TABLES, REPLICATION CLIENT ON *.* TO '{self.backups_user}'@'%'", - f"GRANT SELECT ON performance_schema.log_status TO '{self.backups_user}'@'%'", - f"GRANT SELECT ON performance_schema.keyring_component_status TO '{self.backups_user}'@'%'", - f"GRANT SELECT ON performance_schema.replication_group_members TO '{self.backups_user}'@'%'", - "UPDATE mysql.user SET authentication_string=null WHERE User='root' and Host='localhost'", - f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.root_password}'", - f"REVOKE {', '.join(privileges_to_revoke)} ON *.* FROM 'root'@'localhost'", + ] + + # SYSTEM_USER and SUPER privileges to revoke from the root users + # Reference: https://dev.mysql.com/doc/refman/8.0/en/privileges-provided.html#priv_super + configure_users_commands.extend([ + f"GRANT ALL ON *.* TO '{self.server_config_user}'@'%' WITH GRANT OPTION", + f"GRANT charmed_stats TO '{self.monitoring_user}'@'%'", + f"GRANT charmed_backup TO '{self.backups_user}'@'%'", + f"REVOKE BINLOG_ADMIN, CONNECTION_ADMIN, ENCRYPTION_KEY_ADMIN, GROUP_REPLICATION_ADMIN, REPLICATION_SLAVE_ADMIN, SET_USER_ID, SUPER, SYSTEM_USER, SYSTEM_VARIABLES_ADMIN, VERSION_TOKEN_ADMIN ON *.* FROM '{self.root_user}'@'localhost'", "FLUSH PRIVILEGES", - ) + ]) try: logger.debug(f"Configuring MySQL users for {self.instance_address}") self._run_mysqlcli_script( configure_users_commands, + user=self.root_user, password=self.root_password, ) except MySQLClientError: logger.error(f"Failed to configure users for: {self.instance_address}") raise MySQLConfigureMySQLUsersError + def list_mysql_roles(self, name_pattern: str) -> Set[str]: + """Returns a set with the MySQL roles.""" + try: + query_commands = (f"SELECT User FROM mysql.user WHERE User LIKE '{name_pattern}'",) + output = self._run_mysqlcli_script( + query_commands, + user=self.root_user, + password=self.root_password, + ) + return {row[0] for row in output} + except MySQLClientError: + logger.error("Failed to list roles") + raise MySQLListMySQLRolesError + def _plugin_file_exists(self, plugin_file_name: str) -> bool: """Check if the plugin file exists. @@ -1242,6 +1356,7 @@ def _get_installed_plugins(self) -> set[str]: try: output = self._run_mysqlcli_script( ("select name from mysql.plugin",), + user=self.root_user, password=self.root_password, ) return { @@ -1311,51 +1426,89 @@ def configure_mysqlrouter_user( logger.error(f"Failed to configure mysqlrouter {username=}") raise MySQLConfigureRouterUserError - def create_application_database_and_scoped_user( + def create_database(self, database: str) -> None: + """Create an application database.""" + role_name = f"charmed_dba_{database}" + + if len(database) >= ROLE_MAX_LENGTH: + logger.error(f"Failed to create application database {database}") + raise MySQLCreateApplicationDatabaseError("Name longer than 32 characters") + if len(role_name) >= ROLE_MAX_LENGTH: + logger.warning(f"Pruning application database role name {role_name}") + role_name = role_name[:ROLE_MAX_LENGTH] + + create_database_commands = ( + "shell.connect_to_primary()", + f'session.run_sql("CREATE DATABASE IF NOT EXISTS `{database}`;")', + f'session.run_sql("GRANT SELECT ON `{database}`.* TO {ROLE_READ};")', + f'session.run_sql("GRANT SELECT, INSERT, DELETE, UPDATE ON `{database}`.* TO {ROLE_DML};")', + ) + create_dba_role_commands = ( + f'session.run_sql("CREATE ROLE IF NOT EXISTS `{role_name}`;")', + f'session.run_sql("GRANT SELECT, INSERT, DELETE, UPDATE, EXECUTE ON `{database}`.* TO {role_name};")', + f'session.run_sql("GRANT ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE VIEW, DROP, INDEX, LOCK TABLES, REFERENCES, TRIGGER ON `{database}`.* TO {role_name};")', + ) + + try: + self._run_mysqlsh_script( + "\n".join(create_database_commands + create_dba_role_commands), + user=self.server_config_user, + password=self.server_config_password, + host=self.instance_def(self.server_config_user), + ) + except MySQLClientError as e: + logger.error(f"Failed to create application database {database}") + raise MySQLCreateApplicationDatabaseError(e.message) + + def create_scoped_user( self, - database_name: str, + database: str, username: str, password: str, hostname: str, *, unit_name: Optional[str] = None, - create_database: bool = True, + extra_roles: Optional[List[str]] = None, ) -> None: - """Create an application database and a user scoped to the created database.""" + """Create an application user scoped to the created database.""" + if extra_roles is not None and set(extra_roles) & FORBIDDEN_EXTRA_ROLES: + logger.error(f"Invalid extra user roles: {extra_roles}") + raise MySQLCreateApplicationScopedUserError("invalid role(s) for extra user roles") + attributes = {} if unit_name is not None: - attributes["unit_name"] = unit_name - try: - # Using server_config_user as we are sure it has create database grants - connect_command = ("shell.connect_to_primary()",) - create_database_commands = ( - f'session.run_sql("CREATE DATABASE IF NOT EXISTS `{database_name}`;")', - ) + attributes = {"unit_name": unit_name} + if extra_roles is not None: + extra_roles = ", ".join(extra_roles) - escaped_user_attributes = json.dumps(attributes).replace('"', r"\"") - # Using server_config_user as we are sure it has create user grants - create_scoped_user_commands = ( - f"session.run_sql(\"CREATE USER `{username}`@`{hostname}` IDENTIFIED BY '{password}' ATTRIBUTE '{escaped_user_attributes}';\")", + create_scoped_user_attributes = json.dumps(attributes).replace('"', r"\"") + create_scoped_user_commands = ( + "shell.connect_to_primary()", + f"session.run_sql(\"CREATE USER `{username}`@`{hostname}` IDENTIFIED BY '{password}' ATTRIBUTE '{create_scoped_user_attributes}';\")", + ) + + if extra_roles: + grant_scoped_user_commands = ( + f'session.run_sql("GRANT {extra_roles} TO `{username}`@`{hostname}`;")', + ) + else: + # Legacy behaviour when no explicit roles were assigned to users + # (before system roles were introduced). + grant_scoped_user_commands = ( f'session.run_sql("GRANT USAGE ON *.* TO `{username}`@`{hostname}`;")', - f'session.run_sql("GRANT ALL PRIVILEGES ON `{database_name}`.* TO `{username}`@`{hostname}`;")', + f'session.run_sql("GRANT ALL PRIVILEGES ON `{database}`.* TO `{username}`@`{hostname}`;")', ) - if create_database: - commands = connect_command + create_database_commands + create_scoped_user_commands - else: - commands = connect_command + create_scoped_user_commands - + try: self._run_mysqlsh_script( - "\n".join(commands), + "\n".join(create_scoped_user_commands + grant_scoped_user_commands), user=self.server_config_user, password=self.server_config_password, host=self.instance_def(self.server_config_user), ) except MySQLClientError as e: - logger.error( - f"Failed to create application database {database_name} and scoped user {username}@{hostname}" - ) - raise MySQLCreateApplicationDatabaseAndScopedUserError(e.message) + logger.error(f"Failed to create application scoped user {username}@{hostname}") + raise MySQLCreateApplicationScopedUserError(e.message) @staticmethod def _get_statements_to_delete_users_with_attribute( @@ -1785,7 +1938,7 @@ def cluster_metadata_exists(self, from_instance: Optional[str] = None) -> bool: try: output = self._run_mysqlcli_script( (get_clusters_query,), - user=ROOT_USERNAME, + user=self.root_user, password=self.root_password, timeout=60, exception_as_warning=True, @@ -2581,29 +2734,6 @@ def get_mysql_version(self) -> Optional[str]: return matches.group(1) - def grant_privileges_to_user( - self, username, hostname, privileges, with_grant_option=False - ) -> None: - """Grants specified privileges to the provided database user.""" - grant_privileges_commands = ( - "shell.connect_to_primary()", - ( - f"session.run_sql(\"GRANT {', '.join(privileges)} ON *.* TO '{username}'@'{hostname}'" - f'{" WITH GRANT OPTION" if with_grant_option else ""}")' - ), - ) - - try: - self._run_mysqlsh_script( - "\n".join(grant_privileges_commands), - user=self.server_config_user, - password=self.server_config_password, - host=self.instance_def(self.server_config_user), - ) - except MySQLClientError as e: - logger.warning(f"Failed to grant privileges to user {username}@{hostname}") - raise MySQLGrantPrivilegesToUserError(e.message) - def update_user_password(self, username: str, new_password: str, host: str = "%") -> None: """Updates user password in MySQL database.""" # password is set on the global primary @@ -3572,7 +3702,7 @@ def _run_mysqlsh_script( def _run_mysqlcli_script( self, script: Union[Tuple[Any, ...], List[Any]], - user: str = "root", + user: str = ROOT_USERNAME, password: Optional[str] = None, timeout: Optional[int] = None, exception_as_warning: bool = False, diff --git a/src/charm.py b/src/charm.py index b9ae419c2..49b445823 100755 --- a/src/charm.py +++ b/src/charm.py @@ -34,8 +34,10 @@ MySQLAddInstanceToClusterError, MySQLCharmBase, MySQLConfigureInstanceError, + MySQLConfigureMySQLRolesError, MySQLConfigureMySQLUsersError, MySQLCreateClusterError, + MySQLCreateClusterSetError, MySQLGetClusterPrimaryAddressError, MySQLGetMySQLVersionError, MySQLInitializeJujuOperationsTableError, @@ -372,6 +374,25 @@ def is_unit_busy(self) -> bool: """Returns whether the unit is busy.""" return self._is_cluster_blocked() + def _create_cluster(self) -> None: + juju_version = ops.JujuVersion.from_environ() + + try: + # Create the cluster when is the leader unit + logger.info(f"Creating cluster {self.app_peer_data['cluster-name']}") + self.create_cluster() + self.unit.set_ports(3306, 33060) if juju_version.supports_open_port_on_k8s else None + self.unit.status = ops.ActiveStatus(self.active_status_message) + except ( + MySQLCreateClusterError, + MySQLCreateClusterSetError, + MySQLInitializeJujuOperationsTableError, + MySQLNoMemberStateError, + MySQLUnableToGetMemberStateError, + ): + logger.exception("Failed to initialize primary") + raise + def _get_primary_from_online_peer(self) -> Optional[str]: """Get the primary address from an online peer.""" for unit in self.peers.units: @@ -677,17 +698,6 @@ def _on_leader_elected(self, _) -> None: "cluster-set-domain-name", self.config.cluster_set_name or f"cluster-set-{common_hash}" ) - def _open_ports(self) -> None: - """Open ports if supported. - - Used if `juju expose` ran on application - """ - if ops.JujuVersion.from_environ().supports_open_port_on_k8s: - try: - self.unit.set_ports(3306, 33060) - except ops.ModelError: - logger.exception("failed to open port") - def _write_mysqld_configuration(self) -> dict: """Write the mysqld configuration to the file.""" memory_limit_bytes = (self.config.profile_limit_memory or 0) * BYTES_1MB @@ -725,7 +735,9 @@ def _configure_instance(self, container) -> None: logger.info("Configuring initialized mysqld") # Configure all base users and revoke privileges from the root users - self._mysql.configure_mysql_users() + self._mysql.configure_mysql_router_roles() + self._mysql.configure_mysql_system_roles() + self._mysql.configure_mysql_system_users() if self.config.plugin_audit_enabled: # Enable the audit plugin @@ -737,6 +749,7 @@ def _configure_instance(self, container) -> None: except ( MySQLInitialiseMySQLDError, MySQLServiceNotRunningError, + MySQLConfigureMySQLRolesError, MySQLConfigureMySQLUsersError, MySQLConfigureInstanceError, ChangeError, @@ -758,8 +771,6 @@ def _configure_instance(self, container) -> None: else: container.start(MYSQLD_EXPORTER_SERVICE) - self._open_ports() - try: # Set workload version if workload_version := self._mysql.get_mysql_version(): @@ -840,23 +851,7 @@ def _on_mysql_pebble_ready(self, event) -> None: self.join_unit_to_cluster() return - try: - # Create the cluster when is the leader unit - logger.info(f"Creating cluster {self.app_peer_data['cluster-name']}") - self.unit.status = MaintenanceStatus("Creating cluster") - self.create_cluster() - self.unit.status = ops.ActiveStatus(self.active_status_message) - - except ( - MySQLCreateClusterError, - MySQLUnableToGetMemberStateError, - MySQLNoMemberStateError, - MySQLInitializeJujuOperationsTableError, - MySQLCreateClusterError, - ): - logger.exception("Failed to initialize primary") - raise - + self._create_cluster() self._mysql.reconcile_binlogs_collection(force_restart=True) def _handle_potential_cluster_crash_scenario(self) -> bool: # noqa: C901 diff --git a/src/mysql_k8s_helpers.py b/src/mysql_k8s_helpers.py index 9cd1978bd..5dadb6549 100644 --- a/src/mysql_k8s_helpers.py +++ b/src/mysql_k8s_helpers.py @@ -450,7 +450,7 @@ def _wait_until_unit_removed_from_cluster(self, unit_address: str) -> None: if unit_address in members_in_cluster: raise MySQLWaitUntilUnitRemovedFromClusterError("Remove member still in cluster") - def create_database(self, database_name: str) -> None: + def create_database_legacy(self, database_name: str) -> None: """Creates a database. Args: @@ -475,7 +475,9 @@ def create_database(self, database_name: str) -> None: logger.exception(f"Failed to create database {database_name}", exc_info=e) raise MySQLCreateDatabaseError(e.message) - def create_user(self, username: str, password: str, label: str, hostname: str = "%") -> None: + def create_user_legacy( + self, username: str, password: str, label: str, hostname: str = "%" + ) -> None: """Creates a new user. Args: diff --git a/src/relations/mysql.py b/src/relations/mysql.py index b920b217d..09e03f4c6 100644 --- a/src/relations/mysql.py +++ b/src/relations/mysql.py @@ -9,7 +9,8 @@ from charms.mysql.v0.mysql import ( MySQLCheckUserExistenceError, - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLDeleteUsersForUnitError, ) from ops.charm import RelationBrokenEvent, RelationChangedEvent, RelationCreatedEvent @@ -245,14 +246,18 @@ def _on_mysql_relation_created(self, event: RelationCreatedEvent) -> None: # no try: logger.info("Creating application database and scoped user") - self.charm._mysql.create_application_database_and_scoped_user( + self.charm._mysql.create_database(database) + self.charm._mysql.create_scoped_user( database, username, password, "%", unit_name="mysql-legacy-relation", ) - except MySQLCreateApplicationDatabaseAndScopedUserError: + except ( + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, + ): self.charm.unit.status = BlockedStatus( "Failed to create application database and scoped user" ) diff --git a/src/relations/mysql_provider.py b/src/relations/mysql_provider.py index ed0fdcdbe..ba6ed385a 100644 --- a/src/relations/mysql_provider.py +++ b/src/relations/mysql_provider.py @@ -9,11 +9,13 @@ from charms.data_platform_libs.v0.data_interfaces import DatabaseProvides, DatabaseRequestedEvent from charms.mysql.v0.mysql import ( - MySQLCreateApplicationDatabaseAndScopedUserError, + LEGACY_ROLE_ROUTER, + MODERN_ROLE_ROUTER, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLDeleteUserError, MySQLDeleteUsersForRelationError, MySQLGetMySQLVersionError, - MySQLGrantPrivilegesToUserError, MySQLRemoveRouterFromMetadataError, ) from ops.charm import PebbleReadyEvent, RelationBrokenEvent, RelationDepartedEvent @@ -107,16 +109,17 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: # get base relation data relation_id = event.relation.id + app_name = event.app.name db_name = event.database + extra_user_roles = [] if event.extra_user_roles: extra_user_roles = event.extra_user_roles.split(",") + # user name is derived from the relation id db_user = self._get_username(relation_id) db_pass = self._get_or_set_password(event.relation) - remote_app = event.app.name - try: # make sure pods are labeled before adding service self.charm._mysql.update_endpoints(DB_RELATION_NAME) @@ -132,25 +135,19 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: # wait for endpoints to be ready self.charm.k8s_helpers.wait_service_ready((primary_endpoint, 3306)) - if "mysqlrouter" in extra_user_roles: - self.charm._mysql.create_application_database_and_scoped_user( - db_name, - db_user, - db_pass, - "%", - # MySQL Router charm does not need a new database - create_database=False, - ) - self.charm._mysql.grant_privileges_to_user( - db_user, "%", ["ALL PRIVILEGES"], with_grant_option=True - ) - else: - # TODO: - # add setup of tls, tls_ca and status - # add extra roles parsing from relation data - self.charm._mysql.create_application_database_and_scoped_user( - db_name, db_user, db_pass, "%" - ) + if not any([ + LEGACY_ROLE_ROUTER in extra_user_roles, + MODERN_ROLE_ROUTER in extra_user_roles, + ]): + self.charm._mysql.create_database(db_name) + + self.charm._mysql.create_scoped_user( + db_name, + db_user, + db_pass, + "%", + extra_roles=extra_user_roles, + ) # Set relation data self.database.set_endpoints(relation_id, f"{primary_endpoint}:3306") @@ -159,11 +156,12 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: self.database.set_version(relation_id, db_version) self.database.set_database(relation_id, db_name) - logger.info(f"Created user for app {remote_app}") + logger.info(f"Created user for app {app_name}") + self.charm.unit.status = ActiveStatus() except ( - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLGetMySQLVersionError, - MySQLGrantPrivilegesToUserError, ) as e: logger.exception("Failed to set up database relation", exc_info=e) self.charm.unit.status = BlockedStatus("Failed to create scoped user") diff --git a/src/relations/mysql_root.py b/src/relations/mysql_root.py index e2eae8a5a..7422adaa5 100644 --- a/src/relations/mysql_root.py +++ b/src/relations/mysql_root.py @@ -201,12 +201,12 @@ def _on_mysql_root_relation_created(self, event: RelationCreatedEvent) -> None: try: root_password = self.charm.get_secret("app", ROOT_PASSWORD_KEY) assert root_password, "Root password not set" - self.charm._mysql.create_database(database) - self.charm._mysql.create_user(username, password, "mysql-root-legacy-relation") + self.charm._mysql.create_database_legacy(database) + self.charm._mysql.create_user_legacy(username, password, "mysql-root-legacy-relation") if not self.charm._mysql.does_mysql_user_exist("root", "%"): # create `root@%` user if it doesn't exist # this is needed for the `mysql-root` interface to work - self.charm._mysql.create_user( + self.charm._mysql.create_user_legacy( "root", root_password, "mysql-root-legacy-relation", diff --git a/tests/integration/roles/__init__.py b/tests/integration/roles/__init__.py new file mode 100644 index 000000000..dddb292a2 --- /dev/null +++ b/tests/integration/roles/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/tests/integration/roles/test_database_dba_role.py b/tests/integration/roles/test_database_dba_role.py new file mode 100644 index 000000000..28a2d2bc3 --- /dev/null +++ b/tests/integration/roles/test_database_dba_role.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +import asyncio +import logging +from pathlib import Path + +import pytest +import yaml +from mysql.connector.errors import ProgrammingError +from pytest_operator.plugin import OpsTest + +from .. import juju_ +from ..helpers import ( + execute_queries_on_unit, + get_primary_unit, + get_unit_address, +) + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) + +DATABASE_APP_NAME = METADATA["name"] +INTEGRATOR_APP_NAME = "data-integrator" + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest, charm) -> None: + """Simple test to ensure that the mysql and data-integrator charms get deployed.""" + resources = {"mysql-image": METADATA["resources"]["mysql-image"]["upstream-source"]} + + async with ops_test.fast_forward("10s"): + await asyncio.gather( + ops_test.model.deploy( + charm, + application_name=DATABASE_APP_NAME, + num_units=3, + resources=resources, + base="ubuntu@22.04", + config={"profile": "testing"}, + ), + ops_test.model.deploy( + INTEGRATOR_APP_NAME, + application_name=f"{INTEGRATOR_APP_NAME}1", + base="ubuntu@24.04", + ), + ops_test.model.deploy( + INTEGRATOR_APP_NAME, + application_name=f"{INTEGRATOR_APP_NAME}2", + base="ubuntu@24.04", + ), + ) + + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + status="active", + ) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}1", f"{INTEGRATOR_APP_NAME}2"], + status="blocked", + ) + + +@pytest.mark.abort_on_fail +async def test_charmed_dba_role(ops_test: OpsTest): + """Test the database-level DBA role.""" + await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].set_config({ + "database-name": "preserved", + "extra-user-roles": "", + }) + await ops_test.model.add_relation(f"{INTEGRATOR_APP_NAME}1", DATABASE_APP_NAME) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}1", DATABASE_APP_NAME], + status="active", + ) + + await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}2"].set_config({ + "database-name": "throwaway", + "extra-user-roles": "charmed_dba_preserved", + }) + await ops_test.model.add_relation(f"{INTEGRATOR_APP_NAME}2", DATABASE_APP_NAME) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}2", DATABASE_APP_NAME], + status="active", + ) + + mysql_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0] + primary_unit = await get_primary_unit(ops_test, mysql_unit, DATABASE_APP_NAME) + primary_unit_address = await get_unit_address(ops_test, primary_unit.name) + + data_integrator_2_unit = ops_test.model.applications[f"{INTEGRATOR_APP_NAME}2"].units[0] + results = await juju_.run_action(data_integrator_2_unit, "get-credentials") + + logger.info("Checking that the database-level DBA role cannot create new databases") + with pytest.raises(ProgrammingError): + execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + ["CREATE DATABASE IF NOT EXISTS test"], + commit=True, + ) + + logger.info("Checking that the database-level DBA role can see all databases") + execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + ["SHOW DATABASES"], + commit=True, + ) + + logger.info("Checking that the database-level DBA role can create a new table") + execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "CREATE TABLE preserved.test_table (`id` SERIAL PRIMARY KEY, `data` TEXT)", + ], + commit=True, + ) + + logger.info("Checking that the database-level DBA role can write into an existing table") + execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "INSERT INTO preserved.test_table (`data`) VALUES ('test_data_1'), ('test_data_2')", + ], + commit=True, + ) + + logger.info("Checking that the database-level DBA role can read from an existing table") + rows = execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "SELECT `data` FROM preserved.test_table", + ], + commit=True, + ) + assert sorted(rows) == sorted([ + "test_data_1", + "test_data_2", + ]), "Unexpected data in preserved with charmed_dba_preserved role" diff --git a/tests/integration/roles/test_instance_dba_role.py b/tests/integration/roles/test_instance_dba_role.py new file mode 100644 index 000000000..612c22442 --- /dev/null +++ b/tests/integration/roles/test_instance_dba_role.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +import asyncio +from pathlib import Path + +import pytest +import yaml +from pytest_operator.plugin import OpsTest + +from .. import juju_ +from ..helpers import ( + execute_queries_on_unit, + get_primary_unit, + get_server_config_credentials, + get_unit_address, +) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) + +DATABASE_APP_NAME = METADATA["name"] +INTEGRATOR_APP_NAME = "data-integrator" + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest, charm) -> None: + """Simple test to ensure that the mysql and data-integrator charms get deployed.""" + resources = {"mysql-image": METADATA["resources"]["mysql-image"]["upstream-source"]} + + async with ops_test.fast_forward("10s"): + await asyncio.gather( + ops_test.model.deploy( + charm, + application_name=DATABASE_APP_NAME, + num_units=3, + resources=resources, + base="ubuntu@22.04", + config={"profile": "testing"}, + ), + ops_test.model.deploy( + INTEGRATOR_APP_NAME, + base="ubuntu@24.04", + ), + ) + + await ops_test.model.wait_for_idle(apps=[DATABASE_APP_NAME], status="active") + await ops_test.model.wait_for_idle(apps=[INTEGRATOR_APP_NAME], status="blocked") + + +@pytest.mark.abort_on_fail +async def test_charmed_dba_role(ops_test: OpsTest): + """Test the DBA predefined role.""" + await ops_test.model.applications[INTEGRATOR_APP_NAME].set_config({ + "database-name": "charmed_dba_database", + "extra-user-roles": "charmed_dba", + }) + await ops_test.model.add_relation(INTEGRATOR_APP_NAME, DATABASE_APP_NAME) + await ops_test.model.wait_for_idle( + apps=[INTEGRATOR_APP_NAME, DATABASE_APP_NAME], status="active" + ) + + mysql_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0] + primary_unit = await get_primary_unit(ops_test, mysql_unit, DATABASE_APP_NAME) + primary_unit_address = await get_unit_address(ops_test, primary_unit.name) + server_config_credentials = await get_server_config_credentials(primary_unit) + + execute_queries_on_unit( + primary_unit_address, + server_config_credentials["username"], + server_config_credentials["password"], + ["CREATE DATABASE IF NOT EXISTS test"], + commit=True, + ) + + data_integrator_unit = ops_test.model.applications[INTEGRATOR_APP_NAME].units[0] + results = await juju_.run_action(data_integrator_unit, "get-credentials") + + rows = execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + ["SHOW DATABASES"], + commit=True, + ) + + assert "test" in rows, "Database is not visible to DBA user" diff --git a/tests/integration/roles/test_instance_roles.py b/tests/integration/roles/test_instance_roles.py new file mode 100644 index 000000000..f4b7f272d --- /dev/null +++ b/tests/integration/roles/test_instance_roles.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +import asyncio +import logging +from pathlib import Path + +import pytest +import yaml +from mysql.connector.errors import ProgrammingError +from pytest_operator.plugin import OpsTest + +from .. import juju_ +from ..helpers import ( + execute_queries_on_unit, + get_primary_unit, + get_server_config_credentials, + get_unit_address, +) + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) + +DATABASE_APP_NAME = METADATA["name"] +INTEGRATOR_APP_NAME = "data-integrator" + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest, charm) -> None: + """Simple test to ensure that the mysql and data-integrator charms get deployed.""" + resources = {"mysql-image": METADATA["resources"]["mysql-image"]["upstream-source"]} + + async with ops_test.fast_forward("10s"): + await asyncio.gather( + ops_test.model.deploy( + charm, + application_name=DATABASE_APP_NAME, + num_units=3, + resources=resources, + base="ubuntu@22.04", + config={"profile": "testing"}, + ), + ops_test.model.deploy( + INTEGRATOR_APP_NAME, + application_name=f"{INTEGRATOR_APP_NAME}1", + base="ubuntu@24.04", + ), + ops_test.model.deploy( + INTEGRATOR_APP_NAME, + application_name=f"{INTEGRATOR_APP_NAME}2", + base="ubuntu@24.04", + ), + ) + + await ops_test.model.wait_for_idle( + apps=[DATABASE_APP_NAME], + status="active", + ) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}1", f"{INTEGRATOR_APP_NAME}2"], + status="blocked", + ) + + +@pytest.mark.abort_on_fail +async def test_charmed_read_role(ops_test: OpsTest): + """Test the charmed_read predefined role.""" + await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].set_config({ + "database-name": "charmed_read_database", + "extra-user-roles": "charmed_read", + }) + await ops_test.model.add_relation(f"{INTEGRATOR_APP_NAME}1", DATABASE_APP_NAME) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}1", DATABASE_APP_NAME], + status="active", + ) + + mysql_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0] + primary_unit = await get_primary_unit(ops_test, mysql_unit, DATABASE_APP_NAME) + primary_unit_address = await get_unit_address(ops_test, primary_unit.name) + server_config_credentials = await get_server_config_credentials(primary_unit) + + execute_queries_on_unit( + primary_unit_address, + server_config_credentials["username"], + server_config_credentials["password"], + [ + "CREATE TABLE charmed_read_database.test_table (`id` SERIAL PRIMARY KEY, `data` TEXT)", + "INSERT INTO charmed_read_database.test_table (`data`) VALUES ('test_data_1'), ('test_data_2')", + ], + commit=True, + ) + + data_integrator_unit = ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].units[0] + results = await juju_.run_action(data_integrator_unit, "get-credentials") + + logger.info("Checking that the charmed_read role can read from an existing table") + rows = execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "SELECT `data` FROM charmed_read_database.test_table", + ], + commit=True, + ) + assert sorted(rows) == sorted([ + "test_data_1", + "test_data_2", + ]), "Unexpected data in charmed_read_database with charmed_read role" + + logger.info("Checking that the charmed_read role cannot write into an existing table") + with pytest.raises(ProgrammingError): + execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "INSERT INTO charmed_read_database.test_table (`data`) VALUES ('test_data_3')", + ], + commit=True, + ) + + logger.info("Checking that the charmed_read role cannot create a new table") + with pytest.raises(ProgrammingError): + execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "CREATE TABLE charmed_read_database.new_table (`id` SERIAL PRIMARY KEY, `data` TEXT)", + ], + commit=True, + ) + + await ops_test.model.applications[DATABASE_APP_NAME].remove_relation( + f"{DATABASE_APP_NAME}:database", + f"{INTEGRATOR_APP_NAME}1:mysql", + ) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}1"], + status="blocked", + ) + + +@pytest.mark.abort_on_fail +async def test_charmed_dml_role(ops_test: OpsTest): + """Test the charmed_dml role.""" + await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].set_config({ + "database-name": "charmed_dml_database", + "extra-user-roles": "", + }) + await ops_test.model.add_relation(f"{INTEGRATOR_APP_NAME}1", DATABASE_APP_NAME) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}1", DATABASE_APP_NAME], + status="active", + ) + + await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}2"].set_config({ + "database-name": "throwaway", + "extra-user-roles": "charmed_dml", + }) + await ops_test.model.add_relation(f"{INTEGRATOR_APP_NAME}2", DATABASE_APP_NAME) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}2", DATABASE_APP_NAME], + status="active", + ) + + mysql_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0] + primary_unit = await get_primary_unit(ops_test, mysql_unit, DATABASE_APP_NAME) + primary_unit_address = await get_unit_address(ops_test, primary_unit.name) + + data_integrator_1_unit = ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].units[0] + results = await juju_.run_action(data_integrator_1_unit, "get-credentials") + + logger.info("Checking that when no role is specified the created user can do everything") + rows = execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "CREATE TABLE charmed_dml_database.test_table (`id` SERIAL PRIMARY KEY, `data` TEXT)", + "INSERT INTO charmed_dml_database.test_table (`data`) VALUES ('test_data_1'), ('test_data_2')", + "SELECT `data` FROM charmed_dml_database.test_table", + ], + commit=True, + ) + assert sorted(rows) == sorted([ + "test_data_1", + "test_data_2", + ]), "Unexpected data in charmed_dml_database with charmed_dml role" + + data_integrator_2_unit = ops_test.model.applications[f"{INTEGRATOR_APP_NAME}2"].units[0] + results = await juju_.run_action(data_integrator_2_unit, "get-credentials") + + logger.info("Checking that the charmed_dml role can read from an existing table") + rows = execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "SELECT `data` FROM charmed_dml_database.test_table", + ], + commit=True, + ) + assert sorted(rows) == sorted([ + "test_data_1", + "test_data_2", + ]), "Unexpected data in charmed_dml_database with charmed_dml role" + + logger.info("Checking that the charmed_dml role can write into an existing table") + execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "INSERT INTO charmed_dml_database.test_table (`data`) VALUES ('test_data_3')", + ], + commit=True, + ) + + logger.info("Checking that the charmed_dml role cannot create a new table") + with pytest.raises(ProgrammingError): + execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "CREATE TABLE charmed_dml_database.new_table (`id` SERIAL PRIMARY KEY, `data` TEXT)", + ], + commit=True, + ) + + await ops_test.model.applications[DATABASE_APP_NAME].remove_relation( + f"{DATABASE_APP_NAME}:database", + f"{INTEGRATOR_APP_NAME}1:mysql", + ) + await ops_test.model.applications[DATABASE_APP_NAME].remove_relation( + f"{DATABASE_APP_NAME}:database", + f"{INTEGRATOR_APP_NAME}2:mysql", + ) + await ops_test.model.wait_for_idle( + apps=[f"{INTEGRATOR_APP_NAME}1", f"{INTEGRATOR_APP_NAME}2"], + status="blocked", + ) diff --git a/tests/spread/test_database_dba_role.py/task.yaml b/tests/spread/test_database_dba_role.py/task.yaml new file mode 100644 index 000000000..fc765822f --- /dev/null +++ b/tests/spread/test_database_dba_role.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_database_dba_role.py +environment: + TEST_MODULE: roles/test_database_dba_role.py +execute: | + tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results" +artifacts: + - allure-results diff --git a/tests/spread/test_instance_dba_role.py/task.yaml b/tests/spread/test_instance_dba_role.py/task.yaml new file mode 100644 index 000000000..13b1c77c1 --- /dev/null +++ b/tests/spread/test_instance_dba_role.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_instance_dba_role.py +environment: + TEST_MODULE: roles/test_instance_dba_role.py +execute: | + tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results" +artifacts: + - allure-results diff --git a/tests/spread/test_instance_roles.py/task.yaml b/tests/spread/test_instance_roles.py/task.yaml new file mode 100644 index 000000000..3c2487be2 --- /dev/null +++ b/tests/spread/test_instance_roles.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_instance_roles.py +environment: + TEST_MODULE: roles/test_instance_roles.py +execute: | + tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results" +artifacts: + - allure-results diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 2de0308fa..a9a40f2fe 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -147,7 +147,9 @@ def test_on_leader_elected_secrets(self): @patch("mysql_k8s_helpers.MySQL.initialize_juju_units_operations_table") @patch("mysql_k8s_helpers.MySQL.get_mysql_version", return_value="8.0.0") @patch("mysql_k8s_helpers.MySQL.wait_until_mysql_connection") - @patch("mysql_k8s_helpers.MySQL.configure_mysql_users") + @patch("mysql_k8s_helpers.MySQL.configure_mysql_router_roles") + @patch("mysql_k8s_helpers.MySQL.configure_mysql_system_roles") + @patch("mysql_k8s_helpers.MySQL.configure_mysql_system_users") @patch("mysql_k8s_helpers.MySQL.configure_instance") @patch("mysql_k8s_helpers.MySQL.create_cluster") @patch("mysql_k8s_helpers.MySQL.initialise_mysqld") @@ -173,7 +175,9 @@ def test_mysql_pebble_ready( _fix_data_dir, _create_cluster, _configure_instance, - _configure_mysql_users, + _configure_mysql_router_roles, + _configure_mysql_system_roles, + _configure_mysql_system_users, _wait_until_mysql_connection, _get_mysql_version, _initialize_juju_units_operations_table, diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index 27ee26be7..fe440d4eb 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -62,14 +62,16 @@ def tearDown(self) -> None: @patch("mysql_k8s_helpers.MySQL.update_endpoints") @patch("k8s_helpers.KubernetesHelpers.create_endpoint_services") @patch("mysql_k8s_helpers.MySQL.get_mysql_version", return_value="8.0.29-0ubuntu0.20.04.3") - @patch("mysql_k8s_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_k8s_helpers.MySQL.create_database") + @patch("mysql_k8s_helpers.MySQL.create_scoped_user") @patch( "relations.mysql_provider.generate_random_password", return_value="super_secure_password" ) def test_database_requested( self, _generate_random_password, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _get_mysql_version, _create_endpoint_services, _update_endpoints, @@ -116,7 +118,8 @@ def test_database_requested( ) _generate_random_password.assert_called_once() - _create_application_database_and_scoped_user.assert_called_once() + _create_database.assert_called_once() + _create_scoped_user.assert_called_once() _get_mysql_version.assert_called_once() _create_endpoint_services.assert_called_once() _update_endpoints.assert_called() diff --git a/tests/unit/test_mysql_k8s_helpers.py b/tests/unit/test_mysql_k8s_helpers.py index 40fbb0206..84ee18f13 100644 --- a/tests/unit/test_mysql_k8s_helpers.py +++ b/tests/unit/test_mysql_k8s_helpers.py @@ -104,14 +104,14 @@ def test_wait_until_mysql_connection(self, _container): self.assertTrue(not self.mysql.wait_until_mysql_connection(check_port=False)) @patch("mysql_k8s_helpers.MySQL._run_mysqlsh_script") - def test_create_database(self, _run_mysqlsh_script): - """Test successful execution of create_database.""" + def test_create_database_legacy(self, _run_mysqlsh_script): + """Test successful execution of create_database_legacy.""" _expected_create_database_commands = ( "shell.connect_to_primary()", 'session.run_sql("CREATE DATABASE IF NOT EXISTS `test_database`;")', ) - self.mysql.create_database("test_database") + self.mysql.create_database_legacy("test_database") _run_mysqlsh_script.assert_called_once_with( "\n".join(_expected_create_database_commands), @@ -121,23 +121,23 @@ def test_create_database(self, _run_mysqlsh_script): ) @patch("mysql_k8s_helpers.MySQL._run_mysqlsh_script") - def test_create_database_exception(self, _run_mysqlsh_script): - """Test exception while executing create_database.""" + def test_create_database_legacy_exception(self, _run_mysqlsh_script): + """Test exception while executing create_database_legacy.""" _run_mysqlsh_script.side_effect = MySQLClientError("Error creating database") with self.assertRaises(MySQLCreateDatabaseError): - self.mysql.create_database("test_database") + self.mysql.create_database_legacy("test_database") @patch("mysql_k8s_helpers.MySQL._run_mysqlsh_script") - def test_create_user(self, _run_mysqlsh_script): - """Test successful execution of create_user.""" + def test_create_user_legacy(self, _run_mysqlsh_script): + """Test successful execution of create_user_legacy.""" _escaped_attributes = json.dumps({"label": "test_label"}).replace('"', r"\"") _expected_create_user_commands = ( "shell.connect_to_primary()", f"session.run_sql(\"CREATE USER `test_user`@`%` IDENTIFIED BY 'test_password' ATTRIBUTE '{_escaped_attributes}';\")", ) - self.mysql.create_user("test_user", "test_password", "test_label") + self.mysql.create_user_legacy("test_user", "test_password", "test_label") _run_mysqlsh_script.assert_called_once_with( "\n".join(_expected_create_user_commands), @@ -147,12 +147,12 @@ def test_create_user(self, _run_mysqlsh_script): ) @patch("mysql_k8s_helpers.MySQL._run_mysqlsh_script") - def test_create_user_exception(self, _run_mysqlsh_script): - """Test exception while executing create_user.""" + def test_create_user_legacy_exception(self, _run_mysqlsh_script): + """Test exception while executing create_user_legacy.""" _run_mysqlsh_script.side_effect = MySQLClientError("Error creating user") with self.assertRaises(MySQLCreateUserError): - self.mysql.create_user("test_user", "test_password", "test_label") + self.mysql.create_user_legacy("test_user", "test_password", "test_label") @patch("mysql_k8s_helpers.MySQL._run_mysqlsh_script") def test_escalate_user_privileges(self, _run_mysqlsh_script):