Skip to content

Commit

Permalink
Direct writes to the cluster primary for methods in mysql lib (#19)
Browse files Browse the repository at this point in the history
* Direct writes to the cluster primary for methods in mysql lib

* Use serverconfig user instead of clusteradmin to create and drop users and databases

* Minor updates/improvements to the README and metadata

* Add missing error handling in db-router legacy relation
  • Loading branch information
shayancanonical authored Jul 8, 2022
1 parent d5b92aa commit 3e69f97
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 66 deletions.
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

The [MySQL](https://www.mysql.com/) operator provides an open-source relational database management system (RDBMS). This repository contains a Juju Charm for deploying MySQL on machines.

This charm is currently in development, with High Availability via Group Replication as a short-term goal.

## Usage

To deploy this charm using Juju 2.9.0 or later, run:
Expand Down
76 changes: 49 additions & 27 deletions lib/charms/mysql/v0/mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,9 @@ def does_mysql_user_exist(self, username: str, hostname: str) -> bool:
)

try:
output = self._run_mysqlcli_script("; ".join(user_existence_commands))
output = self._run_mysqlcli_script(
"; ".join(user_existence_commands), password=self.root_password
)
return "USER_EXISTS" in output
except MySQLClientError as e:
logger.exception(
Expand All @@ -296,24 +298,33 @@ def configure_mysqlrouter_user(
Raises MySQLConfigureRouterUserError
if there is an issue creating and configuring the mysqlrouter user
"""
mysqlrouter_user_attributes = {"unit_name": unit_name}
create_mysqlrouter_user_commands = (
f"CREATE USER '{username}'@'{hostname}' IDENTIFIED BY '{password}' ATTRIBUTE '{json.dumps(mysqlrouter_user_attributes)}'",
)
mysqlrouter_user_grant_commands = (
f"GRANT CREATE USER ON *.* TO '{username}'@'{hostname}' WITH GRANT OPTION",
f"GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON mysql_innodb_cluster_metadata.* TO '{username}'@'{hostname}'",
f"GRANT SELECT ON mysql.user TO '{username}'@'{hostname}'",
f"GRANT SELECT ON performance_schema.replication_group_members TO '{username}'@'{hostname}'",
f"GRANT SELECT ON performance_schema.replication_group_member_stats TO '{username}'@'{hostname}'",
f"GRANT SELECT ON performance_schema.global_variables TO '{username}'@'{hostname}'",
)

try:
primary_address = self.get_cluster_primary_address()

escaped_mysqlrouter_user_attributes = json.dumps({"unit_name": unit_name}).replace(
'"', r"\""
)
# Using server_config_user as we are sure it has create user grants
create_mysqlrouter_user_commands = (
f"shell.connect('{self.server_config_user}:{self.server_config_password}@{primary_address}')",
f"session.run_sql(\"CREATE USER '{username}'@'{hostname}' IDENTIFIED BY '{password}' ATTRIBUTE '{escaped_mysqlrouter_user_attributes}';\")",
)

# Using server_config_user as we are sure it has create user grants
mysqlrouter_user_grant_commands = (
f"shell.connect('{self.server_config_user}:{self.server_config_password}@{primary_address}')",
f"session.run_sql(\"GRANT CREATE USER ON *.* TO '{username}'@'{hostname}' WITH GRANT OPTION;\")",
f"session.run_sql(\"GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON mysql_innodb_cluster_metadata.* TO '{username}'@'{hostname}';\")",
f"session.run_sql(\"GRANT SELECT ON mysql.user TO '{username}'@'{hostname}';\")",
f"session.run_sql(\"GRANT SELECT ON performance_schema.replication_group_members TO '{username}'@'{hostname}';\")",
f"session.run_sql(\"GRANT SELECT ON performance_schema.replication_group_member_stats TO '{username}'@'{hostname}';\")",
f"session.run_sql(\"GRANT SELECT ON performance_schema.global_variables TO '{username}'@'{hostname}';\")",
)

logger.debug(f"Configuring MySQLRouter user for {self.instance_address}")
self._run_mysqlcli_script("; ".join(create_mysqlrouter_user_commands))
self._run_mysqlsh_script("\n".join(create_mysqlrouter_user_commands))
# grant permissions to the newly created mysqlrouter user
self._run_mysqlcli_script("; ".join(mysqlrouter_user_grant_commands))
self._run_mysqlsh_script("\n".join(mysqlrouter_user_grant_commands))
except MySQLClientError as e:
logger.exception(
f"Failed to configure mysqlrouter user for: {self.instance_address} with error {e.message}",
Expand All @@ -336,17 +347,26 @@ def create_application_database_and_scoped_user(
Raises MySQLCreateApplicationDatabaseAndScopedUserError
if there is an issue creating the application database or a user scoped to the database
"""
create_database_commands = (f"CREATE DATABASE IF NOT EXISTS {database_name}",)
user_attributes = {"unit_name": unit_name}
create_scoped_user_commands = (
f"CREATE USER '{username}'@'{hostname}' IDENTIFIED BY '{password}' ATTRIBUTE '{json.dumps(user_attributes)}'",
f"GRANT USAGE ON *.* TO '{username}'@`{hostname}`",
f"GRANT ALL PRIVILEGES ON `{database_name}`.* TO `{username}`@`{hostname}`",
)

try:
self._run_mysqlcli_script("; ".join(create_database_commands))
self._run_mysqlcli_script("; ".join(create_scoped_user_commands))
primary_address = self.get_cluster_primary_address()

# Using server_config_user as we are sure it has create database grants
create_database_commands = (
f"shell.connect('{self.server_config_user}:{self.server_config_password}@{primary_address}')",
f'session.run_sql("CREATE DATABASE IF NOT EXISTS {database_name};")',
)

escaped_user_attributes = json.dumps({"unit_name": unit_name}).replace('"', r"\"")
# Using server_config_user as we are sure it has create user grants
create_scoped_user_commands = (
f"shell.connect('{self.server_config_user}:{self.server_config_password}@{primary_address}')",
f"session.run_sql(\"CREATE USER '{username}'@'{hostname}' IDENTIFIED BY '{password}' ATTRIBUTE '{escaped_user_attributes}';\")",
f"session.run_sql(\"GRANT USAGE ON *.* TO '{username}'@`{hostname}`;\")",
f'session.run_sql("GRANT ALL PRIVILEGES ON `{database_name}`.* TO `{username}`@`{hostname}`;")',
)

self._run_mysqlsh_script("\n".join(create_database_commands))
self._run_mysqlsh_script("\n".join(create_scoped_user_commands))
except MySQLClientError as e:
logger.exception(
f"Failed to create application database {database_name} and scoped user {username}@{hostname}",
Expand Down Expand Up @@ -379,8 +399,10 @@ def delete_users_for_unit(self, unit_name: str) -> None:
return

primary_address = self.get_cluster_primary_address()

# Using server_config_user as we are sure it has drop user grants
drop_users_command = (
f"shell.connect('{self.cluster_admin_user}:{self.cluster_admin_password}@{primary_address}')",
f"shell.connect('{self.server_config_user}:{self.server_config_password}@{primary_address}')",
f"session.run_sql(\"DROP USER IF EXISTS {', '.join(users)};\")",
)
self._run_mysqlsh_script("\n".join(drop_users_command))
Expand Down
2 changes: 1 addition & 1 deletion metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
name: mysql
display-name: MySQL
description: |
MySQL charm for machines
MySQL charm for machines
summary: |
MySQL is a widely used, open-source relational database management system
(RDBMS). MySQL InnoDB cluster provides a complete high availability solution
Expand Down
8 changes: 6 additions & 2 deletions src/relations/db_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
MySQLCheckUserExistenceError,
MySQLConfigureRouterUserError,
MySQLCreateApplicationDatabaseAndScopedUserError,
MySQLDeleteUsersForUnitError,
)
from ops.charm import RelationChangedEvent, RelationDepartedEvent
from ops.framework import Object
Expand Down Expand Up @@ -162,7 +163,7 @@ def _on_leader_elected(self, _) -> None:
if not self.charm._is_peer_data_set:
return

for relation in self.model.relations.get(LEGACY_DB_ROUTER, []):
for relation in self.charm.model.relations.get(LEGACY_DB_ROUTER, []):
relation_databag = relation.data

# Copy data from the application databag into the leader unit's databag
Expand Down Expand Up @@ -264,4 +265,7 @@ def _on_db_router_relation_departed(self, event: RelationDepartedEvent) -> None:

leader_db_router_databag[key] = json.dumps(" ".join(allowed_units))

self.charm._mysql.delete_users_for_unit(departing_unit_name)
try:
self.charm._mysql.delete_users_for_unit(departing_unit_name)
except MySQLDeleteUsersForUnitError:
self.charm.unit.status = BlockedStatus("Failed to delete users for departing unit")
92 changes: 58 additions & 34 deletions tests/unit/test_mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ def test_does_mysql_user_exist(self, _run_mysqlcli_script):
)

self.mysql.does_mysql_user_exist("test_username", "1.1.1.1")
_run_mysqlcli_script.assert_called_once_with("\n".join(user_existence_command))
_run_mysqlcli_script.assert_called_once_with(
"\n".join(user_existence_command), password="password"
)

# Reset the mock
_run_mysqlcli_script.reset_mock()
Expand All @@ -105,7 +107,9 @@ def test_does_mysql_user_exist(self, _run_mysqlcli_script):
)

self.mysql.does_mysql_user_exist("test_username", "1.1.1.2")
_run_mysqlcli_script.assert_called_once_with("\n".join(user_existence_command))
_run_mysqlcli_script.assert_called_once_with(
"\n".join(user_existence_command), password="password"
)

@patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script")
def test_does_mysql_user_exist_failure(self, _run_mysqlcli_script):
Expand All @@ -115,34 +119,38 @@ def test_does_mysql_user_exist_failure(self, _run_mysqlcli_script):
with self.assertRaises(MySQLCheckUserExistenceError):
self.mysql.does_mysql_user_exist("test_username", "1.1.1.1")

@patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script")
def test_configure_mysqlrouter_user(self, _run_mysqlcli_script):
@patch("charms.mysql.v0.mysql.MySQLBase.get_cluster_primary_address")
@patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script")
def test_configure_mysqlrouter_user(self, _run_mysqlsh_script, _get_cluster_primary_address):
"""Test the successful execution of configure_mysqlrouter_user."""
_run_mysqlcli_script.return_value = b""
_get_cluster_primary_address.return_value = "2.2.2.2"
_run_mysqlsh_script.return_value = ""

_expected_create_mysqlrouter_user_commands = "; ".join(
_expected_create_mysqlrouter_user_commands = "\n".join(
(
"CREATE USER 'test_username'@'1.1.1.1' IDENTIFIED BY 'test_password' ATTRIBUTE '{\"unit_name\": \"app/0\"}'",
"shell.connect('serverconfig:[email protected]')",
"session.run_sql(\"CREATE USER 'test_username'@'1.1.1.1' IDENTIFIED BY 'test_password' ATTRIBUTE '{\\\"unit_name\\\": \\\"app/0\\\"}';\")",
)
)

_expected_mysqlrouter_user_grant_commands = "; ".join(
_expected_mysqlrouter_user_grant_commands = "\n".join(
(
"GRANT CREATE USER ON *.* TO 'test_username'@'1.1.1.1' WITH GRANT OPTION",
"GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON mysql_innodb_cluster_metadata.* TO 'test_username'@'1.1.1.1'",
"GRANT SELECT ON mysql.user TO 'test_username'@'1.1.1.1'",
"GRANT SELECT ON performance_schema.replication_group_members TO 'test_username'@'1.1.1.1'",
"GRANT SELECT ON performance_schema.replication_group_member_stats TO 'test_username'@'1.1.1.1'",
"GRANT SELECT ON performance_schema.global_variables TO 'test_username'@'1.1.1.1'",
"shell.connect('serverconfig:[email protected]')",
"session.run_sql(\"GRANT CREATE USER ON *.* TO 'test_username'@'1.1.1.1' WITH GRANT OPTION;\")",
"session.run_sql(\"GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON mysql_innodb_cluster_metadata.* TO 'test_username'@'1.1.1.1';\")",
"session.run_sql(\"GRANT SELECT ON mysql.user TO 'test_username'@'1.1.1.1';\")",
"session.run_sql(\"GRANT SELECT ON performance_schema.replication_group_members TO 'test_username'@'1.1.1.1';\")",
"session.run_sql(\"GRANT SELECT ON performance_schema.replication_group_member_stats TO 'test_username'@'1.1.1.1';\")",
"session.run_sql(\"GRANT SELECT ON performance_schema.global_variables TO 'test_username'@'1.1.1.1';\")",
)
)

self.mysql.configure_mysqlrouter_user("test_username", "test_password", "1.1.1.1", "app/0")

self.assertEqual(_run_mysqlcli_script.call_count, 2)
self.assertEqual(_run_mysqlsh_script.call_count, 2)

self.assertEqual(
sorted(_run_mysqlcli_script.mock_calls),
sorted(_run_mysqlsh_script.mock_calls),
sorted(
[
call(_expected_create_mysqlrouter_user_commands),
Expand All @@ -151,41 +159,53 @@ def test_configure_mysqlrouter_user(self, _run_mysqlcli_script):
),
)

@patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script")
def test_configure_mysqlrouter_user_failure(self, _run_mysqlcli_script):
@patch("charms.mysql.v0.mysql.MySQLBase.get_cluster_primary_address")
@patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlsh_script")
def test_configure_mysqlrouter_user_failure(
self, _run_mysqlsh_script, _get_cluster_primary_address
):
"""Test failure to configure the MySQLRouter user."""
_run_mysqlcli_script.side_effect = MySQLClientError("Error on subprocess")
_get_cluster_primary_address.return_value = "2.2.2.2"
_run_mysqlsh_script.side_effect = MySQLClientError("Error on subprocess")

with self.assertRaises(MySQLConfigureRouterUserError):
self.mysql.configure_mysqlrouter_user(
"test_username", "test_password", "1.1.1.1", "app/0"
)

@patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script")
def test_create_application_database_and_scoped_user(self, _run_mysqlcli_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(
self, _run_mysqlsh_script, _get_cluster_primary_address
):
"""Test the successful execution of create_application_database_and_scoped_user."""
_run_mysqlcli_script.return_value = b""
_get_cluster_primary_address.return_value = "2.2.2.2"
_run_mysqlsh_script.return_value = ""

_expected_create_database_commands = "; ".join(
("CREATE DATABASE IF NOT EXISTS test_database",)
_expected_create_database_commands = "\n".join(
(
"shell.connect('serverconfig:[email protected]')",
'session.run_sql("CREATE DATABASE IF NOT EXISTS test_database;")',
)
)

_expected_create_scoped_user_commands = "; ".join(
_expected_create_scoped_user_commands = "\n".join(
(
"CREATE USER 'test_username'@'1.1.1.1' IDENTIFIED BY 'test_password' ATTRIBUTE '{\"unit_name\": \"app/0\"}'",
"GRANT USAGE ON *.* TO 'test_username'@`1.1.1.1`",
"GRANT ALL PRIVILEGES ON `test_database`.* TO `test_username`@`1.1.1.1`",
"shell.connect('serverconfig:[email protected]')",
"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_application_database_and_scoped_user(
"test_database", "test_username", "test_password", "1.1.1.1", "app/0"
)

self.assertEqual(_run_mysqlcli_script.call_count, 2)
self.assertEqual(_run_mysqlsh_script.call_count, 2)

self.assertEqual(
sorted(_run_mysqlcli_script.mock_calls),
sorted(_run_mysqlsh_script.mock_calls),
sorted(
[
call(_expected_create_database_commands),
Expand All @@ -194,10 +214,14 @@ def test_create_application_database_and_scoped_user(self, _run_mysqlcli_script)
),
)

@patch("charms.mysql.v0.mysql.MySQLBase._run_mysqlcli_script")
def test_create_application_database_and_scoped_user_failure(self, _run_mysqlcli_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
):
"""Test failure to create application database and scoped user."""
_run_mysqlcli_script.side_effect = MySQLClientError("Error on subprocess")
_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(
Expand All @@ -222,7 +246,7 @@ def test_delete_users_for_unit(

_expected_drop_users_command = "\n".join(
(
"shell.connect('clusteradmin:clusteradminpassword@2.2.2.2')",
"shell.connect('serverconfig:serverconfigpassword@2.2.2.2')",
'session.run_sql("DROP USER IF EXISTS [email protected], [email protected];")',
)
)
Expand Down

0 comments on commit 3e69f97

Please sign in to comment.