Skip to content

Commit

Permalink
Cleanup router metadata (#224)
Browse files Browse the repository at this point in the history
  • Loading branch information
carlcsaposs-canonical authored May 18, 2023
1 parent 791c319 commit c2e396f
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 32 deletions.
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
44 changes: 41 additions & 3 deletions src/relations/mysql_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
)
from charms.mysql.v0.mysql import (
MySQLCreateApplicationDatabaseAndScopedUserError,
MySQLDeleteUserError,
MySQLDeleteUsersForRelationError,
MySQLGetClusterEndpointsError,
MySQLGetMySQLVersionError,
MySQLGrantPrivilegesToUserError,
MySQLRemoveRouterFromMetadataError,
)
from ops.charm import (
PebbleReadyEvent,
Expand Down Expand Up @@ -54,6 +56,10 @@ def __init__(self, charm) -> None:
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_departed, self._on_peer_relation_departed
Expand Down Expand Up @@ -296,7 +302,9 @@ def _on_update_status(self, _) -> None:
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 @@ -309,7 +317,37 @@ def _on_database_broken(self, event: RelationBrokenEvent) -> None:
relation_id = event.relation.id
try:
self.charm._mysql.delete_users_for_relation(relation_id)
logger.info(f"Removed user for relation {relation_id}")
logger.info(f"Removed user(s) for relation {relation_id}")
except MySQLDeleteUsersForRelationError:
logger.error(f"Failed to delete user for relation {relation_id}")
logger.error(f"Failed to delete user(s) 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:
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}")
26 changes: 0 additions & 26 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 @@ -112,28 +111,3 @@ def test_database_requested(
_create_endpoint_services.assert_called_once()
_update_endpoints.assert_called_once()
_wait_service_ready.assert_called_once()

@patch("k8s_helpers.KubernetesHelpers.delete_endpoint_services")
@patch("mysql_k8s_helpers.MySQL.delete_users_for_relation")
def test_database_broken(self, _delete_users_for_relation, _delete_endpoint_services):
# 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)
_delete_endpoint_services.assert_called_once()

@patch("k8s_helpers.KubernetesHelpers.delete_endpoint_services")
@patch("mysql_k8s_helpers.MySQL.delete_users_for_relation")
def test_database_broken_failure(self, _delete_users_for_relation, _delete_endpoint_services):
# 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()

0 comments on commit c2e396f

Please sign in to comment.