diff --git a/docs/reference/software-testing.md b/docs/reference/software-testing.md index fb897fcee9..ad8310ce8d 100644 --- a/docs/reference/software-testing.md +++ b/docs/reference/software-testing.md @@ -36,13 +36,13 @@ juju run mysql-test-app/leader get-inserted-data # Start "continuous write" test: juju run mysql-test-app/leader start-continuous-writes export password=$(juju run mysql/leader get-password username=root | yq '.. | select(. | has("password")).password') -watch -n1 -x juju ssh mysql/leader "mysql -h 127.0.0.1 -uroot -p${password} -e \"select count(*) from continuous_writes_database.data\"" +watch -n1 -x juju ssh mysql/leader "mysql -h 127.0.0.1 -uroot -p${password} -e \"select count(*) from continuous_writes.data\"" # Watch the counter is growing! ``` Expected results: -* mysql-test-app continuously inserts records in database `continuous_writes_database` table `data`. +* mysql-test-app continuously inserts records in database `continuous_writes` table `data`. * the counters (amount of records in table) are growing on all cluster members Hints: diff --git a/lib/charms/mysql/v0/mysql.py b/lib/charms/mysql/v0/mysql.py index e7407d21f0..d80d0b23dc 100644 --- a/lib/charms/mysql/v0/mysql.py +++ b/lib/charms/mysql/v0/mysql.py @@ -127,7 +127,7 @@ def wait_until_mysql_connection(self) -> None: # Increment this major API version when introducing breaking changes LIBAPI = 0 -LIBPATCH = 92 +LIBPATCH = 93 UNIT_TEARDOWN_LOCKNAME = "unit-teardown" UNIT_ADD_LOCKNAME = "unit-add" @@ -146,6 +146,24 @@ def wait_until_mysql_connection(self) -> None: SECRET_INTERNAL_LABEL = "secret-id" # noqa: S105 SECRET_DELETED_LABEL = "None" # noqa: S105 +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"] @@ -173,10 +191,18 @@ def name(self): return f"<{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.""" @@ -185,8 +211,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): @@ -270,10 +300,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.""" @@ -1032,6 +1058,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 @@ -1133,8 +1160,8 @@ def render_mysqld_configuration( # noqa: C901 # the admin enables them manually config["mysqld"] = { # All interfaces bind expected - "bind-address": "0.0.0.0", # noqa: S104 - "mysqlx-bind-address": "0.0.0.0", # noqa: S104 + "bind_address": "0.0.0.0", # noqa: S104 + "mysqlx_bind_address": "0.0.0.0", # noqa: S104 "admin_address": self.instance_address, "report_host": self.instance_address, "max_connections": str(max_connections), @@ -1150,6 +1177,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: @@ -1174,52 +1202,139 @@ 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", - ) - - # 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 = ( + 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 as e: + logger.error(f"Failed to configure Router role for {self.instance_address}") + raise MySQLConfigureMySQLRolesError from e + + 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}", + ], + } + + 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 as e: + logger.error(f"Failed to configure roles for {self.instance_address}") + raise MySQLConfigureMySQLRolesError from e + + 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'", # noqa: S608 + 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 as e: logger.error(f"Failed to configure users for: {self.instance_address}") raise MySQLConfigureMySQLUsersError from e + 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}'", # noqa: S608 + ) + output = self._run_mysqlcli_script( + query_commands, + user=self.root_user, + password=self.root_password, + ) + return {row[0] for row in output} + except MySQLClientError as e: + logger.error("Failed to list roles") + raise MySQLListMySQLRolesError from e + def _plugin_file_exists(self, plugin_file_name: str) -> bool: """Check if the plugin file exists. @@ -1320,6 +1435,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 { @@ -1390,51 +1506,86 @@ def configure_mysqlrouter_user( logger.error(f"Failed to configure mysqlrouter {username=}") raise MySQLConfigureRouterUserError from e - 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(role_name) >= ROLE_MAX_LENGTH: + logger.error(f"Failed to create application database {database}") + raise MySQLCreateApplicationDatabaseError("Role name longer than 32 characters") + + 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) from e + + def create_scoped_user( self, - database_name: str, + database: str, username: str, password: str, hostname: str, *, unit_name: str | None = None, - create_database: bool = True, + extra_roles: list[str] | None = 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) from e + logger.error(f"Failed to create application scoped user {username}@{hostname}") + raise MySQLCreateApplicationScopedUserError(e.message) from e @staticmethod def _get_statements_to_delete_users_with_attribute( @@ -1866,7 +2017,7 @@ def cluster_metadata_exists(self, from_instance: str | None = 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, @@ -2744,29 +2895,6 @@ def get_mysql_version(self) -> str | None: 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) from e - 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 @@ -3761,7 +3889,7 @@ def _run_mysqlsh_script( def _run_mysqlcli_script( self, script: tuple[Any, ...] | list[Any], - user: str = "root", + user: str = ROOT_USERNAME, password: str | None = None, timeout: int | None = None, exception_as_warning: bool = False, diff --git a/src/charm.py b/src/charm.py index 0a0536aa75..bc38069b14 100755 --- a/src/charm.py +++ b/src/charm.py @@ -32,6 +32,7 @@ MySQLAddInstanceToClusterError, MySQLCharmBase, MySQLConfigureInstanceError, + MySQLConfigureMySQLRolesError, MySQLConfigureMySQLUsersError, MySQLCreateClusterError, MySQLCreateClusterSetError, @@ -326,6 +327,9 @@ def _on_start(self, event: StartEvent) -> None: try: self.workload_initialise() + except MySQLConfigureMySQLRolesError: + self.unit.status = BlockedStatus("Failed to initialize MySQL roles") + return except MySQLConfigureMySQLUsersError: self.unit.status = BlockedStatus("Failed to initialize MySQL users") return @@ -350,19 +354,7 @@ def _on_start(self, event: StartEvent) -> None: self.unit_peer_data["member-state"] = "waiting" return - try: - # Create the cluster and cluster set from the leader unit - logger.info(f"Creating cluster {self.app_peer_data['cluster-name']}") - self.create_cluster() - self._open_ports() - self.unit.status = ActiveStatus(self.active_status_message) - except ( - MySQLCreateClusterError, - MySQLCreateClusterSetError, - MySQLInitializeJujuOperationsTableError, - ) as e: - logger.exception("Failed to create cluster") - raise e + self._create_cluster() def _on_peer_relation_changed(self, event: RelationChangedEvent) -> None: """Handle the peer relation changed event.""" @@ -780,7 +772,9 @@ def workload_initialise(self) -> None: self._mysql.write_mysqld_config() self.log_rotation_setup.setup() self._mysql.reset_root_password_and_start_mysqld() - 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: self._mysql.install_plugins(["audit_log"]) @@ -816,16 +810,6 @@ def update_endpoints(self) -> None: self.database_relation._update_endpoints_all_relations(None) self._on_update_status(None) - def _open_ports(self) -> None: - """Open ports. - - Used if `juju expose` ran on application - """ - try: - self.unit.set_ports(3306, 33060) - except ops.ModelError: - logger.exception("failed to open port") - def _can_start(self, event: StartEvent) -> bool: """Check if the unit can start. @@ -875,6 +859,22 @@ def _can_start(self, event: StartEvent) -> bool: return True + def _create_cluster(self) -> None: + """Creates the InnoDB cluster and sets up the ports.""" + try: + # Create the cluster and cluster set from the leader unit + logger.info(f"Creating cluster {self.app_peer_data['cluster-name']}") + self.create_cluster() + self.unit.set_ports(3306, 33060) + self.unit.status = ActiveStatus(self.active_status_message) + except ( + MySQLCreateClusterError, + MySQLCreateClusterSetError, + MySQLInitializeJujuOperationsTableError, + ) as e: + logger.exception("Failed to create cluster") + raise e + def _is_unit_waiting_to_join_cluster(self) -> bool: """Return if the unit is waiting to join the cluster.""" # alternatively, we could check if the instance is configured diff --git a/src/mysql_vm_helpers.py b/src/mysql_vm_helpers.py index bd0db8f192..7df9108495 100644 --- a/src/mysql_vm_helpers.py +++ b/src/mysql_vm_helpers.py @@ -54,6 +54,7 @@ MYSQLD_DEFAULTS_CONFIG_FILE, MYSQLD_SOCK_FILE, ROOT_SYSTEM_USER, + ROOT_USERNAME, XTRABACKUP_PLUGIN_DIR, ) @@ -819,7 +820,7 @@ def _run_mysqlsh_script( def _run_mysqlcli_script( self, script: tuple[Any, ...] | list[Any], - user: str = "root", + user: str = ROOT_USERNAME, password: str | None = None, timeout: int | None = None, exception_as_warning: bool = False, diff --git a/src/relations/db_router.py b/src/relations/db_router.py index a67f4c7ad5..863ea76a11 100644 --- a/src/relations/db_router.py +++ b/src/relations/db_router.py @@ -11,7 +11,8 @@ from charms.mysql.v0.mysql import ( MySQLCheckUserExistenceError, MySQLConfigureRouterUserError, - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLDeleteUsersForUnitError, MySQLGetClusterPrimaryAddressError, ) @@ -124,8 +125,8 @@ def _create_requested_users( Raises: MySQLCheckUserExistenceError if there is an issue checking a user's existence MySQLConfigureRouterUserError if there is an issue configuring the mysqlrouter user - MySQLCreateApplicationDatabaseAndScopedUserError if there is an issue creating a - user or said user scoped database + MySQLCreateApplicationDatabaseError if there is an issue creating the database + MySQLCreateApplicationScopedUserError if there is an issue creating the database user """ user_passwords = {} requested_user_applications = set() @@ -141,7 +142,8 @@ def _create_requested_users( requested_user.username, password, requested_user.hostname, user_unit_name ) else: - self.charm._mysql.create_application_database_and_scoped_user( + self.charm._mysql.create_database(requested_user.database) + self.charm._mysql.create_scoped_user( requested_user.database, requested_user.username, password, @@ -227,7 +229,8 @@ def _on_db_router_relation_changed(self, event: RelationChangedEvent) -> None: except ( MySQLCheckUserExistenceError, MySQLConfigureRouterUserError, - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, ): self.charm.unit.status = BlockedStatus("Failed to create app user or scoped database") return diff --git a/src/relations/mysql.py b/src/relations/mysql.py index ecbb4f3370..edaea163c3 100644 --- a/src/relations/mysql.py +++ b/src/relations/mysql.py @@ -10,7 +10,8 @@ from charms.mysql.v0.mysql import ( MySQLCheckUserExistenceError, - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLDeleteUsersForUnitError, MySQLGetClusterPrimaryAddressError, ) @@ -195,7 +196,8 @@ def _on_mysql_relation_created(self, event: RelationCreatedEvent) -> None: password = self._get_or_set_password_in_peer_secrets(username) try: - self.charm._mysql.create_application_database_and_scoped_user( + self.charm._mysql.create_database(database) + self.charm._mysql.create_scoped_user( database, username, password, @@ -204,9 +206,9 @@ def _on_mysql_relation_created(self, event: RelationCreatedEvent) -> None: ) primary_address = self.charm._mysql.get_cluster_primary_address() - except ( - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLGetClusterPrimaryAddressError, ): self.charm.unit.status = BlockedStatus("Failed to initialize `mysql` relation") diff --git a/src/relations/mysql_provider.py b/src/relations/mysql_provider.py index 1cad67df5f..fdc1b39594 100644 --- a/src/relations/mysql_provider.py +++ b/src/relations/mysql_provider.py @@ -8,19 +8,21 @@ from charms.data_platform_libs.v0.data_interfaces import DatabaseProvides, DatabaseRequestedEvent from charms.mysql.v0.mysql import ( + LEGACY_ROLE_ROUTER, + MODERN_ROLE_ROUTER, MySQLClientError, - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLDeleteUserError, MySQLDeleteUsersForRelationError, MySQLGetClusterEndpointsError, MySQLGetClusterMembersAddressesError, MySQLGetMySQLVersionError, - MySQLGrantPrivilegesToUserError, MySQLRemoveRouterFromMetadataError, ) from ops.charm import RelationBrokenEvent, RelationDepartedEvent, RelationJoinedEvent from ops.framework import Object -from ops.model import BlockedStatus +from ops.model import ActiveStatus, BlockedStatus from constants import DB_RELATION_NAME, PASSWORD_LENGTH, PEER from utils import generate_random_password @@ -220,16 +222,17 @@ def _on_database_requested(self, event: DatabaseRequestedEvent): # 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 - # Update endpoint addresses self.charm.update_endpoint_address(DB_RELATION_NAME) @@ -242,31 +245,26 @@ def _on_database_requested(self, event: DatabaseRequestedEvent): self.database.set_version(relation_id, db_version) self.database.set_read_only_endpoints(relation_id, ro_endpoints) - 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 - self.charm._mysql.create_application_database_and_scoped_user( - db_name, db_user, db_pass, "%" - ) - - logger.info(f"Created user for app {remote_app}") + 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, + ) + logger.info(f"Created user for app {app_name}") + self.charm.unit.status = ActiveStatus() except ( - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLGetMySQLVersionError, MySQLGetClusterMembersAddressesError, - MySQLGrantPrivilegesToUserError, MySQLClientError, ) as e: logger.exception("Failed to set up database relation", exc_info=e) diff --git a/src/relations/shared_db.py b/src/relations/shared_db.py index 094abfdf7d..7ac684489d 100644 --- a/src/relations/shared_db.py +++ b/src/relations/shared_db.py @@ -7,7 +7,8 @@ import typing from charms.mysql.v0.mysql import ( - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLGetClusterPrimaryAddressError, ) from ops.charm import LeaderElectedEvent, RelationChangedEvent, RelationDepartedEvent @@ -153,8 +154,13 @@ def _on_shared_db_relation_changed(self, event: RelationChangedEvent) -> None: remote_host = event.relation.data[event.unit].get("private-address") try: - self._charm._mysql.create_application_database_and_scoped_user( - database_name, database_user, password, remote_host, unit_name=joined_unit + self._charm._mysql.create_database(database_name) + self._charm._mysql.create_scoped_user( + database_name, + database_user, + password, + remote_host, + unit_name=joined_unit, ) # set the relation data for consumption @@ -179,7 +185,10 @@ def _on_shared_db_relation_changed(self, event: RelationChangedEvent) -> None: allowed_units_set ) - except MySQLCreateApplicationDatabaseAndScopedUserError: + except ( + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, + ): self._charm.unit.status = BlockedStatus("Failed to initialize shared_db relation") return diff --git a/tests/integration/high_availability/high_availability_helpers.py b/tests/integration/high_availability/high_availability_helpers.py index 1b67e2567c..2b2f4256ab 100644 --- a/tests/integration/high_availability/high_availability_helpers.py +++ b/tests/integration/high_availability/high_availability_helpers.py @@ -21,7 +21,7 @@ ) # Copied these values from high_availability.application_charm.src.charm -DATABASE_NAME = "continuous_writes_database" +DATABASE_NAME = "continuous_writes" TABLE_NAME = "data" CLUSTER_NAME = "test_cluster" diff --git a/tests/integration/relations/test_relation_mysql_legacy.py b/tests/integration/relations/test_relation_mysql_legacy.py index 59eebb73cb..2125afbb3a 100644 --- a/tests/integration/relations/test_relation_mysql_legacy.py +++ b/tests/integration/relations/test_relation_mysql_legacy.py @@ -29,7 +29,7 @@ ENDPOINT = "mysql" TEST_USER = "testuser" -TEST_DATABASE = "continuous_writes_database" +TEST_DATABASE = "continuous_writes" TIMEOUT = 15 * 60 diff --git a/tests/integration/roles/__init__.py b/tests/integration/roles/__init__.py new file mode 100644 index 0000000000..dddb292a2c --- /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 0000000000..0933a379dd --- /dev/null +++ b/tests/integration/roles/test_database_dba_role.py @@ -0,0 +1,146 @@ +#!/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, +) + +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.""" + async with ops_test.fast_forward("10s"): + await asyncio.gather( + ops_test.model.deploy( + charm, + application_name=DATABASE_APP_NAME, + num_units=3, + 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 primary_unit.get_public_address() + + 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): + await 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") + await 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") + await 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") + await 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 = await 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 0000000000..57fe86f184 --- /dev/null +++ b/tests/integration/roles/test_instance_dba_role.py @@ -0,0 +1,89 @@ +#!/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 pytest_operator.plugin import OpsTest + +from .. import juju_ +from ..helpers import ( + execute_queries_on_unit, + get_primary_unit, +) + +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.""" + async with ops_test.fast_forward("10s"): + await asyncio.gather( + ops_test.model.deploy( + charm, + application_name=DATABASE_APP_NAME, + num_units=3, + 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 instance-level DBA role.""" + await ops_test.model.applications[INTEGRATOR_APP_NAME].set_config({ + "database-name": "charmed_dba_db", + "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 primary_unit.get_public_address() + + data_integrator_unit = ops_test.model.applications[INTEGRATOR_APP_NAME].units[0] + results = await juju_.run_action(data_integrator_unit, "get-credentials") + + logger.info("Checking that the instance-level DBA role can create new databases") + await execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["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") + + logger.info("Checking that the instance-level DBA role can see all databases") + rows = await 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 0000000000..f259a821b6 --- /dev/null +++ b/tests/integration/roles/test_instance_roles.py @@ -0,0 +1,243 @@ +#!/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, +) + +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.""" + async with ops_test.fast_forward("10s"): + await asyncio.gather( + ops_test.model.deploy( + charm, + application_name=DATABASE_APP_NAME, + num_units=3, + 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 instance-level charmed_read role.""" + await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].set_config({ + "database-name": "charmed_read_db", + "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 primary_unit.get_public_address() + server_config_credentials = await get_server_config_credentials(primary_unit) + + await execute_queries_on_unit( + primary_unit_address, + server_config_credentials["username"], + server_config_credentials["password"], + [ + "CREATE TABLE charmed_read_db.test_table (`id` SERIAL PRIMARY KEY, `data` TEXT)", + "INSERT INTO charmed_read_db.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 = await execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "SELECT `data` FROM charmed_read_db.test_table", + ], + commit=True, + ) + assert sorted(rows) == sorted([ + "test_data_1", + "test_data_2", + ]), "Unexpected data in charmed_read_db with charmed_read role" + + logger.info("Checking that the charmed_read role cannot write into an existing table") + with pytest.raises(ProgrammingError): + await execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "INSERT INTO charmed_read_db.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): + await execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "CREATE TABLE charmed_read_db.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 instance-level charmed_dml role.""" + await ops_test.model.applications[f"{INTEGRATOR_APP_NAME}1"].set_config({ + "database-name": "charmed_dml_db", + "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 primary_unit.get_public_address() + + 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 = await execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "CREATE TABLE charmed_dml_db.test_table (`id` SERIAL PRIMARY KEY, `data` TEXT)", + "INSERT INTO charmed_dml_db.test_table (`data`) VALUES ('test_data_1'), ('test_data_2')", + "SELECT `data` FROM charmed_dml_db.test_table", + ], + commit=True, + ) + assert sorted(rows) == sorted([ + "test_data_1", + "test_data_2", + ]), "Unexpected data in charmed_dml_db 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 = await execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "SELECT `data` FROM charmed_dml_db.test_table", + ], + commit=True, + ) + assert sorted(rows) == sorted([ + "test_data_1", + "test_data_2", + ]), "Unexpected data in charmed_dml_db with charmed_dml role" + + logger.info("Checking that the charmed_dml role can write into an existing table") + await execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "INSERT INTO charmed_dml_db.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): + await execute_queries_on_unit( + primary_unit_address, + results["mysql"]["username"], + results["mysql"]["password"], + [ + "CREATE TABLE charmed_dml_db.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 0000000000..fc765822f7 --- /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 0000000000..13b1c77c12 --- /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 0000000000..3c2487be22 --- /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_database.py b/tests/unit/test_database.py index dfa1d7ae08..07947715c1 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -28,14 +28,16 @@ def setUp(self): return_value=("2.2.2.2:3306", "2.2.2.1:3306,2.2.2.3:3306", ""), ) @patch("mysql_vm_helpers.MySQL.get_mysql_version", return_value="8.0.29-0ubuntu0.20.04.3") - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_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, _get_cluster_endpoints, _cluster_initialized, @@ -82,6 +84,7 @@ 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_cluster_endpoints.assert_called_once() _get_mysql_version.assert_called_once() diff --git a/tests/unit/test_db_router.py b/tests/unit/test_db_router.py index ce57380dce..b18ff8b2ea 100644 --- a/tests/unit/test_db_router.py +++ b/tests/unit/test_db_router.py @@ -7,7 +7,8 @@ from charms.mysql.v0.mysql import ( MySQLCheckUserExistenceError, MySQLConfigureRouterUserError, - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, ) from ops.model import BlockedStatus from ops.testing import Harness @@ -33,10 +34,12 @@ def setUp(self): @patch("mysql_vm_helpers.MySQL.get_cluster_primary_address", return_value="2.2.2.2") @patch("mysql_vm_helpers.MySQL.does_mysql_user_exist", return_value=False) @patch("mysql_vm_helpers.MySQL.configure_mysqlrouter_user") - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_helpers.MySQL.create_scoped_user") def test_db_router_relation_changed( self, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _configure_mysqlrouter_user, _does_mysql_user_exist, _get_cluster_primary_address, @@ -86,7 +89,10 @@ def test_db_router_relation_changed( _configure_mysqlrouter_user.assert_called_once_with( "mysqlrouteruser", "super_secure_password", "1.1.1.3", "app/0" ) - _create_application_database_and_scoped_user.assert_called_once_with( + _create_database.assert_called_once_with( + "keystone_database", + ) + _create_scoped_user.assert_called_once_with( "keystone_database", "keystone_user", "super_secure_password", @@ -122,10 +128,12 @@ def test_db_router_relation_changed( @patch("relations.db_router.generate_random_password", return_value="super_secure_password") @patch("mysql_vm_helpers.MySQL.does_mysql_user_exist", return_value=False) @patch("mysql_vm_helpers.MySQL.configure_mysqlrouter_user") - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_helpers.MySQL.create_scoped_user") def test_db_router_relation_changed_exceptions( self, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _configure_mysqlrouter_user, _does_mysql_user_exist, _generate_random_password, @@ -184,10 +192,27 @@ def test_db_router_relation_changed_exceptions( _configure_mysqlrouter_user.reset_mock() - # test an exception while creating the application database and scoped user - _create_application_database_and_scoped_user.side_effect = ( - MySQLCreateApplicationDatabaseAndScopedUserError + # test an exception while creating the application database + _create_database.side_effect = MySQLCreateApplicationDatabaseError + self.harness.update_relation_data( + self.db_router_relation_id, + "app/0", + { + "MRUP_database": "keystone_database", + "MRUP_hostname": "1.1.1.2", + "MRUP_username": "keystone_user", + "mysqlrouter_hostname": "1.1.1.3", + "mysqlrouter_username": "mysqlrouteruser", + }, ) + + self.assertTrue(isinstance(self.harness.model.unit.status, BlockedStatus)) + + _create_database.reset_mock() + _create_scoped_user.reset_mock() + + # test an exception while creating the application scoped user + _create_scoped_user.side_effect = MySQLCreateApplicationScopedUserError self.harness.update_relation_data( self.db_router_relation_id, "app/0", @@ -202,4 +227,5 @@ def test_db_router_relation_changed_exceptions( self.assertTrue(isinstance(self.harness.model.unit.status, BlockedStatus)) - _create_application_database_and_scoped_user.reset_mock() + _create_database.reset_mock() + _create_scoped_user.reset_mock() diff --git a/tests/unit/test_mysql.py b/tests/unit/test_mysql.py index fc00eb8c3f..d7c0f18495 100644 --- a/tests/unit/test_mysql.py +++ b/tests/unit/test_mysql.py @@ -8,6 +8,14 @@ import tenacity from charms.mysql.v0.mysql import ( + LEGACY_ROLE_ROUTER, + MODERN_ROLE_ROUTER, + ROLE_BACKUP, + ROLE_DBA, + ROLE_DDL, + ROLE_DML, + ROLE_READ, + ROLE_STATS, Error, MySQLAddInstanceToClusterError, MySQLBase, @@ -15,9 +23,11 @@ MySQLClientError, MySQLClusterMetadataExistsError, MySQLConfigureInstanceError, + MySQLConfigureMySQLRolesError, MySQLConfigureMySQLUsersError, MySQLConfigureRouterUserError, - MySQLCreateApplicationDatabaseAndScopedUserError, + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, MySQLCreateClusterError, MySQLCreateClusterSetError, MySQLCreateReplicaClusterError, @@ -55,7 +65,7 @@ MySQLSetVariableError, ) -from constants import MYSQLD_SOCK_FILE +from constants import MYSQLD_SOCK_FILE, ROOT_USERNAME SHORT_CLUSTER_STATUS = { "defaultreplicaset": { @@ -124,40 +134,137 @@ def setUp(self): "backupspassword", ) # pyright: ignore + @patch("charms.mysql.v0.mysql.MySQLBase.list_mysql_roles") @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script") - def test_configure_mysql_users(self, _run_mysqlcli_script): - """Test successful configuration of MySQL users.""" + def test_configure_mysql_router_roles(self, _run_mysqlcli_script, _list_mysql_roles): + """Test successful configuration of MySQL router role.""" + router_roles = (LEGACY_ROLE_ROUTER, MODERN_ROLE_ROUTER) + + for role in router_roles: + _list_mysql_roles.return_value = {next(r for r in router_roles if r != role)} + _run_mysqlcli_script.reset_mock() + _run_mysqlcli_script.return_value = b"" + + _expected_configure_role_commands = [ + f"CREATE ROLE {role}", + f"GRANT CREATE ON *.* TO {role}", + f"GRANT CREATE USER ON *.* TO {role}", + f"GRANT ALL ON *.* TO {role} WITH GRANT OPTION", + ] + + self.mysql.configure_mysql_router_roles() + + _run_mysqlcli_script.assert_called_once_with( + _expected_configure_role_commands, + user=ROOT_USERNAME, + password="password", + ) + + @patch("charms.mysql.v0.mysql.MySQLBase.list_mysql_roles") + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script") + def test_configure_mysql_router_roles_fail(self, _run_mysqlcli_script, _list_mysql_roles): + """Test failure to configure the MySQL router role.""" + _list_mysql_roles.return_value = set() + _run_mysqlcli_script.side_effect = MySQLClientError("Error on subprocess") + + with self.assertRaises(MySQLConfigureMySQLRolesError): + self.mysql.configure_mysql_router_roles() + + @patch("charms.mysql.v0.mysql.MySQLBase.list_mysql_roles") + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script") + def test_configure_mysql_system_roles(self, _run_mysqlcli_script, _list_mysql_roles): + """Test successful configuration of MySQL system roles.""" + _list_mysql_roles.return_value = {ROLE_DBA} _run_mysqlcli_script.return_value = b"" - _expected_configure_user_commands = ( + _expected_configure_roles_commands = [ + # Charmed read queries + f"CREATE ROLE {ROLE_READ}", + # Charmed DML queries + f"CREATE ROLE {ROLE_DML}", + # Charmed stats queries + f"CREATE ROLE {ROLE_STATS}", + f"GRANT SELECT ON performance_schema.* TO {ROLE_STATS}", + f"GRANT PROCESS, RELOAD, REPLICATION CLIENT ON *.* TO {ROLE_STATS}", + # Charmed backup queries + 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}", + # Charmed DDL queries + 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}", + ] + + self.mysql.configure_mysql_system_roles() + + _run_mysqlcli_script.assert_called_once_with( + _expected_configure_roles_commands, + user=ROOT_USERNAME, + password="password", + ) + + @patch("charms.mysql.v0.mysql.MySQLBase.list_mysql_roles") + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script") + def test_configure_mysql_system_roles_fail(self, _run_mysqlcli_script, _list_mysql_roles): + """Test failure to configure the MySQL system roles.""" + _list_mysql_roles.return_value = set() + _run_mysqlcli_script.side_effect = MySQLClientError("Error on subprocess") + + with self.assertRaises(MySQLConfigureMySQLRolesError): + self.mysql.configure_mysql_system_roles() + + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script") + def test_configure_mysql_system_users(self, _run_mysqlcli_script): + """Test successful configuration of MySQL system users.""" + _run_mysqlcli_script.return_value = b"" + + _expected_configure_user_commands = [ + "UPDATE mysql.user SET authentication_string=null WHERE User='root' and Host='localhost'", + "ALTER USER 'root'@'localhost' IDENTIFIED BY 'password'", "CREATE USER 'serverconfig'@'%' IDENTIFIED BY 'serverconfigpassword'", - "GRANT ALL ON *.* TO 'serverconfig'@'%' WITH GRANT OPTION", "CREATE USER 'monitoring'@'%' IDENTIFIED BY 'monitoringpassword' WITH MAX_USER_CONNECTIONS 3", - "GRANT SYSTEM_USER, SELECT, PROCESS, SUPER, REPLICATION CLIENT, RELOAD ON *.* TO 'monitoring'@'%'", "CREATE USER 'backups'@'%' IDENTIFIED BY 'backupspassword'", - "GRANT CONNECTION_ADMIN, BACKUP_ADMIN, PROCESS, RELOAD, LOCK TABLES, REPLICATION CLIENT ON *.* TO 'backups'@'%'", - "GRANT SELECT ON performance_schema.log_status TO 'backups'@'%'", - "GRANT SELECT ON performance_schema.keyring_component_status TO 'backups'@'%'", - "GRANT SELECT ON performance_schema.replication_group_members TO 'backups'@'%'", - "UPDATE mysql.user SET authentication_string=null WHERE User='root' and Host='localhost'", - "ALTER USER 'root'@'localhost' IDENTIFIED BY 'password'", - "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 ON *.* FROM 'root'@'localhost'", + "GRANT ALL ON *.* TO 'serverconfig'@'%' WITH GRANT OPTION", + "GRANT charmed_stats TO 'monitoring'@'%'", + "GRANT charmed_backup TO 'backups'@'%'", + "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 'root'@'localhost'", "FLUSH PRIVILEGES", - ) + ] - self.mysql.configure_mysql_users() + self.mysql.configure_mysql_system_users() _run_mysqlcli_script.assert_called_once_with( - _expected_configure_user_commands, password="password" + _expected_configure_user_commands, + user=ROOT_USERNAME, + password="password", ) @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script") - def test_configure_mysql_users_fail(self, _run_mysqlcli_script): - """Test failure to configure the MySQL users.""" + def test_configure_mysql_system_users_fail(self, _run_mysqlcli_script): + """Test failure to configure the MySQL system users.""" _run_mysqlcli_script.side_effect = MySQLClientError("Error on subprocess") with self.assertRaises(MySQLConfigureMySQLUsersError): - self.mysql.configure_mysql_users() + self.mysql.configure_mysql_system_users() + + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script") + def test_list_mysql_roles(self, _run_mysqlcli_script): + """Test successful listing of MySQL roles.""" + _run_mysqlcli_script.return_value = [] + + _expected_list_roles_commands = ( + "SELECT User FROM mysql.user WHERE User LIKE 'charmed_%'", + ) + + self.mysql.list_mysql_roles("charmed_%") + + _run_mysqlcli_script.assert_called_once_with( + _expected_list_roles_commands, + user=ROOT_USERNAME, + password="password", + ) @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script") def test_does_mysql_user_exist(self, _run_mysqlcli_script): @@ -250,24 +357,23 @@ def test_configure_mysqlrouter_user_failure( ) @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") - def test_create_application_database_and_scoped_user(self, _run_mysqlsh_script): - """Test the successful execution of create_application_database_and_scoped_user.""" + def test_create_application_database(self, _run_mysqlsh_script): + """Test the successful execution of create_application_database.""" _run_mysqlsh_script.return_value = "" _expected_create_scoped_user_commands = "\n".join(( "shell.connect_to_primary()", - 'session.run_sql("CREATE DATABASE IF NOT EXISTS `test-database`;")', - 'session.run_sql("CREATE USER `test-username`@`1.1.1.1` IDENTIFIED BY \'test-password\' ATTRIBUTE \'{\\"unit_name\\": \\"app/0\\"}\';")', - 'session.run_sql("GRANT USAGE ON *.* TO `test-username`@`1.1.1.1`;")', - 'session.run_sql("GRANT ALL PRIVILEGES ON `test-database`.* TO `test-username`@`1.1.1.1`;")', + 'session.run_sql("CREATE DATABASE IF NOT EXISTS `test_database`;")', + 'session.run_sql("GRANT SELECT ON `test_database`.* TO charmed_read;")', + 'session.run_sql("GRANT SELECT, INSERT, DELETE, UPDATE ON `test_database`.* TO charmed_dml;")', + 'session.run_sql("CREATE ROLE IF NOT EXISTS `charmed_dba_test_database`;")', + 'session.run_sql("GRANT SELECT, INSERT, DELETE, UPDATE, EXECUTE ON `test_database`.* TO charmed_dba_test_database;")', + 'session.run_sql("GRANT ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE VIEW, DROP, INDEX, LOCK TABLES, REFERENCES, TRIGGER ON `test_database`.* TO charmed_dba_test_database;")', )) - self.mysql.create_application_database_and_scoped_user( - "test-database", "test-username", "test-password", "1.1.1.1", unit_name="app/0" - ) + self.mysql.create_database("test_database") self.assertEqual(_run_mysqlsh_script.call_count, 1) - self.assertEqual( _run_mysqlsh_script.mock_calls, [ @@ -280,18 +386,78 @@ def test_create_application_database_and_scoped_user(self, _run_mysqlsh_script): ], ) - @patch("charms.mysql.v0.mysql.MySQLBase.get_cluster_primary_address") @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") - def test_create_application_database_and_scoped_user_failure( - self, _run_mysqlsh_script, _get_cluster_primary_address - ): + def test_create_application_database_failure(self, _run_mysqlsh_script): """Test failure to create application database and scoped user.""" - _get_cluster_primary_address.return_value = "2.2.2.2" _run_mysqlsh_script.side_effect = MySQLClientError("Error on subprocess") - with self.assertRaises(MySQLCreateApplicationDatabaseAndScopedUserError): - self.mysql.create_application_database_and_scoped_user( - "test_database", "test_username", "test_password", "1.1.1.1", unit_name="app/.0" + with self.assertRaises(MySQLCreateApplicationDatabaseError): + self.mysql.create_database("test_database") + + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") + def test_create_application_database_invalid(self, _run_mysqlsh_script): + """Test failure to create an invalid application database.""" + with self.assertRaises(MySQLCreateApplicationDatabaseError): + self.mysql.create_database("extremely_extra_long_database_name") + + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") + def test_create_application_scoped_user(self, _run_mysqlsh_script): + """Test the successful execution of create_application_scoped_user.""" + _run_mysqlsh_script.return_value = "" + + _expected_create_scoped_user_commands = "\n".join(( + "shell.connect_to_primary()", + 'session.run_sql("CREATE USER `test_username`@`1.1.1.1` IDENTIFIED BY \'test_password\' ATTRIBUTE \'{\\"unit_name\\": \\"app/0\\"}\';")', + 'session.run_sql("GRANT USAGE ON *.* TO `test_username`@`1.1.1.1`;")', + 'session.run_sql("GRANT ALL PRIVILEGES ON `test_database`.* TO `test_username`@`1.1.1.1`;")', + )) + + self.mysql.create_scoped_user( + "test_database", + "test_username", + "test_password", + "1.1.1.1", + unit_name="app/0", + ) + + self.assertEqual(_run_mysqlsh_script.call_count, 1) + self.assertEqual( + _run_mysqlsh_script.mock_calls, + [ + call( + _expected_create_scoped_user_commands, + user="serverconfig", + password="serverconfigpassword", + host="127.0.0.1:33062", + ) + ], + ) + + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") + def test_create_application_scoped_user_failure(self, _run_mysqlsh_script): + """Test failure to create application scoped user.""" + _run_mysqlsh_script.side_effect = MySQLClientError("Error on subprocess") + + with self.assertRaises(MySQLCreateApplicationScopedUserError): + self.mysql.create_scoped_user( + "test_database", + "test_username", + "test_password", + "1.1.1.1", + unit_name="app/0", + ) + + @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") + def test_create_application_scoped_user_invalid(self, _run_mysqlsh_script): + """Test failure to create an invalid application scoped user.""" + with self.assertRaises(MySQLCreateApplicationScopedUserError): + self.mysql.create_scoped_user( + "test_database", + "test_username", + "test_password", + "1.1.1.1", + unit_name="app/0", + extra_roles=[ROLE_BACKUP], ) @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") @@ -1050,41 +1216,6 @@ def test_get_mysql_version(self, _run_mysqlsh_script): with self.assertRaises(MySQLGetMySQLVersionError): self.mysql.get_mysql_version() - @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") - def test_grant_privileges_to_user(self, _run_mysqlsh_script): - """Test the successful execution of grant_privileges_to_user.""" - expected_commands = "\n".join(( - "shell.connect_to_primary()", - "session.run_sql(\"GRANT CREATE USER ON *.* TO 'test_user'@'%' WITH GRANT OPTION\")", - )) - - self.mysql.grant_privileges_to_user( - "test_user", "%", ["CREATE USER"], with_grant_option=True - ) - - _run_mysqlsh_script.assert_called_with( - expected_commands, - user="serverconfig", - password="serverconfigpassword", - host="127.0.0.1:33062", - ) - - _run_mysqlsh_script.reset_mock() - - expected_commands = "\n".join(( - "shell.connect_to_primary()", - "session.run_sql(\"GRANT SELECT, UPDATE ON *.* TO 'test_user'@'%'\")", - )) - - self.mysql.grant_privileges_to_user("test_user", "%", ["SELECT", "UPDATE"]) - - _run_mysqlsh_script.assert_called_with( - expected_commands, - user="serverconfig", - password="serverconfigpassword", - host="127.0.0.1:33062", - ) - @patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script") def test_update_user_password(self, _run_mysqlsh_script): """Test the successful execution of update_user_password.""" @@ -2075,8 +2206,8 @@ def test_render_mysqld_configuration(self, _get_available_memory): _get_available_memory.return_value = 32341442560 expected_config = { - "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": "127.0.0.1", "report_host": "127.0.0.1", "max_connections": "724", @@ -2095,6 +2226,7 @@ def test_render_mysqld_configuration(self, _get_available_memory): "innodb_buffer_pool_chunk_size": "2902458368", "gtid_mode": "ON", "enforce_gtid_consistency": "ON", + "activate_all_roles_on_login": "ON", } self.maxDiff = None @@ -2478,8 +2610,8 @@ def test_strip_off_password(self): File "/var/lib/juju/agents/unit-mysql-k8s-edge-0/charm/venv/lib/python3.10/site-packages/ops/pebble.py", line 1771, in wait_output raise ExecError[AnyStr](self._command, exit_code, out_value, err_value) ops.pebble.ExecError: non-zero exit code 1 executing ['/usr/bin/mysqlsh', '--passwords-from-stdin', '--uri=serverconfig@mysql-k8s-edge-0.mysql-k8s-edge-endpoints.stg-alutay-datasql-juju361.svc.cluster.local:33062', '--python', '--verbose=0', '-c', 'shell.options.set(\'useWizards\', False)\nprint(\'###\')\nsh$ -ll.connect_to_primary()\nsession.run_sql("CREATE DATABASE IF NOT EXISTS `continuous_writes_database`;")\nsession.run_sql("CREATE USER `relation-21_ff7306c7454f44`@`%` IDENTIFIED BY \'s1ffxPedAmX58aOdCRSzxEpm\' ATTRIBUTE \'{}\';")\nsession.run_sql("GRANT USAGE ON *.* TO `relation-21_ff7306c7454f44`@`%`;")\nses -sion.run_sql("GRANT ALL PRIVILEGES ON `continuous_writes_database`.* TO `relation-21_ff7306c7454f44`@`%`;")'], stdout="\x1b[1mPlease provide the password for 'serverconfig@mysql-k8s-edge-0.mysql-k8s-edge-endpoints.stg-alutay-datasql-juju361.svc.cluster.local:33062': \x1b[0m###\n", stderr='Cannot set LC_ALL to +ll.connect_to_primary()\nsession.run_sql("CREATE DATABASE IF NOT EXISTS `continuous_writes`;")\nsession.run_sql("CREATE USER `relation-21_ff7306c7454f44`@`%` IDENTIFIED BY \'s1ffxPedAmX58aOdCRSzxEpm\' ATTRIBUTE \'{}\';")\nsession.run_sql("GRANT USAGE ON *.* TO `relation-21_ff7306c7454f44`@`%`;")\nses +sion.run_sql("GRANT ALL PRIVILEGES ON `continuous_writes`.* TO `relation-21_ff7306c7454f44`@`%`;")'], stdout="\x1b[1mPlease provide the password for 'serverconfig@mysql-k8s-edge-0.mysql-k8s-edge-endpoints.stg-alutay-datasql-juju361.svc.cluster.local:33062': \x1b[0m###\n", stderr='Cannot set LC_ALL to locale en_US.UTF-8: No such file or directory\n\x1b[36mNOTE: \x1b[0mAlready connected to a PRIMARY.\nTraceback (most recent call last):\n File "", line 5, in \nmysqlsh.DBError: MySQL Error (1396): ClassicSession.run_sql: Operation CREATE USER failed for \'relation-21_ff7306c7454f44\'@\'%\'\n """ output = self.mysql.strip_off_passwords(_input) diff --git a/tests/unit/test_mysqlsh_helpers.py b/tests/unit/test_mysqlsh_helpers.py index 56db70c58e..62311dee17 100644 --- a/tests/unit/test_mysqlsh_helpers.py +++ b/tests/unit/test_mysqlsh_helpers.py @@ -317,8 +317,8 @@ def test_write_mysqld_config( config = "\n".join(( "[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 = 127.0.0.1", "report_host = 127.0.0.1", "max_connections = 111", @@ -334,6 +334,7 @@ def test_write_mysqld_config( "loose-audit_log_file = /var/snap/charmed-mysql/common/var/log/mysql/audit.log", "gtid_mode = ON", "enforce_gtid_consistency = ON", + "activate_all_roles_on_login = ON", "loose-audit_log_format = JSON", "loose-audit_log_strategy = ASYNCHRONOUS", "innodb_buffer_pool_chunk_size = 5678", @@ -353,39 +354,8 @@ def test_write_mysqld_config( _open_mock.reset_mock() self.mysql.write_mysqld_config() - config = "\n".join(( - "[mysqld]", - "bind-address = 0.0.0.0", - "mysqlx-bind-address = 0.0.0.0", - "admin_address = 127.0.0.1", - "report_host = 127.0.0.1", - "max_connections = 100", - "innodb_buffer_pool_size = 20971520", - "log_error_services = log_filter_internal;log_sink_internal", - "log_error = /var/snap/charmed-mysql/common/var/log/mysql/error.log", - "general_log = OFF", - "general_log_file = /var/snap/charmed-mysql/common/var/log/mysql/general.log", - "loose-group_replication_paxos_single_leader = ON", - "slow_query_log_file = /var/snap/charmed-mysql/common/var/log/mysql/slow.log", - "binlog_expire_logs_seconds = 604800", - "loose-audit_log_policy = LOGINS", - "loose-audit_log_file = /var/snap/charmed-mysql/common/var/log/mysql/audit.log", - "gtid_mode = ON", - "enforce_gtid_consistency = ON", - "loose-audit_log_format = JSON", - "loose-audit_log_strategy = ASYNCHRONOUS", - "innodb_buffer_pool_chunk_size = 1048576", - "performance-schema-instrument = 'memory/%=OFF'", - "loose-group_replication_message_cache_size = 134217728", - "\n", - )) - self.assertTrue( - call( - f"{MYSQLD_CONFIG_DIRECTORY}/z-custom-mysqld.cnf", - "w", - encoding="utf-8", - ) + call(f"{MYSQLD_CONFIG_DIRECTORY}/z-custom-mysqld.cnf", "w", encoding="utf-8") in _open_mock.mock_calls ) diff --git a/tests/unit/test_relation_mysql_legacy.py b/tests/unit/test_relation_mysql_legacy.py index 3b39ef1bb8..3cbde49b3e 100644 --- a/tests/unit/test_relation_mysql_legacy.py +++ b/tests/unit/test_relation_mysql_legacy.py @@ -29,10 +29,12 @@ def setUp(self): "relations.mysql.MySQLRelation._get_or_set_password_in_peer_secrets", return_value="super_secure_password", ) - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_helpers.MySQL.create_scoped_user") def test_maria_db_relation_created( self, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _get_or_set_password_in_peer_secrets, _get_cluster_primary_address, _does_mysql_user_exist, @@ -52,7 +54,10 @@ def test_maria_db_relation_created( self.harness.add_relation_unit(self.maria_db_relation_id, "other-app/0") self.assertEqual(_get_or_set_password_in_peer_secrets.call_count, 1) - _create_application_database_and_scoped_user.assert_called_once_with( + _create_database.assert_called_once_with( + "default_database", + ) + _create_scoped_user.assert_called_once_with( "default_database", "mysql", "super_secure_password", @@ -86,10 +91,12 @@ def test_maria_db_relation_created( "relations.mysql.MySQLRelation._get_or_set_password_in_peer_secrets", return_value="super_secure_password", ) - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_helpers.MySQL.create_scoped_user") def test_maria_db_relation_created_with_secrets( self, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _get_or_set_password_in_peer_secrets, _get_cluster_primary_address, _does_mysql_user_exist, @@ -109,7 +116,10 @@ def test_maria_db_relation_created_with_secrets( self.harness.add_relation_unit(self.maria_db_relation_id, "other-app/0") self.assertEqual(_get_or_set_password_in_peer_secrets.call_count, 1) - _create_application_database_and_scoped_user.assert_called_once_with( + _create_database.assert_called_once_with( + "default_database", + ) + _create_scoped_user.assert_called_once_with( "default_database", "mysql", "super_secure_password", @@ -146,10 +156,12 @@ def test_maria_db_relation_created_with_secrets( "relations.mysql.MySQLRelation._get_or_set_password_in_peer_secrets", return_value="super_secure_password", ) - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_helpers.MySQL.create_scoped_user") def test_maria_db_relation_departed( self, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _get_or_set_password_in_peer_secrets, _delete_users_for_unit, _get_cluster_primary_address, diff --git a/tests/unit/test_shared_db.py b/tests/unit/test_shared_db.py index c7c23f6dbe..5a40bb8340 100644 --- a/tests/unit/test_shared_db.py +++ b/tests/unit/test_shared_db.py @@ -4,7 +4,10 @@ import unittest from unittest.mock import patch -from charms.mysql.v0.mysql import MySQLCreateApplicationDatabaseAndScopedUserError +from charms.mysql.v0.mysql import ( + MySQLCreateApplicationDatabaseError, + MySQLCreateApplicationScopedUserError, +) from ops.model import BlockedStatus from ops.testing import Harness @@ -27,10 +30,12 @@ def setUp(self): @patch("charm.MySQLOperatorCharm.unit_initialized", return_value=True) @patch("mysql_vm_helpers.MySQL.get_cluster_primary_address", return_value="192.0.2.0") @patch("relations.shared_db.generate_random_password", return_value="super_secure_password") - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_helpers.MySQL.create_scoped_user") def test_shared_db_relation_changed( self, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _generate_random_password, _get_cluster_primary_address, _, @@ -65,7 +70,10 @@ def test_shared_db_relation_changed( # 2 calls during start-up events, and 1 calls during the shared_db_relation_changed event self.assertEqual(_generate_random_password.call_count, 1) - _create_application_database_and_scoped_user.assert_called_once_with( + _create_database.assert_called_once_with( + "shared_database", + ) + _create_scoped_user.assert_called_once_with( "shared_database", "shared_user", "super_secure_password", @@ -88,10 +96,12 @@ def test_shared_db_relation_changed( @patch("charm.MySQLOperatorCharm.unit_initialized", return_value=True) @patch("relations.shared_db.SharedDBRelation._on_leader_elected") @patch("utils.generate_random_password", return_value="super_secure_password") - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_helpers.MySQL.create_scoped_user") def test_shared_db_relation_changed_error_on_user_creation( self, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _generate_random_password, _, _leader_elected, @@ -101,9 +111,23 @@ def test_shared_db_relation_changed_error_on_user_creation( self.harness.set_leader(True) self.charm.on.config_changed.emit() - _create_application_database_and_scoped_user.side_effect = ( - MySQLCreateApplicationDatabaseAndScopedUserError("Can't create user") + _create_database.side_effect = MySQLCreateApplicationDatabaseError + # update the app leader unit data to trigger shared_db_relation_changed event + self.harness.update_relation_data( + self.shared_db_relation_id, + "other-app/0", + { + "database": "shared_database", + "hostname": "1.1.1.2", + "username": "shared_user", + }, ) + + self.assertTrue(isinstance(self.harness.model.unit.status, BlockedStatus)) + _create_database.reset_mock() + _create_scoped_user.reset_mock() + + _create_scoped_user.side_effect = MySQLCreateApplicationScopedUserError # update the app leader unit data to trigger shared_db_relation_changed event self.harness.update_relation_data( self.shared_db_relation_id, @@ -116,15 +140,19 @@ def test_shared_db_relation_changed_error_on_user_creation( ) self.assertTrue(isinstance(self.harness.model.unit.status, BlockedStatus)) + _create_database.reset_mock() + _create_scoped_user.reset_mock() @patch("charm.MySQLOperatorCharm.unit_initialized", return_value=True) @patch("mysql_vm_helpers.MySQL.get_cluster_primary_address", return_value="192.0.2.0:3306") @patch("mysql_vm_helpers.MySQL.delete_users_for_unit") @patch("relations.shared_db.generate_random_password", return_value="super_secure_password") - @patch("mysql_vm_helpers.MySQL.create_application_database_and_scoped_user") + @patch("mysql_vm_helpers.MySQL.create_database") + @patch("mysql_vm_helpers.MySQL.create_scoped_user") def test_shared_db_relation_departed( self, - _create_application_database_and_scoped_user, + _create_scoped_user, + _create_database, _generate_random_password, _delete_users_for_unit, _get_cluster_primary_address,