diff --git a/common/common/mysql_shell/__init__.py b/common/common/mysql_shell/__init__.py index a99af8c..cb24423 100644 --- a/common/common/mysql_shell/__init__.py +++ b/common/common/mysql_shell/__init__.py @@ -19,6 +19,10 @@ if typing.TYPE_CHECKING: from ..relations import database_requires +_ROLE_DML = "charmed_dml" +_ROLE_READ = "charmed_read" +_ROLE_MAX_LENGTH = 32 + logger = logging.getLogger(__name__) @@ -123,17 +127,67 @@ def _get_attributes(self, additional_attributes: dict = None) -> str: attributes.update(additional_attributes) return json.dumps(attributes) - def create_application_database_and_user(self, *, username: str, database: str) -> str: - """Create database and user for related database_provides application.""" + # TODO python3.10 min version: Use `set` instead of `typing.Set` + def _get_mysql_roles(self, name_pattern: str) -> typing.Set[str]: + """Returns a set with the MySQL roles.""" + logger.debug(f"Getting MySQL roles with {name_pattern=}") + output_file = self._container.path("/tmp/mysqlsh_output.json") + self._run_code( + _jinja_env.get_template("get_mysql_roles_with_pattern.py.jinja").render( + name_pattern=name_pattern, + output_filepath=output_file.relative_to_container, + ) + ) + with output_file.open("r") as file: + rows = json.load(file) + output_file.unlink() + logger.debug(f"MySQL roles found for {name_pattern=}: {len(rows)}") + return {row[0] for row in rows} + + def _create_application_database(self, *, database: str, rolename: str) -> str: + """Create database for related database_provides application.""" + statements = [ + f"CREATE DATABASE IF NOT EXISTS `{database}`", + f"CREATE ROLE IF NOT EXISTS `{rolename}`", + f"GRANT SELECT, INSERT, DELETE, UPDATE, EXECUTE ON `{database}`.* TO {rolename}", + f"GRANT ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE VIEW, DROP, INDEX, LOCK TABLES, REFERENCES, TRIGGER ON `{database}`.* TO {rolename}", + ] + + mysql_roles = self._get_mysql_roles("charmed_%") + if _ROLE_READ in mysql_roles: + statements.append( + f"GRANT SELECT ON `{database}`.* TO {_ROLE_READ}", + ) + if _ROLE_DML in mysql_roles: + statements.append( + f"GRANT SELECT, INSERT, DELETE, UPDATE ON `{database}`.* TO {_ROLE_DML}", + ) + + logger.debug(f"Creating {database=}") + self._run_sql(statements) + logger.debug(f"Created {database=}") + return database + + def _create_application_user(self, *, database: str, username: str) -> str: + """Create database user for related database_provides application.""" attributes = self._get_attributes() - logger.debug(f"Creating {database=} and {username=} with {attributes=}") password = utils.generate_password() + logger.debug(f"Creating {username=} with {attributes=}") self._run_sql([ - f"CREATE DATABASE IF NOT EXISTS `{database}`", f"CREATE USER `{username}` IDENTIFIED BY '{password}' ATTRIBUTE '{attributes}'", f"GRANT ALL PRIVILEGES ON `{database}`.* TO `{username}`", ]) - logger.debug(f"Created {database=} and {username=} with {attributes=}") + logger.debug(f"Created {username=} with {attributes=}") + return password + + def create_application_database(self, *, database: str, username: str) -> str: + """Create both the database and the relation user, returning its password.""" + rolename = f"charmed_dba_{database}" + if len(rolename) >= _ROLE_MAX_LENGTH: + raise ValueError("Database DBA role longer than 32 characters") + + ________ = self._create_application_database(database=database, rolename=rolename) + password = self._create_application_user(database=database, username=username) return password def add_attributes_to_mysql_router_user( diff --git a/common/common/mysql_shell/templates/get_mysql_roles_with_pattern.py.jinja b/common/common/mysql_shell/templates/get_mysql_roles_with_pattern.py.jinja new file mode 100644 index 0000000..9ef4cf3 --- /dev/null +++ b/common/common/mysql_shell/templates/get_mysql_roles_with_pattern.py.jinja @@ -0,0 +1,12 @@ +import json + +result = session.run_sql( + "SELECT user FROM mysql.user WHERE user LIKE '{{ name_pattern }}'" +) +rows = result.fetch_all() +# mysqlsh objects are weird—they quack (i.e. duck typing) like standard Python objects (e.g. list, +# dict), but do not serialize to JSON correctly. +# Cast to str & load from JSON str before serializing +rows = json.loads(str(rows)) +with open("{{ output_filepath }}", "w") as file: + json.dump(rows, file) diff --git a/common/common/relations/database_provides.py b/common/common/relations/database_provides.py index 5b3b96a..caa2a05 100644 --- a/common/common/relations/database_provides.py +++ b/common/common/relations/database_provides.py @@ -30,12 +30,25 @@ class _UnsupportedExtraUserRole(status_exception.StatusException): def __init__(self, *, app_name: str, endpoint_name: str) -> None: message = ( - f"{app_name} app requested unsupported extra user role on {endpoint_name} endpoint" + f"{app_name} app requested unsupported extra user role on " + f"{endpoint_name} endpoint" ) logger.warning(message) super().__init__(ops.BlockedStatus(message)) +class _InvalidDatabaseName(status_exception.StatusException): + """Application charm requested an invalid database name""" + + def __init__(self, *, app_name: str, endpoint_name: str, exception_msg: str) -> None: + message = ( + f"{app_name} app requested an invalid database name on " + f"{endpoint_name} endpoint: {exception_msg}" + ) + logger.warning(message) + super().__init__(ops.BlockedStatus(exception_msg)) + + class _Relation: """Relation to one application charm""" @@ -79,9 +92,11 @@ def __init__( if isinstance(event, ops.RelationBrokenEvent) and event.relation.id == self._id: raise _RelationBreaking self._database: str = self._databag["database"] + self._app_name: str = relation.app.name + self._endpoint_name: str = relation.name if self._databag.get("extra-user-roles"): raise _UnsupportedExtraUserRole( - app_name=relation.app.name, endpoint_name=relation.name + app_name=self._app_name, endpoint_name=self._endpoint_name ) def _set_databag( @@ -121,16 +136,24 @@ def create_database_and_user( shell.delete_user(username, must_exist=False) logger.debug("Deleted user if exists before creating user") - password = shell.create_application_database_and_user( - username=username, database=self._database - ) - - self._set_databag( - username=username, - password=password, - router_read_write_endpoints=router_read_write_endpoints, - router_read_only_endpoints=router_read_only_endpoints, - ) + try: + password = shell.create_application_database( + database=self._database, + username=username, + ) + except ValueError as exception: + raise _InvalidDatabaseName( + app_name=self._app_name, + endpoint_name=self._endpoint_name, + exception_msg=str(exception), + ) + else: + self._set_databag( + username=username, + password=password, + router_read_write_endpoints=router_read_write_endpoints, + router_read_only_endpoints=router_read_only_endpoints, + ) class _UserNotShared(Exception): diff --git a/kubernetes/tests/unit/conftest.py b/kubernetes/tests/unit/conftest.py index 3076ffa..69b4808 100644 --- a/kubernetes/tests/unit/conftest.py +++ b/kubernetes/tests/unit/conftest.py @@ -47,6 +47,7 @@ def patch(monkeypatch): ) monkeypatch.setattr("common.workload.RunningWorkload._router_username", "") monkeypatch.setattr("common.mysql_shell.Shell._run_code", lambda *args, **kwargs: None) + monkeypatch.setattr("common.mysql_shell.Shell._get_mysql_roles", lambda *args, **kwargs: set()) monkeypatch.setattr( "common.mysql_shell.Shell.get_mysql_router_user_for_unit", lambda *args, **kwargs: None ) diff --git a/machines/src/relations/deprecated_shared_db_database_provides.py b/machines/src/relations/deprecated_shared_db_database_provides.py index a56b2cc..097ad1b 100644 --- a/machines/src/relations/deprecated_shared_db_database_provides.py +++ b/machines/src/relations/deprecated_shared_db_database_provides.py @@ -147,8 +147,9 @@ def create_database_and_user( shell.delete_user(self._username, must_exist=False) logger.debug("Deleted user if exists before creating user") - password = shell.create_application_database_and_user( - username=self._username, database=self._database + password = shell.create_application_database( + database=self._database, + username=self._username, ) self._peer_app_databag[self.peer_databag_password_key] = password self.set_databag(password=password) diff --git a/machines/tests/unit/conftest.py b/machines/tests/unit/conftest.py index 265f263..8d4b4d1 100644 --- a/machines/tests/unit/conftest.py +++ b/machines/tests/unit/conftest.py @@ -60,6 +60,7 @@ def patch(monkeypatch): ) monkeypatch.setattr("common.workload.RunningWorkload._router_username", "") monkeypatch.setattr("common.mysql_shell.Shell._run_code", lambda *args, **kwargs: None) + monkeypatch.setattr("common.mysql_shell.Shell._get_mysql_roles", lambda *args, **kwargs: set()) monkeypatch.setattr( "common.mysql_shell.Shell.get_mysql_router_user_for_unit", lambda *args, **kwargs: None )