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

methods used on in-place upgrades #288

Merged
merged 3 commits into from
Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
112 changes: 105 additions & 7 deletions lib/charms/mysql/v0/mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ def wait_until_mysql_connection(self) -> None:
class Error(Exception):
paulomach marked this conversation as resolved.
Show resolved Hide resolved
"""Base class for exceptions in this module."""

def __init__(self, message: Optional[str] = None) -> None:
paulomach marked this conversation as resolved.
Show resolved Hide resolved
"""Initialize the Error class.

Args:
message: Optional message to pass to the exception.
"""
super().__init__(message)
self.message = message or ""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you could use str(exception) to avoid overriding the __init__

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I understood it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that some Exceptions don't have .message, but you can use str(message) to get the message (instead of args[0])

e.g.

@property
def message(self) -> str:
    return str(self)


def __repr__(self):
"""String representation of the Error class."""
return "<{}.{} {}>".format(type(self).__module__, type(self).__name__, self.args)
Expand All @@ -132,11 +141,6 @@ def name(self):
"""Return a string representation of the model plus class."""
return "<{}.{}>".format(type(self).__module__, type(self).__name__)

@property
def message(self):
"""Return the message passed as an argument."""
return self.args[0]


class MySQLConfigureMySQLUsersError(Error):
"""Exception raised when creating a user fails."""
Expand Down Expand Up @@ -227,6 +231,10 @@ class MySQLGetClusterPrimaryAddressError(Error):
"""Exception raised when there is an issue getting the primary instance."""


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."""

Expand Down Expand Up @@ -326,6 +334,14 @@ class MySQLRescanClusterError(Error):
"""Exception raised when there is an issue rescanning the cluster."""


class MySQLSetVariableError(Error):
"""Exception raised when there is an issue setting a variable."""


class MySQLServerUpgradableError(Error):
"""Exception raised when there is an issue checking for upgradeability."""


class MySQLSecretError(Error):
"""Exception raised when there is an issue setting/getting a secret."""

Expand Down Expand Up @@ -452,6 +468,11 @@ def unit_peer_data(self) -> Dict:

return self.peers.data[self.unit]

@property
def unit_label(self):
"""Return unit label."""
return self.unit.name.replace("/", "-")

@property
def _is_peer_data_set(self):
return bool(
Expand Down Expand Up @@ -975,6 +996,32 @@ def remove_router_from_cluster_metadata(self, router_id: str) -> None:
logger.exception(f"Failed to remove router from metadata with ID {router_id}")
raise MySQLRemoveRouterFromMetadataError(e.message)

def set_dynamic_variable(
self, variable: str, value: str, persist: bool = False, instance: Optional[str] = None
) -> None:
"""Set a dynamic variable value for the instance.
paulomach marked this conversation as resolved.
Show resolved Hide resolved

Args:
variable: The name of the variable to set
value: The value to set the variable to
persist: Whether to persist the variable value across restarts
instance: instance address to run on, default to current

Raises:
MySQLSetVariableError
"""
logger.debug(f"Setting {variable} to {value} on {instance or self.instance_address}")
set_var_command = [
f"shell.connect('{self.server_config_user}:{self.server_config_password}@{instance or self.instance_address}')",
paulomach marked this conversation as resolved.
Show resolved Hide resolved
f"session.run_sql(\"SET {'PERSIST' if persist else 'GLOBAL'} {variable}={value}\")",
]

try:
self._run_mysqlsh_script("\n".join(set_var_command))
except MySQLClientError:
logger.exception(f"Failed to set variable {variable} to {value}")
raise MySQLSetVariableError

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

Expand Down Expand Up @@ -1276,7 +1323,7 @@ def is_instance_in_cluster(self, unit_label: str) -> bool:
)
return False

def get_cluster_status(self) -> Optional[dict]:
def get_cluster_status(self, extended: Optional[bool] = False) -> Optional[dict]:
"""Get the cluster status.

Executes script to retrieve cluster status.
Expand All @@ -1286,10 +1333,11 @@ def get_cluster_status(self) -> Optional[dict]:
Cluster status as a dictionary,
or None if running the status script fails.
"""
options = {"extended": extended}
status_commands = (
f"shell.connect('{self.cluster_admin_user}:{self.cluster_admin_password}@{self.instance_address}')",
f"cluster = dba.get_cluster('{self.cluster_name}')",
"print(cluster.status())",
f"print(cluster.status({options}))",
)

try:
Expand Down Expand Up @@ -1579,6 +1627,34 @@ def get_cluster_primary_address(

return matches.group(1)

def get_primary_label(self) -> Optional[str]:
"""Get the label of the cluster's primary."""
status = self.get_cluster_status()
if not status:
return None
for label, value in status["defaultreplicaset"]["topology"].items():
if value["memberrole"] == "primary":
return label

def set_cluster_primary(self, primary_address: str) -> None:
"""Set the cluster primary.

Args:
primary_address: The address of the cluster's primary
paulomach marked this conversation as resolved.
Show resolved Hide resolved
"""
logger.debug(f"Setting cluster primary to {primary_address}")

set_cluster_primary_commands = (
f"shell.connect_to_primary('{self.server_config_user}:{self.server_config_password}@{self.instance_address}')",
f"cluster = dba.get_cluster('{self.cluster_name}')",
f"cluster.set_primary_instance('{primary_address}')",
)
try:
self._run_mysqlsh_script("\n".join(set_cluster_primary_commands))
except MySQLClientError as e:
logger.exception("Failed to set cluster primary")
raise MySQLSetClusterPrimaryError(e.message)

def get_cluster_members_addresses(self) -> Optional[Iterable[str]]:
"""Get the addresses of the cluster's members.

Expand All @@ -1605,6 +1681,28 @@ def get_cluster_members_addresses(self) -> Optional[Iterable[str]]:

return set(matches.group(1).split(","))

def is_server_upgradable(self, instance: Optional[str] = None) -> None:
"""Wrapper for API check_for_server_upgrade."""
check_command = [
f"shell.connect_to_primary('{self.server_config_user}"
f":{self.server_config_password}@{instance or self.instance_address}')",
"try:",
" util.check_for_server_upgrade(options={'outputFormat': 'JSON'})",
"except ValueError:", # ValueError is raised for same version check
" print('SAME_VERSION')",
]

try:
output = self._run_mysqlsh_script("\n".join(check_command))
if "SAME_VERSION" in output:
return
result = json.loads(output)
if result["errorCount"] == 0:
return
paulomach marked this conversation as resolved.
Show resolved Hide resolved
raise MySQLServerUpgradableError(result.get("summary"))
except MySQLClientError:
raise MySQLServerUpgradableError("Failed to check for server upgrade")

def get_mysql_version(self) -> Optional[str]:
"""Get the MySQL version.

Expand Down
12 changes: 4 additions & 8 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,15 +257,13 @@ def _on_database_storage_detaching(self, _) -> None:
if not self.unit_peer_data.get("unit-initialized"):
return

unit_label = self.unit.name.replace("/", "-")

# No need to remove the instance from the cluster if it is not a member of the cluster
if not self._mysql.is_instance_in_cluster(unit_label):
if not self._mysql.is_instance_in_cluster(self.unit_label):
return

# The following operation uses locks to ensure that only one instance is removed
# from the cluster at a time (to avoid split-brain or lack of majority issues)
self._mysql.remove_instance(unit_label)
self._mysql.remove_instance(self.unit_label)

# Inform other hooks of current status
self.unit_peer_data["unit-status"] = "removing"
Expand Down Expand Up @@ -480,8 +478,7 @@ def _create_cluster(self) -> None:

Create a cluster from the current unit and initialise operations database.
"""
unit_label = self.unit.name.replace("/", "-")
self._mysql.create_cluster(unit_label)
self._mysql.create_cluster(self.unit_label)
self._mysql.initialize_juju_units_operations_table()

self.app_peer_data["units-added-to-cluster"] = "1"
Expand Down Expand Up @@ -550,9 +547,8 @@ def _workload_reset(self) -> StatusBase:
self._mysql.rescan_cluster(from_instance=primary_address, remove_instances=True)
# Re-add the member as if it's the first time

unit_label = self.unit.name.replace("/", "-")
self._mysql.add_instance_to_cluster(
self._get_unit_ip(self.unit), unit_label, from_instance=primary_address
self._get_unit_ip(self.unit), self.unit_label, from_instance=primary_address
)
except MySQLReconfigureError:
return MaintenanceStatus("Failed to re-initialize MySQL data-dir")
Expand Down
Loading