diff --git a/config.yaml b/config.yaml index 1c4dc3692..a92da4c89 100644 --- a/config.yaml +++ b/config.yaml @@ -6,12 +6,18 @@ options: description: "Optional - Name of the MySQL InnoDB cluster" type: "string" profile: - description: | + description: | profile representing the scope of deployment, and used to be able to enable high-level high-level customisation of sysconfigs, resource checks/allocation, warning levels, etc. Allowed values are: “production” and “testing”. - type: string - default: production + type: string + default: production + profile-limit-memory: + type: int + description: | + Amount of memory in Megabytes to limit MySQL and associated process to. + If unset, this will be decided according to the default memory limit in the selected profile. + Only comes into effect when the `production` profile is selected. # Config options for the legacy 'mysql' interface mysql-interface-user: description: "The database username for the legacy 'mysql' interface" diff --git a/lib/charms/data_platform_libs/v0/data_models.py b/lib/charms/data_platform_libs/v0/data_models.py new file mode 100644 index 000000000..a1dbb8299 --- /dev/null +++ b/lib/charms/data_platform_libs/v0/data_models.py @@ -0,0 +1,354 @@ +# Copyright 2023 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +r"""Library to provide simple API for promoting typed, validated and structured dataclass in charms. + +Dict-like data structure are often used in charms. They are used for config, action parameters +and databag. This library aims at providing simple API for using pydantic BaseModel-derived class +in charms, in order to enhance: +* Validation, by embedding custom business logic to validate single parameters or even have + validators that acts across different fields +* Parsing, by loading data into pydantic object we can both allow for other types (e.g. float) to + be used in configuration/parameters as well as specify even nested complex objects for databags +* Static typing checks, by moving from dict-like object to classes with typed-annotated properties, + that can be statically checked using mypy to ensure that the code is correct. + +Pydantic models can be used on: + +* Charm Configuration (as defined in config.yaml) +* Actions parameters (as defined in actions.yaml) +* Application/Unit Databag Information (thus making it more structured and encoded) + + +## Creating models + +Any data-structure can be modeled using dataclasses instead of dict-like objects (e.g. storing +config, action parameters and databags). Within pydantic, we can define dataclasses that provides +also parsing and validation on standard dataclass implementation: + +```python + +from charms.data_platform_libs.v0.data_models import BaseConfigModel + +class MyConfig(BaseConfigModel): + + my_key: int + + @validator("my_key") + def is_lower_than_100(cls, v: int): + if v > 100: + raise ValueError("Too high") + +``` + +This should allow to collapse both parsing and validation as the dataclass object is parsed and +created: + +```python +dataclass = MyConfig(my_key="1") + +dataclass.my_key # this returns 1 (int) +dataclass["my_key"] # this returns 1 (int) + +dataclass = MyConfig(my_key="102") # this returns a ValueError("Too High") +``` + +## Charm Configuration Model + +Using the class above, we can implement parsing and validation of configuration by simply +extending our charms using the `TypedCharmBase` class, as shown below. + +```python +class MyCharm(TypedCharmBase[MyConfig]): + config_type = MyConfig + + # everywhere in the code you will have config property already parsed and validate + def my_method(self): + self.config: MyConfig +``` + +## Action parameters + +In order to parse action parameters, we can use a decorator to be applied to action event +callbacks, as shown below. + +```python +@validate_params(PullActionModel) +def _pull_site_action( + self, event: ActionEvent, + params: Optional[Union[PullActionModel, ValidationError]] = None +): + if isinstance(params, ValidationError): + # handle errors + else: + # do stuff +``` + +Note that this changes the signature of the callbacks by adding an extra parameter with the parsed +counterpart of the `event.params` dict-like field. If validation fails, we return (not throw!) the +exception, to be handled (or raised) in the callback. + +## Databag + +In order to parse databag fields, we define a decorator to be applied to base relation event +callbacks. + +```python +@parse_relation_data(app_model=AppDataModel, unit_model=UnitDataModel) +def _on_cluster_relation_joined( + self, event: RelationEvent, + app_data: Optional[Union[AppDataModel, ValidationError]] = None, + unit_data: Optional[Union[UnitDataModel, ValidationError]] = None +) -> None: + ... +``` + +The parameters `app_data` and `unit_data` refers to the databag of the entity which fired the +RelationEvent. + +When we want to access to a relation databag outsides of an action, it can be useful also to +compact multiple databags into a single object (if there are no conflicting fields), e.g. + +```python + +class ProviderDataBag(BaseClass): + provider_key: str + +class RequirerDataBag(BaseClass): + requirer_key: str + +class MergedDataBag(ProviderDataBag, RequirerDataBag): + pass + +merged_data = get_relation_data_as( + MergedDataBag, relation.data[self.app], relation.data[relation.app] +) + +merged_data.requirer_key +merged_data.provider_key + +``` + +The above code can be generalized to other kinds of merged objects, e.g. application and unit, and +it can be extended to multiple sources beyond 2: + +```python +merged_data = get_relation_data_as( + MergedDataBag, relation.data[self.app], relation.data[relation.app], ... +) +``` + +""" + +import json +from functools import reduce, wraps +from typing import Callable, Generic, MutableMapping, Optional, Type, TypeVar, Union + +import pydantic +from ops.charm import ActionEvent, CharmBase, RelationEvent +from ops.model import RelationDataContent +from pydantic import BaseModel, ValidationError + +# The unique Charmhub library identifier, never change it +LIBID = "cb2094c5b07d47e1bf346aaee0fcfcfe" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 4 + +PYDEPS = ["ops>=2.0.0", "pydantic>=1.10,<2"] + +G = TypeVar("G") +T = TypeVar("T", bound=BaseModel) +AppModel = TypeVar("AppModel", bound=BaseModel) +UnitModel = TypeVar("UnitModel", bound=BaseModel) + +DataBagNativeTypes = (int, str, float) + + +class BaseConfigModel(BaseModel): + """Class to be used for defining the structured configuration options.""" + + def __getitem__(self, x): + """Return the item using the notation instance[key].""" + return getattr(self, x.replace("-", "_")) + + +class TypedCharmBase(CharmBase, Generic[T]): + """Class to be used for extending config-typed charms.""" + + config_type: Type[T] + + @property + def config(self) -> T: + """Return a config instance validated and parsed using the provided pydantic class.""" + translated_keys = {k.replace("-", "_"): v for k, v in self.model.config.items()} + return self.config_type(**translated_keys) + + +def validate_params(cls: Type[T]): + """Return a decorator to allow pydantic parsing of action parameters. + + Args: + cls: Pydantic class representing the model to be used for parsing the content of the + action parameter + """ + + def decorator( + f: Callable[[CharmBase, ActionEvent, Union[T, ValidationError]], G] + ) -> Callable[[CharmBase, ActionEvent], G]: + @wraps(f) + def event_wrapper(self: CharmBase, event: ActionEvent): + try: + params = cls( + **{key.replace("-", "_"): value for key, value in event.params.items()} + ) + except ValidationError as e: + params = e + return f(self, event, params) + + return event_wrapper + + return decorator + + +def write(relation_data: RelationDataContent, model: BaseModel): + """Write the data contained in a domain object to the relation databag. + + Args: + relation_data: pointer to the relation databag + model: instance of pydantic model to be written + """ + for key, value in model.dict(exclude_none=False).items(): + if value: + relation_data[key.replace("_", "-")] = ( + str(value) + if any(isinstance(value, _type) for _type in DataBagNativeTypes) + else json.dumps(value) + ) + else: + relation_data[key.replace("_", "-")] = "" + + +def read(relation_data: MutableMapping[str, str], obj: Type[T]) -> T: + """Read data from a relation databag and parse it into a domain object. + + Args: + relation_data: pointer to the relation databag + obj: pydantic class representing the model to be used for parsing + """ + return obj( + **{ + field_name: ( + relation_data[parsed_key] + if field.outer_type_ in DataBagNativeTypes + else json.loads(relation_data[parsed_key]) + ) + for field_name, field in obj.__fields__.items() + # pyright: ignore[reportGeneralTypeIssues] + if (parsed_key := field_name.replace("_", "-")) in relation_data + if relation_data[parsed_key] + } + ) + + +def parse_relation_data( + app_model: Optional[Type[AppModel]] = None, unit_model: Optional[Type[UnitModel]] = None +): + """Return a decorator to allow pydantic parsing of the app and unit databags. + + Args: + app_model: Pydantic class representing the model to be used for parsing the content of the + app databag. None if no parsing ought to be done. + unit_model: Pydantic class representing the model to be used for parsing the content of the + unit databag. None if no parsing ought to be done. + """ + + def decorator( + f: Callable[ + [ + CharmBase, + RelationEvent, + Optional[Union[AppModel, ValidationError]], + Optional[Union[UnitModel, ValidationError]], + ], + G, + ] + ) -> Callable[[CharmBase, RelationEvent], G]: + @wraps(f) + def event_wrapper(self: CharmBase, event: RelationEvent): + try: + app_data = ( + read(event.relation.data[event.app], app_model) + if app_model is not None and event.app + else None + ) + except pydantic.ValidationError as e: + app_data = e + + try: + unit_data = ( + read(event.relation.data[event.unit], unit_model) + if unit_model is not None and event.unit + else None + ) + except pydantic.ValidationError as e: + unit_data = e + + return f(self, event, app_data, unit_data) + + return event_wrapper + + return decorator + + +class RelationDataModel(BaseModel): + """Base class to be used for creating data models to be used for relation databags.""" + + def write(self, relation_data: RelationDataContent): + """Write data to a relation databag. + + Args: + relation_data: pointer to the relation databag + """ + return write(relation_data, self) + + @classmethod + def read(cls, relation_data: RelationDataContent) -> "RelationDataModel": + """Read data from a relation databag and parse it as an instance of the pydantic class. + + Args: + relation_data: pointer to the relation databag + """ + return read(relation_data, cls) + + +def get_relation_data_as( + model_type: Type[AppModel], + *relation_data: RelationDataContent, +) -> Union[AppModel, ValidationError]: + """Return a merged representation of the provider and requirer databag into a single object. + + Args: + model_type: pydantic class representing the merged databag + relation_data: list of RelationDataContent of provider/requirer/unit sides + """ + try: + app_data = read(reduce(lambda x, y: dict(x) | dict(y), relation_data, {}), model_type) + except pydantic.ValidationError as e: + app_data = e + return app_data diff --git a/lib/charms/mysql/v0/mysql.py b/lib/charms/mysql/v0/mysql.py index 880ca417c..0e7b638ff 100644 --- a/lib/charms/mysql/v0/mysql.py +++ b/lib/charms/mysql/v0/mysql.py @@ -116,15 +116,17 @@ 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 = 50 +LIBPATCH = 51 UNIT_TEARDOWN_LOCKNAME = "unit-teardown" UNIT_ADD_LOCKNAME = "unit-add" BYTES_1GiB = 1073741824 # 1 gibibyte BYTES_1GB = 1000000000 # 1 gigabyte +BYTES_1MB = 1000000 # 1 megabyte BYTES_1MiB = 1048576 # 1 mebibyte RECOVERY_CHECK_TIME = 10 # seconds +GET_MEMBER_STATE_TIME = 10 # seconds class Error(Exception): @@ -560,7 +562,7 @@ def _get_secret_from_juju(self, scope: str, key: str) -> Optional[str]: secret = self.model.get_secret(id=secret_id) content = secret.get_content() self.app_secrets = content - logger.debug(f"Retrieved secert {key} for app from juju") + logger.debug(f"Retrieved secret {key} for app from juju") return self.app_secrets.get(key) @@ -756,19 +758,21 @@ def __init__( self.backups_user = backups_user self.backups_password = backups_password - def render_myqld_configuration( + def render_mysqld_configuration( self, *, profile: str, + memory_limit: Optional[int] = None, snap_common: str = "", - ) -> str: + ) -> tuple[str, dict]: """Render mysqld ini configuration file. Args: profile: profile to use for the configuration (testing, production) + memory_limit: memory limit to use for the configuration in bytes snap_common: snap common directory (for log files locations in vm) - Returns: mysqld ini file string content + Returns: a tuple with mysqld ini file string content and a the config dict """ performance_schema_instrument = "" if profile == "testing": @@ -779,6 +783,10 @@ def render_myqld_configuration( performance_schema_instrument = "'memory/%=OFF'" else: available_memory = self.get_available_memory() + if memory_limit: + # when memory limit is set, we need to use the minimum + # between the available memory and the limit + available_memory = min(available_memory, memory_limit) ( innodb_buffer_pool_size, innodb_buffer_pool_chunk_size, @@ -817,7 +825,7 @@ def render_myqld_configuration( with io.StringIO() as string_io: config.write(string_io) - return string_io.getvalue() + return string_io.getvalue(), dict(config["mysqld"]) def configure_mysql_users(self): """Configure the MySQL users for the instance. @@ -1141,6 +1149,11 @@ def set_dynamic_variable( """ if not instance_address: instance_address = self.instance_address + + # escape variable values when needed + if not re.match(r"^[0-9,a-z,A-Z$_]+$", value): + value = f"`{value}`" + logger.debug(f"Setting {variable} to {value} on {instance_address}") set_var_command = [ f"shell.connect('{self.server_config_user}:{self.server_config_password}@{instance_address}')", @@ -1771,6 +1784,15 @@ def get_primary_label(self) -> Optional[str]: if value["memberrole"] == "primary": return label + def is_unit_primary(self, unit_label: str) -> bool: + """Test if a given unit is the cluster primary. + + Args: + unit_label: The label of the unit to test + """ + primary_label = self.get_primary_label() + return primary_label == unit_label + def set_cluster_primary(self, new_primary_address: str) -> None: """Set the cluster primary. @@ -1934,7 +1956,7 @@ def update_user_password(self, username: str, new_password: str, host: str = "%" ) raise MySQLCheckUserExistenceError(e.message) - @retry(reraise=True, stop=stop_after_attempt(3), wait=wait_fixed(10)) + @retry(reraise=True, stop=stop_after_attempt(3), wait=wait_fixed(GET_MEMBER_STATE_TIME)) def get_member_state(self) -> Tuple[str, str]: """Get member status in cluster. diff --git a/lib/charms/rolling_ops/v0/rollingops.py b/lib/charms/rolling_ops/v0/rollingops.py new file mode 100644 index 000000000..7f4bd1b89 --- /dev/null +++ b/lib/charms/rolling_ops/v0/rollingops.py @@ -0,0 +1,390 @@ +# Copyright 2022 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This library enables "rolling" operations across units of a charmed Application. + +For example, a charm author might use this library to implement a "rolling restart", in +which all units in an application restart their workload, but no two units execute the +restart at the same time. + +To implement the rolling restart, a charm author would do the following: + +1. Add a peer relation called 'restart' to a charm's `metadata.yaml`: +```yaml +peers: + restart: + interface: rolling_op +``` + +Import this library into src/charm.py, and initialize a RollingOpsManager in the Charm's +`__init__`. The Charm should also define a callback routine, which will be executed when +a unit holds the distributed lock: + +src/charm.py +```python +# ... +from charms.rolling_ops.v0.rollingops import RollingOpsManager +# ... +class SomeCharm(...): + def __init__(...) + # ... + self.restart_manager = RollingOpsManager( + charm=self, relation="restart", callback=self._restart + ) + # ... + def _restart(self, event): + systemd.service_restart('foo') +``` + +To kick off the rolling restart, emit this library's AcquireLock event. The simplest way +to do so would be with an action, though it might make sense to acquire the lock in +response to another event. + +```python + def _on_trigger_restart(self, event): + self.charm.on[self.restart_manager.name].acquire_lock.emit() +``` + +In order to trigger the restart, a human operator would execute the following command on +the CLI: + +``` +juju run-action some-charm/0 some-charm/1 <... some-charm/n> restart +``` + +Note that all units that plan to restart must receive the action and emit the aquire +event. Any units that do not run their acquire handler will be left out of the rolling +restart. (An operator might take advantage of this fact to recover from a failed rolling +operation without restarting workloads that were able to successfully restart -- simply +omit the successful units from a subsequent run-action call.) + +""" +import logging +from enum import Enum +from typing import AnyStr, Callable + +from ops.charm import ActionEvent, CharmBase, RelationChangedEvent +from ops.framework import EventBase, Object +from ops.model import ActiveStatus, MaintenanceStatus, WaitingStatus + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "20b7777f58fe421e9a223aefc2b4d3a4" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 2 + + +class LockNoRelationError(Exception): + """Raised if we are trying to process a lock, but do not appear to have a relation yet.""" + + pass + + +class LockState(Enum): + """Possible states for our Distributed lock. + + Note that there are two states set on the unit, and two on the application. + + """ + + ACQUIRE = "acquire" + RELEASE = "release" + GRANTED = "granted" + IDLE = "idle" + + +class Lock: + """A class that keeps track of a single asynchronous lock. + + Warning: a Lock has permission to update relation data, which means that there are + side effects to invoking the .acquire, .release and .grant methods. Running any one of + them will trigger a RelationChanged event, once per transition from one internal + status to another. + + This class tracks state across the cloud by implementing a peer relation + interface. There are two parts to the interface: + + 1) The data on a unit's peer relation (defined in metadata.yaml.) Each unit can update + this data. The only meaningful values are "acquire", and "release", which represent + a request to acquire the lock, and a request to release the lock, respectively. + + 2) The application data in the relation. This tracks whether the lock has been + "granted", Or has been released (and reverted to idle). There are two valid states: + "granted" or None. If a lock is in the "granted" state, a unit should emit a + RunWithLocks event and then release the lock. + + If a lock is in "None", this means that a unit has not yet requested the lock, or + that the request has been completed. + + In more detail, here is the relation structure: + + relation.data: + : + status: 'acquire|release' + : + : 'granted|None' + + Note that this class makes no attempts to timestamp the locks and thus handle multiple + requests in a row. If a unit re-requests a lock before being granted the lock, the + lock will simply stay in the "acquire" state. If a unit wishes to clear its lock, it + simply needs to call lock.release(). + + """ + + def __init__(self, manager, unit=None): + + self.relation = manager.model.relations[manager.name][0] + if not self.relation: + # TODO: defer caller in this case (probably just fired too soon). + raise LockNoRelationError() + + self.unit = unit or manager.model.unit + self.app = manager.model.app + + @property + def _state(self) -> LockState: + """Return an appropriate state. + + Note that the state exists in the unit's relation data, and the application + relation data, so we have to be careful about what our states mean. + + Unit state can only be in "acquire", "release", "None" (None means unset) + Application state can only be in "granted" or "None" (None means unset or released) + + """ + unit_state = LockState(self.relation.data[self.unit].get("state", LockState.IDLE.value)) + app_state = LockState( + self.relation.data[self.app].get(str(self.unit), LockState.IDLE.value) + ) + + if app_state == LockState.GRANTED and unit_state == LockState.RELEASE: + # Active release request. + return LockState.RELEASE + + if app_state == LockState.IDLE and unit_state == LockState.ACQUIRE: + # Active acquire request. + return LockState.ACQUIRE + + return app_state # Granted or unset/released + + @_state.setter + def _state(self, state: LockState): + """Set the given state. + + Since we update the relation data, this may fire off a RelationChanged event. + """ + if state == LockState.ACQUIRE: + self.relation.data[self.unit].update({"state": state.value}) + + if state == LockState.RELEASE: + self.relation.data[self.unit].update({"state": state.value}) + + if state == LockState.GRANTED: + self.relation.data[self.app].update({str(self.unit): state.value}) + + if state is LockState.IDLE: + self.relation.data[self.app].update({str(self.unit): state.value}) + + def acquire(self): + """Request that a lock be acquired.""" + self._state = LockState.ACQUIRE + + def release(self): + """Request that a lock be released.""" + self._state = LockState.RELEASE + + def clear(self): + """Unset a lock.""" + self._state = LockState.IDLE + + def grant(self): + """Grant a lock to a unit.""" + self._state = LockState.GRANTED + + def is_held(self): + """This unit holds the lock.""" + return self._state == LockState.GRANTED + + def release_requested(self): + """A unit has reported that they are finished with the lock.""" + return self._state == LockState.RELEASE + + def is_pending(self): + """Is this unit waiting for a lock?""" + return self._state == LockState.ACQUIRE + + +class Locks: + """Generator that returns a list of locks.""" + + def __init__(self, manager): + self.manager = manager + + # Gather all the units. + relation = manager.model.relations[manager.name][0] + units = [unit for unit in relation.units] + + # Plus our unit ... + units.append(manager.model.unit) + + self.units = units + + def __iter__(self): + """Yields a lock for each unit we can find on the relation.""" + for unit in self.units: + yield Lock(self.manager, unit=unit) + + +class RunWithLock(EventBase): + """Event to signal that this unit should run the callback.""" + + pass + + +class AcquireLock(EventBase): + """Signals that this unit wants to acquire a lock.""" + + pass + + +class ProcessLocks(EventBase): + """Used to tell the leader to process all locks.""" + + pass + + +class RollingOpsManager(Object): + """Emitters and handlers for rolling ops.""" + + def __init__(self, charm: CharmBase, relation: AnyStr, callback: Callable): + """Register our custom events. + + params: + charm: the charm we are attaching this to. + relation: an identifier, by convention based on the name of the relation in the + metadata.yaml, which identifies this instance of RollingOperatorsFactory, + distinct from other instances that may be hanlding other events. + callback: a closure to run when we have a lock. (It must take a CharmBase object and + EventBase object as args.) + """ + # "Inherit" from the charm's class. This gives us access to the framework as + # self.framework, as well as the self.model shortcut. + super().__init__(charm, None) + + self.name = relation + self._callback = callback + self.charm = charm # Maintain a reference to charm, so we can emit events. + + charm.on.define_event("{}_run_with_lock".format(self.name), RunWithLock) + charm.on.define_event("{}_acquire_lock".format(self.name), AcquireLock) + charm.on.define_event("{}_process_locks".format(self.name), ProcessLocks) + + # Watch those events (plus the built in relation event). + self.framework.observe(charm.on[self.name].relation_changed, self._on_relation_changed) + self.framework.observe(charm.on[self.name].acquire_lock, self._on_acquire_lock) + self.framework.observe(charm.on[self.name].run_with_lock, self._on_run_with_lock) + self.framework.observe(charm.on[self.name].process_locks, self._on_process_locks) + + def _callback(self: CharmBase, event: EventBase) -> None: + """Placeholder for the function that actually runs our event. + + Usually overridden in the init. + """ + raise NotImplementedError + + def _on_relation_changed(self: CharmBase, event: RelationChangedEvent): + """Process relation changed. + + First, determine whether this unit has been granted a lock. If so, emit a RunWithLock + event. + + Then, if we are the leader, fire off a process locks event. + + """ + lock = Lock(self) + + if lock.is_pending(): + self.model.unit.status = WaitingStatus("Awaiting {} operation".format(self.name)) + + if lock.is_held(): + self.charm.on[self.name].run_with_lock.emit() + + if self.model.unit.is_leader(): + self.charm.on[self.name].process_locks.emit() + + def _on_process_locks(self: CharmBase, event: ProcessLocks): + """Process locks. + + Runs only on the leader. Updates the status of all locks. + + """ + if not self.model.unit.is_leader(): + return + + pending = [] + + for lock in Locks(self): + if lock.is_held(): + # One of our units has the lock -- return without further processing. + return + + if lock.release_requested(): + lock.clear() # Updates relation data + + if lock.is_pending(): + if lock.unit == self.model.unit: + # Always run on the leader last. + pending.insert(0, lock) + else: + pending.append(lock) + + # If we reach this point, and we have pending units, we want to grant a lock to + # one of them. + if pending: + self.model.app.status = MaintenanceStatus("Beginning rolling {}".format(self.name)) + lock = pending[-1] + lock.grant() + if lock.unit == self.model.unit: + # It's time for the leader to run with lock. + self.charm.on[self.name].run_with_lock.emit() + return + + self.model.app.status = ActiveStatus() + + def _on_acquire_lock(self: CharmBase, event: ActionEvent): + """Request a lock.""" + try: + Lock(self).acquire() # Updates relation data + # emit relation changed event in the edge case where aquire does not + relation = self.model.get_relation(self.name) + self.charm.on[self.name].relation_changed.emit(relation) + except LockNoRelationError: + logger.debug("No {} peer relation yet. Delaying rolling op.".format(self.name)) + event.defer() + + def _on_run_with_lock(self: CharmBase, event: RunWithLock): + lock = Lock(self) + self.model.unit.status = MaintenanceStatus("Executing {} operation".format(self.name)) + self._callback(event) + lock.release() # Updates relation data + if lock.unit == self.model.unit: + self.charm.on[self.name].process_locks.emit() + + self.model.unit.status = ActiveStatus() diff --git a/metadata.yaml b/metadata.yaml index ad23e85d2..9e1fa7da3 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -39,6 +39,8 @@ peers: interface: mysql_peers upgrade: interface: upgrade + restart: + interface: rolling_op provides: database: diff --git a/poetry.lock b/poetry.lock index 9d84a0d23..019c2c3c4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1022,16 +1022,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -1772,7 +1762,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1780,15 +1769,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1805,7 +1787,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1813,7 +1794,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2293,4 +2273,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "8e7da6daf0309f9e5595077043a858466b6c484df7cf3f9e13551a6ef69b6f37" +content-hash = "d7e0087e1e70645b18a7a86215177003dd670be6da9819a8b1fbd01fd24ff1d4" diff --git a/pyproject.toml b/pyproject.toml index 7d56fadef..56b48e55a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,9 @@ jinja2 = "^3.1.2" # data_platform_libs/v0/data_interfaces.py ops = ">=2.0.0" # data_platform_libs/v0/upgrade.py +# grafana_agent/v0/cos_agent.py requires pydantic <2 poetry-core = "*" -pydantic = "^1.10" +pydantic = "^1.10, <2" # tls_certificates_interface/v1/tls_certificates.py cryptography = "*" jsonschema = "*" diff --git a/src/charm.py b/src/charm.py index 16b5ac219..fa75a094f 100755 --- a/src/charm.py +++ b/src/charm.py @@ -9,11 +9,13 @@ from typing import Optional import ops +from charms.data_platform_libs.v0.data_models import TypedCharmBase from charms.data_platform_libs.v0.s3 import S3Requirer from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider from charms.loki_k8s.v0.loki_push_api import LogProxyConsumer from charms.mysql.v0.backups import MySQLBackups from charms.mysql.v0.mysql import ( + BYTES_1MB, MySQLAddInstanceToClusterError, MySQLCharmBase, MySQLConfigureInstanceError, @@ -29,7 +31,8 @@ ) from charms.mysql.v0.tls import MySQLTLS from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider -from ops import RelationBrokenEvent, RelationCreatedEvent +from charms.rolling_ops.v0.rollingops import RollingOpsManager +from ops import EventBase, RelationBrokenEvent, RelationCreatedEvent from ops.charm import RelationChangedEvent, UpdateStatusEvent from ops.main import main from ops.model import ( @@ -41,6 +44,7 @@ ) from ops.pebble import Layer +from config import CharmConfig, MySQLConfig from constants import ( BACKUPS_PASSWORD_KEY, BACKUPS_USERNAME, @@ -54,6 +58,7 @@ MYSQL_LOG_FILES, MYSQL_SYSTEM_GROUP, MYSQL_SYSTEM_USER, + MYSQLD_CONFIG_FILE, MYSQLD_EXPORTER_PORT, MYSQLD_EXPORTER_SERVICE, MYSQLD_SAFE_SERVICE, @@ -77,14 +82,15 @@ from relations.mysql_root import MySQLRootRelation from rotate_mysql_logs import RotateMySQLLogs, RotateMySQLLogsCharmEvents from upgrade import MySQLK8sUpgrade, get_mysql_k8s_dependencies_model -from utils import generate_random_hash, generate_random_password +from utils import compare_dictionaries, generate_random_hash, generate_random_password logger = logging.getLogger(__name__) -class MySQLOperatorCharm(MySQLCharmBase): +class MySQLOperatorCharm(MySQLCharmBase, TypedCharmBase[CharmConfig]): """Operator framework charm for MySQL.""" + config_type = CharmConfig # RotateMySQLLogsCharmEvents needs to be defined on the charm object for # the log rotate manager process (which runs juju-run/juju-exec to dispatch # a custom event) @@ -112,6 +118,7 @@ def __init__(self, *args): self.on[COS_AGENT_RELATION_NAME].relation_broken, self._reconcile_mysqld_exporter ) + self.mysql_config = MySQLConfig() self.k8s_helpers = KubernetesHelpers(self) self.mysql_relation = MySQLRelation(self) self.database_relation = MySQLProvider(self) @@ -137,6 +144,7 @@ def __init__(self, *args): relation_name="logging", container_name="mysql", ) + self.restart = RollingOpsManager(self, relation="restart", callback=self._restart) self.log_rotate_manager = LogRotateManager(self) self.log_rotate_manager.start_log_rotate_manager() @@ -206,6 +214,11 @@ def active_status_message(self) -> str: return "Primary" return "" + @property + def restart_peers(self) -> Optional[ops.model.Relation]: + """Retrieve the peer relation.""" + return self.model.get_relation("restart") + def get_unit_hostname(self, unit_name: Optional[str] = None) -> str: """Get the hostname.localdomain for a unit. @@ -341,6 +354,24 @@ def _reconcile_pebble_layer(self, container: Container) -> None: self._on_update_status(None) + def _restart(self, event: EventBase) -> None: + """Restart the service.""" + if self._mysql.is_unit_primary(self.unit_label): + restart_states = { + self.restart_peers.data[unit].get("state", "unset") for unit in self.peers.units + } + if restart_states != {"release"}: + # Wait other units restart first to minimize primary switchover + message = "Primary restart deferred after other units" + logger.info(message) + self.unit.status = WaitingStatus(message) + event.defer() + return + self.unit.status = MaintenanceStatus("restarting MySQL") + container = self.unit.get_container(CONTAINER_NAME) + if container.can_connect(): + container.restart(MYSQLD_SAFE_SERVICE) + # ========================================================================= # Charm event handlers # ========================================================================= @@ -367,18 +398,53 @@ def _on_peer_relation_joined(self, _) -> None: self.unit_peer_data.setdefault("member-role", "unknown") self.unit_peer_data.setdefault("member-state", "waiting") - def _on_config_changed(self, _) -> None: + def _on_config_changed(self, event: EventBase) -> None: """Handle the config changed event.""" - # Only execute on unit leader - if not self.unit.is_leader(): + if not self._is_peer_data_set: + # skip when not initialized return - # Create and set cluster and cluster-set names in the peer relation databag - common_hash = generate_random_hash() - self.app_peer_data.setdefault( - "cluster-name", self.config.get("cluster-name", f"cluster-{common_hash}") + if not self.upgrade.idle: + # skip when upgrade is in progress + # the upgrade already restart the daemon + return + + if not self._mysql.is_mysqld_running(): + # defer config-changed event until MySQL is running + logger.debug("Deferring config-changed event until MySQL is running") + event.defer() + return + + config_content = self._mysql.read_file_content(MYSQLD_CONFIG_FILE) + if not config_content: + # empty config means not initialized, skipping + return + + previous_config_dict = self.mysql_config.custom_config(config_content) + + # render the new config + memory_limit_bytes = (self.config.profile_limit_memory or 0) * BYTES_1MB + new_config_content, new_config_dict = self._mysql.render_mysqld_configuration( + profile=self.config.profile, + memory_limit=memory_limit_bytes, ) - self.app_peer_data.setdefault("cluster-set-domain-name", f"cluster-set-{common_hash}") + + changed_config = compare_dictionaries(previous_config_dict, new_config_dict) + + if self.mysql_config.keys_requires_restart(changed_config): + # there are static configurations in changed keys + logger.info("Configuration change requires restart") + + # persist config to file + self._mysql.write_content_to_file(path=MYSQLD_CONFIG_FILE, content=new_config_content) + self.on[f"{self.restart.name}"].acquire_lock.emit() + return + + if dynamic_config := self.mysql_config.filter_static_keys(changed_config): + # if only dynamic config changed, apply it + logger.info("Configuration does not requires restart") + for config in dynamic_config: + self._mysql.set_dynamic_variable(config, new_config_dict[config]) def _on_leader_elected(self, _) -> None: """Handle the leader elected event. @@ -401,6 +467,13 @@ def _on_leader_elected(self, _) -> None: "app", required_password, generate_random_password(PASSWORD_LENGTH) ) + # Create and set cluster and cluster-set names in the peer relation databag + common_hash = generate_random_hash() + self.app_peer_data.setdefault( + "cluster-name", self.config.cluster_name or f"cluster-{common_hash}" + ) + self.app_peer_data.setdefault("cluster-set-domain-name", f"cluster-set-{common_hash}") + def _open_ports(self) -> None: """Open ports if supported. @@ -490,7 +563,12 @@ def _on_mysql_pebble_ready(self, event) -> None: container = event.workload try: - self._mysql.write_mysqld_config(profile=self.config["profile"]) + memory_limit_bytes = (self.config.profile_limit_memory or 0) * BYTES_1MB + new_config_content, _ = self._mysql.render_mysqld_configuration( + profile=self.config.profile, + memory_limit=memory_limit_bytes, + ) + self._mysql.write_content_to_file(path=MYSQLD_CONFIG_FILE, content=new_config_content) except MySQLCreateCustomConfigFileError: logger.exception("Unable to write custom config file") raise @@ -630,6 +708,7 @@ def _on_update_status(self, _: Optional[UpdateStatusEvent]) -> None: if self._is_cluster_blocked(): return + del self.restart_peers.data[self.unit]["state"] container = self.unit.get_container(CONTAINER_NAME) if not container.can_connect(): diff --git a/src/config.py b/src/config.py new file mode 100644 index 000000000..5ddaa7e7f --- /dev/null +++ b/src/config.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Structured configuration for the MySQL charm.""" +import configparser +import logging +import re +from typing import Optional + +from charms.data_platform_libs.v0.data_models import BaseConfigModel +from pydantic import validator + +logger = logging.getLogger(__name__) + + +class MySQLConfig: + """Configuration.""" + + # Static config requires workload restart + static_config = { + "innodb_buffer_pool_size", + "innodb_buffer_pool_chunk_size", + "group_replication_message_cache_size", + "log_error", + } + + def keys_requires_restart(self, keys: set) -> bool: + """Check if keys require restart.""" + return bool(keys & self.static_config) + + def filter_static_keys(self, keys: set) -> set: + """Filter static keys.""" + return keys - self.static_config + + @staticmethod + def custom_config(config_content: str) -> dict: + """Convert config content to dict.""" + cp = configparser.ConfigParser(interpolation=None) + cp.read_string(config_content) + + return dict(cp["mysqld"]) + + +class CharmConfig(BaseConfigModel): + """Manager for the structured configuration.""" + + profile: str + cluster_name: Optional[str] + profile_limit_memory: Optional[int] + mysql_interface_user: Optional[str] + mysql_interface_database: Optional[str] + mysql_root_interface_user: Optional[str] + mysql_root_interface_database: Optional[str] + + @validator("profile") + @classmethod + def profile_values(cls, value: str) -> Optional[str]: + """Check profile config option is one of `testing` or `production`.""" + if value not in ["testing", "production"]: + raise ValueError("Value not one of 'testing' or 'production'") + + return value + + @validator("cluster_name") + @classmethod + def cluster_name_validator(cls, value: str) -> Optional[str]: + """Check for valid cluster name. + + Limited to 63 characters, and must start with a letter and + contain only alphanumeric characters, `-`, `_` and `.` + """ + if len(value) > 63: + raise ValueError("Cluster name must be less than 63 characters") + + if not value[0].isalpha(): + raise ValueError("Cluster name must start with a letter") + + if not re.match(r"^[a-zA-Z0-9-_.]*$", value): + raise ValueError( + "Cluster name must contain only alphanumeric characters, " + "hyphens, underscores and periods" + ) + + return value + + @validator("profile_limit_memory") + @classmethod + def profile_limit_memory_validator(cls, value: int) -> Optional[int]: + """Check profile limit memory.""" + if value < 600: + raise ValueError("MySQL Charm requires at least 600MB for bootstrapping") + if value > 9999999: + raise ValueError("`profile-limit-memory` limited to 7 digits (9999999MB)") + + return value + + @validator("mysql_interface_user", "mysql_root_interface_user") + @classmethod + def user_name_validator(cls, value: str) -> Optional[str]: + """Check user name is valid.""" + if len(value) > 32: + raise ValueError("User name constrained to 32 characters") + + return value + + @validator("mysql_interface_database", "mysql_root_interface_database") + @classmethod + def database_name_validator(cls, value: str) -> Optional[str]: + """Check database name is valid.""" + if not re.match(r"^[^\\\/?%*:|\"<>.]{1,64}$", value): + raise ValueError( + "Database name cannot contain slashes, dots or characters not" + " allowed for directories, and are limited to 64 characters" + ) + + return value diff --git a/src/mysql_k8s_helpers.py b/src/mysql_k8s_helpers.py index 56d15c328..0775cf8f0 100644 --- a/src/mysql_k8s_helpers.py +++ b/src/mysql_k8s_helpers.py @@ -15,8 +15,6 @@ MySQLClientError, MySQLConfigureMySQLUsersError, MySQLExecError, - MySQLGetAutoTunningParametersError, - MySQLGetAvailableMemoryError, MySQLStartMySQLDError, MySQLStopMySQLDError, ) @@ -40,7 +38,6 @@ MYSQL_DATA_DIR, MYSQL_SYSTEM_GROUP, MYSQL_SYSTEM_USER, - MYSQLD_CONFIG_FILE, MYSQLD_DEFAULTS_CONFIG_FILE, MYSQLD_LOCATION, MYSQLD_SAFE_SERVICE, @@ -311,21 +308,6 @@ def configure_mysql_users(self) -> None: logger.exception("Error configuring MySQL users", exc_info=e) raise MySQLConfigureMySQLUsersError(e.message) - def write_mysqld_config(self, profile: str) -> None: - """Create custom mysql config file. - - Raises MySQLCreateCustomMySQLDConfigError if there is an error creating the - custom mysqld config - """ - logger.debug("Copying custom mysqld config") - try: - content = self.render_myqld_configuration(profile=profile) - except (MySQLGetAvailableMemoryError, MySQLGetAutoTunningParametersError): - logger.exception("Failed to get available memory or auto tuning parameters") - raise MySQLCreateCustomConfigFileError - - self.write_content_to_file(MYSQLD_CONFIG_FILE, content) - def execute_backup_commands( self, s3_directory: str, @@ -747,6 +729,21 @@ def write_content_to_file( """ self.container.push(path, content, permissions=permission, user=owner, group=group) + def read_file_content(self, path: str) -> Optional[str]: + """Read file content. + + Args: + path: filesystem full path (with filename) + + Returns: + file content + """ + if not self.container.exists(path): + return None + + content = self.container.pull(path, encoding="utf8") + return content.read() + def remove_file(self, path: str) -> None: """Remove a file (if it exists) from container workload. diff --git a/src/relations/mysql.py b/src/relations/mysql.py index 592cc6da3..056882169 100644 --- a/src/relations/mysql.py +++ b/src/relations/mysql.py @@ -5,18 +5,14 @@ import json import logging +import typing from charms.mysql.v0.mysql import ( MySQLCheckUserExistenceError, MySQLCreateApplicationDatabaseAndScopedUserError, MySQLDeleteUsersForUnitError, ) -from ops.charm import ( - CharmBase, - RelationBrokenEvent, - RelationChangedEvent, - RelationCreatedEvent, -) +from ops.charm import RelationBrokenEvent, RelationChangedEvent, RelationCreatedEvent from ops.framework import Object from ops.model import ActiveStatus, BlockedStatus @@ -35,11 +31,14 @@ MYSQL_RELATION_USER_KEY = "mysql-interface-user" MYSQL_RELATION_DATABASE_KEY = "mysql-interface-database" +if typing.TYPE_CHECKING: + from charm import MySQLOperatorCharm + class MySQLRelation(Object): """Encapsulation of the legacy mysql relation.""" - def __init__(self, charm: CharmBase): + def __init__(self, charm: "MySQLOperatorCharm"): super().__init__(charm, LEGACY_MYSQL) self.charm = charm @@ -83,7 +82,7 @@ def _get_or_generate_username(self, event_relation_id: int) -> str: """ return self.charm.app_peer_data.setdefault( MYSQL_RELATION_USER_KEY, - self.charm.config.get(MYSQL_RELATION_USER_KEY) or f"relation-{event_relation_id}", + self.charm.config.mysql_interface_user or f"relation-{event_relation_id}", ) def _get_or_generate_database(self, event_relation_id: int) -> str: @@ -93,7 +92,7 @@ def _get_or_generate_database(self, event_relation_id: int) -> str: """ return self.charm.app_peer_data.setdefault( MYSQL_RELATION_DATABASE_KEY, - self.charm.config.get(MYSQL_RELATION_DATABASE_KEY) or f"database-{event_relation_id}", + self.charm.config.mysql_interface_database or f"database-{event_relation_id}", ) def _on_config_changed(self, _) -> None: @@ -110,13 +109,15 @@ def _on_config_changed(self, _) -> None: if isinstance(self.charm.unit.status, ActiveStatus) and self.model.relations.get( LEGACY_MYSQL ): - for key in (MYSQL_RELATION_USER_KEY, MYSQL_RELATION_DATABASE_KEY): - config_value = self.charm.config.get(key) - if config_value and config_value != self.charm.app_peer_data[key]: - self.charm.app.status = BlockedStatus( - f"Remove `mysql` relations in order to change `{key}` config" - ) - return + if ( + self.charm.config.mysql_interface_database + != self.charm.app_peer_data[MYSQL_RELATION_DATABASE_KEY] + or self.charm.config.mysql_interface_user + != self.charm.app_peer_data[MYSQL_RELATION_USER_KEY] + ): + self.charm.app.status = BlockedStatus( + "Remove and re-relate `mysql` relations in order to change config" + ) def _on_leader_elected(self, _) -> None: """Handle the leader elected event. diff --git a/src/relations/mysql_root.py b/src/relations/mysql_root.py index 15f3f18e4..e01f0ea34 100644 --- a/src/relations/mysql_root.py +++ b/src/relations/mysql_root.py @@ -5,12 +5,13 @@ import json import logging +import typing from charms.mysql.v0.mysql import ( MySQLCheckUserExistenceError, MySQLDeleteUsersForUnitError, ) -from ops.charm import CharmBase, RelationBrokenEvent, RelationCreatedEvent +from ops.charm import RelationBrokenEvent, RelationCreatedEvent from ops.framework import Object from ops.model import ActiveStatus, BlockedStatus @@ -28,11 +29,14 @@ MYSQL_ROOT_RELATION_USER_KEY = "mysql-root-interface-user" MYSQL_ROOT_RELATION_DATABASE_KEY = "mysql-root-interface-database" +if typing.TYPE_CHECKING: + from charm import MySQLOperatorCharm + class MySQLRootRelation(Object): """Encapsulation of the legacy mysql-root relation.""" - def __init__(self, charm: CharmBase): + def __init__(self, charm: "MySQLOperatorCharm"): super().__init__(charm, LEGACY_MYSQL_ROOT) self.charm = charm @@ -72,7 +76,7 @@ def _get_or_generate_username(self, event_relation_id: int) -> str: """ return self.charm.app_peer_data.setdefault( MYSQL_ROOT_RELATION_USER_KEY, - self.charm.config.get(MYSQL_ROOT_RELATION_USER_KEY) or f"relation-{event_relation_id}", + self.charm.config.mysql_root_interface_user or f"relation-{event_relation_id}", ) def _get_or_generate_database(self, event_relation_id: int) -> str: @@ -82,8 +86,7 @@ def _get_or_generate_database(self, event_relation_id: int) -> str: """ return self.charm.app_peer_data.setdefault( MYSQL_ROOT_RELATION_DATABASE_KEY, - self.charm.config.get(MYSQL_ROOT_RELATION_DATABASE_KEY) - or f"database-{event_relation_id}", + self.charm.config.mysql_root_interface_database or f"database-{event_relation_id}", ) def _on_leader_elected(self, _) -> None: @@ -132,13 +135,15 @@ def _on_config_changed(self, _) -> None: if isinstance(self.charm.unit.status, ActiveStatus) and self.model.relations.get( LEGACY_MYSQL_ROOT ): - for key in (MYSQL_ROOT_RELATION_USER_KEY, MYSQL_ROOT_RELATION_DATABASE_KEY): - config_value = self.charm.config.get(key) - if config_value and config_value != self.charm.app_peer_data[key]: - self.charm.app.status = BlockedStatus( - f"Remove `mysql-root` relations in order to change `{key}` config" - ) - return + if ( + self.charm.config.mysql_root_interface_database + != self.charm.app_peer_data[MYSQL_ROOT_RELATION_DATABASE_KEY] + or self.charm.config.mysql_root_interface_user + != self.charm.app_peer_data[MYSQL_ROOT_RELATION_USER_KEY] + ): + self.charm.app.status = BlockedStatus( + "Remove and re-relate `mysql` relations in order to change config" + ) def _on_mysql_root_relation_created(self, event: RelationCreatedEvent) -> None: """Handle the legacy 'mysql-root' relation created event. diff --git a/src/utils.py b/src/utils.py index 747942930..e0f2aa7c5 100644 --- a/src/utils.py +++ b/src/utils.py @@ -71,3 +71,18 @@ def any_memory_to_bytes(mem_str) -> int: num = int(memory) return int(num * units[unit]) + + +def compare_dictionaries(dict1: dict, dict2: dict) -> set: + """Compare two dictionaries and return a set of keys that are different.""" + different_keys = set() + + # exiting keys with different values + for key in dict1.keys(): + if key in dict2 and dict1[key] != dict2[key]: + different_keys.add(key) + + # non existent keys + different_keys = different_keys | dict2.keys() ^ dict1.keys() + + return different_keys diff --git a/tests/integration/high_availability/test_upgrade.py b/tests/integration/high_availability/test_upgrade.py index ba8896a49..c8321b8ce 100644 --- a/tests/integration/high_availability/test_upgrade.py +++ b/tests/integration/high_availability/test_upgrade.py @@ -127,10 +127,10 @@ async def test_upgrade_from_edge(ops_test: OpsTest, continuous_writes) -> None: await action.wait() logger.info("Wait for upgrade to complete") - async with ops_test.fast_forward("60s"): - await ops_test.model.wait_for_idle( - apps=[MYSQL_APP_NAME], status="active", idle_period=30, timeout=TIMEOUT - ) + await ops_test.model.block_until( + lambda: all(unit.workload_status == "active" for unit in application.units), + timeout=TIMEOUT, + ) logger.info("Ensure continuous_writes") await ensure_all_units_continuous_writes_incrementing(ops_test) @@ -196,7 +196,10 @@ async def test_fail_and_rollback(ops_test, continuous_writes, built_charm) -> No await action.wait() logger.info("Wait for application to recover") - await ops_test.model.wait_for_idle(apps=[MYSQL_APP_NAME], status="active", timeout=TIMEOUT) + await ops_test.model.block_until( + lambda: all(unit.workload_status == "active" for unit in application.units), + timeout=TIMEOUT, + ) logger.info("Ensure continuous_writes after rollback procedure") await ensure_all_units_continuous_writes_incrementing(ops_test) diff --git a/tests/integration/high_availability/test_upgrade_from_stable.py b/tests/integration/high_availability/test_upgrade_from_stable.py index bef3effe7..c60e69584 100644 --- a/tests/integration/high_availability/test_upgrade_from_stable.py +++ b/tests/integration/high_availability/test_upgrade_from_stable.py @@ -123,10 +123,10 @@ async def test_upgrade_from_stable(ops_test: OpsTest): await action.wait() logger.info("Wait for upgrade to complete") - async with ops_test.fast_forward("60s"): - await ops_test.model.wait_for_idle( - apps=[MYSQL_APP_NAME], status="active", idle_period=30, timeout=TIMEOUT - ) + await ops_test.model.block_until( + lambda: all(unit.workload_status == "active" for unit in application.units), + timeout=TIMEOUT, + ) logger.info("Ensure continuous_writes") await ensure_all_units_continuous_writes_incrementing(ops_test) diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 131d91928..eba16a348 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -99,6 +99,7 @@ def test_on_leader_elected_secrets(self): secret_data[password].isalnum() and len(secret_data[password]) == PASSWORD_LENGTH ) + @patch("mysql_k8s_helpers.MySQL.write_content_to_file") @patch("mysql_k8s_helpers.MySQL.is_data_dir_initialised", return_value=False) @patch("mysql_k8s_helpers.MySQL.create_cluster_set") @patch("mysql_k8s_helpers.MySQL.initialize_juju_units_operations_table") @@ -107,7 +108,6 @@ def test_on_leader_elected_secrets(self): @patch("mysql_k8s_helpers.MySQL.configure_mysql_users") @patch("mysql_k8s_helpers.MySQL.configure_instance") @patch("mysql_k8s_helpers.MySQL.create_cluster") - @patch("mysql_k8s_helpers.MySQL.write_mysqld_config") @patch("mysql_k8s_helpers.MySQL.initialise_mysqld") @patch("mysql_k8s_helpers.MySQL.fix_data_dir") @patch("mysql_k8s_helpers.MySQL.is_instance_in_cluster") @@ -127,7 +127,6 @@ def test_mysql_pebble_ready( _is_instance_in_cluster, _initialise_mysqld, _fix_data_dir, - _create_custom_confir_file, _create_cluster, _configure_instance, _configure_mysql_users, @@ -136,6 +135,7 @@ def test_mysql_pebble_ready( _initialize_juju_units_operations_table, _is_data_dir_initialised, _create_cluster_set, + _write_content_to_file, ): # Check if initial plan is empty self.harness.set_can_connect("mysql", True) @@ -147,7 +147,6 @@ def test_mysql_pebble_ready( self.assertTrue(isinstance(self.charm.unit.status, WaitingStatus)) self.harness.set_leader() - self.charm.on.config_changed.emit() # Trigger pebble ready after leader election self.harness.container_pebble_ready("mysql") self.assertTrue(isinstance(self.charm.unit.status, ActiveStatus)) @@ -169,7 +168,6 @@ def test_mysql_pebble_ready_non_leader(self, _mysql_mock): self.harness.update_relation_data( self.peer_relation_id, f"{APP_NAME}/1", {"configured": "True"} ) - _mysql_mock.get_mysql_version.return_value = "8.0.25" self.charm._mysql = _mysql_mock self.harness.container_pebble_ready("mysql") @@ -179,8 +177,8 @@ def test_mysql_pebble_ready_non_leader(self, _mysql_mock): def test_mysql_pebble_ready_exception(self, _mysql_mock): # Test exception raised in bootstrapping self.harness.set_leader() - self.charm.on.config_changed.emit() self.charm._mysql = _mysql_mock + _mysql_mock.render_mysqld_configuration.return_value = ("content", {"config": "data"}) _mysql_mock.get_innodb_buffer_pool_parameters.return_value = (123456, None, None) _mysql_mock.initialise_mysqld.side_effect = MySQLInitialiseMySQLDError # Trigger pebble ready after leader election @@ -203,7 +201,6 @@ def test_mysql_property(self, _): # Test mysql property instance of mysql_k8s_helpers.MySQL # set leader and populate peer relation data self.harness.set_leader() - self.charm.on.config_changed.emit() self.harness.update_relation_data( self.peer_relation_id, f"{APP_NAME}/1", diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 000000000..9fd338f18 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +import logging +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml +from ops.testing import Harness + +from charm import MySQLOperatorCharm + +CONFIG = str(yaml.safe_load(Path("./config.yaml").read_text())) +ACTIONS = str(yaml.safe_load(Path("./actions.yaml").read_text())) +METADATA = str(yaml.safe_load(Path("./metadata.yaml").read_text())) + +logger = logging.getLogger(__name__) + + +@pytest.fixture +def harness(): + harness = Harness(MySQLOperatorCharm, meta=METADATA, config=CONFIG, actions=ACTIONS) + harness.add_relation("restart", "mysql") + patcher = patch("lightkube.core.client.GenericSyncClient") + patcher.start() + harness.begin() + return harness + + +def _check_valid_values(_harness, field: str, accepted_values: list, is_long_field=False) -> None: + """Check the correcteness of the passed values for a field.""" + for value in accepted_values: + _harness.update_config({field: value}) + assert _harness.charm.config[field] == value if not is_long_field else int(value) + + +def _check_invalid_values(_harness, field: str, erroneus_values: list) -> None: + """Check the incorrectness of the passed values for a field.""" + for value in erroneus_values: + _harness.update_config({field: value}) + with pytest.raises(ValueError): + _ = _harness.charm.config[field] + + +def test_profile_limit_values(harness) -> None: + """Check that integer fields are parsed correctly.""" + erroneus_values = [599, 10**7, -354343] + _check_invalid_values(harness, "profile-limit-memory", erroneus_values) + + valid_values = [600, 1000, 35000] + _check_valid_values(harness, "profile-limit-memory", valid_values) + + +def test_profile_values(harness) -> None: + """Test profile values.""" + erroneus_values = ["prod", "Test", "foo", "bar"] + _check_invalid_values(harness, "profile", erroneus_values) + + accepted_values = ["production", "testing"] + _check_valid_values(harness, "profile", accepted_values) + + +def test_cluster_name_values(harness) -> None: + """Test cluster name values.""" + erroneus_values = [64 * "a", "1-cluster", "cluster$"] + _check_invalid_values(harness, "cluster-name", erroneus_values) + + accepted_values = ["c1", "cluster_name", "cluster.name", "Cluster-name", 63 * "c"] + _check_valid_values(harness, "cluster-name", accepted_values) diff --git a/tests/unit/test_upgrade.py b/tests/unit/test_upgrade.py index 627760448..ff0067d2e 100644 --- a/tests/unit/test_upgrade.py +++ b/tests/unit/test_upgrade.py @@ -69,7 +69,6 @@ def test_pre_upgrade_check( ): """Test the pre upgrade check.""" self.harness.set_leader(True) - self.charm.on.config_changed.emit() self.charm.upgrade.pre_upgrade_check() mock_rescan_cluster.assert_called_once() @@ -140,7 +139,6 @@ def test_pre_upgrade_prepare( ): """Test the pre upgrade prepare.""" self.harness.set_leader(True) - self.charm.on.config_changed.emit() self.charm.upgrade._pre_upgrade_prepare()