Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cleanup router metadata #208

Merged
merged 9 commits into from
May 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 78 additions & 3 deletions lib/charms/mysql/v0/mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def wait_until_mysql_connection(self) -> None:

"""

import dataclasses
import json
import logging
import re
Expand All @@ -90,7 +91,7 @@ def wait_until_mysql_connection(self) -> None:

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 26
LIBPATCH = 27

UNIT_TEARDOWN_LOCKNAME = "unit-teardown"

Expand Down Expand Up @@ -129,6 +130,10 @@ class MySQLCreateApplicationDatabaseAndScopedUserError(Error):
"""Exception raised when creating application database and scoped user."""


class MySQLGetRouterUsersError(Error):
"""Exception raised when there is an issue getting MySQL Router users."""


class MySQLDeleteUsersForUnitError(Error):
"""Exception raised when there is an issue deleting users for a unit."""

Expand All @@ -137,6 +142,14 @@ class MySQLDeleteUsersForRelationError(Error):
"""Exception raised when there is an issue deleting users for a relation."""


class MySQLDeleteUserError(Error):
"""Exception raised when there is an issue deleting a user."""


class MySQLRemoveRouterFromMetadataError(Error):
"""Exception raised when there is an issue removing MySQL Router from cluster metadata."""


class MySQLConfigureInstanceError(Error):
"""Exception raised when there is an issue configuring a MySQL instance."""

Expand Down Expand Up @@ -277,6 +290,14 @@ class MySQLKillSessionError(Error):
"""Exception raised when there is an issue killing a connection."""


@dataclasses.dataclass
class RouterUser:
"""MySQL Router user."""

username: str
router_id: str


class MySQLBase(ABC):
"""Abstract class to encapsulate all operations related to the MySQL instance and cluster.

Expand Down Expand Up @@ -528,6 +549,29 @@ def _get_statements_to_delete_users_with_attribute(
'session.run_sql("DEALLOCATE PREPARE stmt")',
]

def get_mysql_router_users_for_unit(
self, *, relation_id: int, mysql_router_unit_name: str
) -> list[RouterUser]:
"""Get users for related MySQL Router unit.

For each user, get username & router ID attribute.
"""
relation_user = f"relation-{relation_id}"
command = [
f"shell.connect('{self.server_config_user}:{self.server_config_password}@{self.instance_address}')",
f"result = session.run_sql(\"SELECT USER, ATTRIBUTE->>'$.router_id' FROM INFORMATION_SCHEMA.USER_ATTRIBUTES WHERE ATTRIBUTE->'$.created_by_user'='{relation_user}' AND ATTRIBUTE->'$.created_by_juju_unit'='{mysql_router_unit_name}'\")",
"print(result.fetch_all())",
]
try:
output = self._run_mysqlsh_script("\n".join(command))
except MySQLClientError as e:
logger.exception(
f"Failed to get MySQL Router users for relation {relation_id} and unit {mysql_router_unit_name}"
)
raise MySQLGetRouterUsersError(e.message)
rows = json.loads(output)
return [RouterUser(username=row[0], router_id=row[1]) for row in rows]

def delete_users_for_unit(self, unit_name: str) -> None:
"""Delete users for a unit.

Expand All @@ -550,7 +594,7 @@ def delete_users_for_unit(self, unit_name: str) -> None:
try:
self._run_mysqlsh_script("\n".join(drop_users_command))
except MySQLClientError as e:
logger.exception(f"Failed to query and delete users for unit {unit_name}", exc_info=e)
logger.exception(f"Failed to query and delete users for unit {unit_name}")
raise MySQLDeleteUsersForUnitError(e.message)

def delete_users_for_relation(self, relation_id: int) -> None:
Expand Down Expand Up @@ -578,9 +622,40 @@ def delete_users_for_relation(self, relation_id: int) -> None:
try:
self._run_mysqlsh_script("\n".join(drop_users_command))
except MySQLClientError as e:
logger.exception(f"Failed to delete users for relation {relation_id}", exc_info=e)
logger.exception(f"Failed to delete users for relation {relation_id}")
raise MySQLDeleteUsersForRelationError(e.message)

def delete_user(self, username: str) -> None:
"""Delete user."""
primary_address = self.get_cluster_primary_address()
if not primary_address:
raise MySQLDeleteUserError("Unable to query cluster primary address")
drop_user_command = [
f"shell.connect('{self.server_config_user}:{self.server_config_password}@{primary_address}')",
f"session.run_sql(\"DROP USER `{username}`@'%'\")",
]
try:
self._run_mysqlsh_script("\n".join(drop_user_command))
except MySQLClientError as e:
logger.exception(f"Failed to delete user {username}")
raise MySQLDeleteUserError(e.message)

def remove_router_from_cluster_metadata(self, router_id: str) -> None:
"""Remove MySQL Router from InnoDB Cluster metadata."""
primary_address = self.get_cluster_primary_address()
if not primary_address:
raise MySQLRemoveRouterFromMetadataError("Unable to query cluster primary address")
command = [
f"shell.connect('{self.cluster_admin_user}:{self.cluster_admin_password}@{primary_address}')",
"cluster = dba.get_cluster()",
f'cluster.remove_router_metadata("{router_id}")',
]
try:
self._run_mysqlsh_script("\n".join(command))
except MySQLClientError as e:
logger.exception(f"Failed to remove router from metadata with ID {router_id}")
raise MySQLRemoveRouterFromMetadataError(e.message)

def configure_instance(self, create_cluster_admin: bool = True) -> None:
"""Configure the instance to be used in an InnoDB cluster.

Expand Down
40 changes: 39 additions & 1 deletion src/relations/mysql_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
from charms.mysql.v0.mysql import (
MySQLClientError,
MySQLCreateApplicationDatabaseAndScopedUserError,
MySQLDeleteUserError,
MySQLDeleteUsersForRelationError,
MySQLGetClusterEndpointsError,
MySQLGetClusterMembersAddressesError,
MySQLGetMySQLVersionError,
MySQLGrantPrivilegesToUserError,
MySQLRemoveRouterFromMetadataError,
)
from ops.charm import RelationBrokenEvent, RelationDepartedEvent, RelationJoinedEvent
from ops.framework import Object
Expand All @@ -42,6 +44,10 @@ def __init__(self, charm):
self.framework.observe(
self.charm.on[DB_RELATION_NAME].relation_broken, self._on_database_broken
)
self.framework.observe(
self.charm.on[DB_RELATION_NAME].relation_departed,
self._on_database_provides_relation_departed,
)
self.framework.observe(self.charm.on[PEER].relation_joined, self._on_relation_joined)
self.framework.observe(self.charm.on[PEER].relation_departed, self._on_relation_departed)

Expand Down Expand Up @@ -237,7 +243,9 @@ def _on_database_requested(self, event: DatabaseRequestedEvent):
def _on_database_broken(self, event: RelationBrokenEvent) -> None:
"""Handle the removal of database relation.

Remove user, keeping database intact.
Remove users, keeping database intact.

Includes users created by MySQL Router for MySQL Router <-> application relation
"""
if not self.charm.unit.is_leader():
# run once by the leader
Expand All @@ -255,4 +263,34 @@ def _on_database_broken(self, event: RelationBrokenEvent) -> None:
logger.info(f"Removed user for relation {relation_id}")
except (MySQLDeleteUsersForRelationError, KeyError):
logger.error(f"Failed to delete user for relation {relation_id}")

def _on_database_provides_relation_departed(self, event: RelationDepartedEvent) -> None:
"""Remove MySQL Router cluster metadata & router user for departing unit."""
if not self.charm.unit.is_leader():
return
if event.departing_unit.app.name == self.charm.app.name:
return

users = self.charm._mysql.get_mysql_router_users_for_unit(
relation_id=event.relation.id, mysql_router_unit_name=event.departing_unit.name
)
if not users:
return

if len(users) > 1:
shayancanonical marked this conversation as resolved.
Show resolved Hide resolved
logger.error(
f"More than one router user for departing unit {event.departing_unit.name}"
)
return

user = users[0]
try:
self.charm._mysql.delete_user(user.username)
logger.info(f"Deleted router user {user.username}")
except MySQLDeleteUserError:
logger.error(f"Failed to delete user {user.username}")
try:
self.charm._mysql.remove_router_from_cluster_metadata(user.router_id)
logger.info(f"Removed router from metadata {user.router_id}")
except MySQLRemoveRouterFromMetadataError:
logger.error(f"Failed to remove router from metadata with ID {user.router_id}")
25 changes: 0 additions & 25 deletions tests/unit/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import unittest
from unittest.mock import patch

from charms.mysql.v0.mysql import MySQLDeleteUsersForRelationError
from ops.testing import Harness

from charm import MySQLOperatorCharm
Expand Down Expand Up @@ -83,27 +82,3 @@ def test_database_requested(
_create_application_database_and_scoped_user.assert_called_once()
_get_cluster_endpoints.assert_called_once()
_get_mysql_version.assert_called_once()

@patch_network_get(private_address="1.1.1.1")
@patch("mysql_vm_helpers.MySQL.delete_users_for_relation")
def test_database_broken(self, _delete_users_for_relation):
# run start-up events to enable usage of the helper class
self.harness.set_leader(True)
self.charm.on.config_changed.emit()

self.harness.remove_relation(self.database_relation_id)

_delete_users_for_relation.assert_called_once_with(self.database_relation_id)

@patch_network_get(private_address="1.1.1.1")
@patch("mysql_vm_helpers.MySQL.delete_users_for_relation")
def test_database_broken_failure(self, _delete_users_for_relation):
# run start-up events to enable usage of the helper class
self.harness.set_leader(True)
self.charm.on.config_changed.emit()

_delete_users_for_relation.side_effect = MySQLDeleteUsersForRelationError()

self.harness.remove_relation(self.database_relation_id)

_delete_users_for_relation.assert_called_once()