diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d8625f2b0..4a2c5477a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,8 +19,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: 'Foresight: Collect workflow telemetry' - uses: runforesight/foresight-workflow-kit-action@v1 - name: Checkout uses: actions/checkout@v3 - name: Install tox @@ -34,24 +32,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: 'Foresight: Collect workflow telemetry' - uses: runforesight/foresight-workflow-kit-action@v1 - name: Checkout uses: actions/checkout@v3 - name: Install tox # TODO: Consider replacing with custom image on self-hosted runner OR pinning version run: python3 -m pip install tox - name: Run tests - run: tox run -e unit -- --junit-xml=pytest_report.xml - - name: 'Foresight: Collect test results' - uses: runforesight/foresight-test-kit-action@v1 - if: ${{ success() || failure() }} - with: - test_framework: PYTEST - test_format: JUNIT - test_path: pytest_report.xml - coverage_format: LCOV/TXT - coverage_path: coverage.lcov + run: tox run -e unit build: name: Build charms @@ -66,13 +53,12 @@ jobs: name: ${{ matrix.tox-environments }} needs: - lint - - unit-test + # TODO: re-enable after adding unit tests + #- unit-test - build runs-on: ubuntu-latest timeout-minutes: 120 steps: - - name: 'Foresight: Collect workflow telemetry' - uses: runforesight/foresight-workflow-kit-action@v1 - name: Checkout uses: actions/checkout@v3 - name: Setup operator environment @@ -98,16 +84,9 @@ jobs: fi - name: Run integration tests # set a predictable model name so it can be consumed by charm-logdump-action - run: sg microk8s -c "tox run -e ${{ matrix.tox-environments }} -- -m '${{ steps.select-tests.outputs.mark_expression }}' --model testing --junit-xml=pytest_report.xml" + run: sg microk8s -c "tox run -e ${{ matrix.tox-environments }} -- -m '${{ steps.select-tests.outputs.mark_expression }}' --model testing" env: CI_PACKED_CHARMS: ${{ needs.build.outputs.charms }} - - name: 'Foresight: Collect test results' - uses: runforesight/foresight-test-kit-action@v1 - if: ${{ success() || failure() }} - with: - test_framework: PYTEST - test_format: JUNIT - test_path: pytest_report.xml - name: Dump logs uses: canonical/charm-logdump-action@main if: failure() diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 26d8b076b..31d7d2370 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,8 +13,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: 'Foresight: Collect workflow telemetry' - uses: runforesight/foresight-workflow-kit-action@v1 - name: Checkout uses: actions/checkout@v3 with: diff --git a/metadata.yaml b/metadata.yaml index 146c5f617..94c291e97 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -3,6 +3,7 @@ name: mysql-router-k8s display-name: MySQL Router maintainers: + - Carl Csaposs - Paulo Machado - Shayan Patel description: | @@ -30,7 +31,6 @@ resources: mysql-router-image: type: oci-image description: OCI image for mysql-router - # TODO: replace with canonical maintained image - upstream-source: dataplatformoci/mysql-router:8.0-22.04_edge + upstream-source: ghcr.io/canonical/charmed-mysql@sha256:017605f168fcc569d10372bb74b29ef9041256bd066013dec39e9ceee8c88539 assumes: - k8s-api diff --git a/pyproject.toml b/pyproject.toml index 070ea6e60..d90dbd047 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,9 @@ exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"] select = ["E", "W", "F", "C", "N", "R", "D", "H"] # Ignore W503, E501 because using black creates errors with this # Ignore D107 Missing docstring in __init__ -ignore = ["W503", "E501", "D107"] +# Ignore D415 Docstring first line punctuation (doesn't make sense for properties) +# Ignore D403 First word of the first line should be properly capitalized (false positive on "MySQL") +ignore = ["W503", "E501", "D107", "D415", "D403"] # D100, D101, D102, D103: Ignore missing docstrings in tests per-file-ignores = ["tests/*:D100,D101,D102,D103,D104"] docstring-convention = "google" diff --git a/requirements.txt b/requirements.txt index bc8be31f5..85f874289 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,5 @@ cryptography==39.0.1 jsonschema==4.17.3 lightkube-models==1.24.1.4 lightkube==0.11.0 -mysql-connector-python ops >= 2.0.0 tenacity==8.0.1 diff --git a/src/charm.py b/src/charm.py index 4abd84f7b..a48387cb8 100755 --- a/src/charm.py +++ b/src/charm.py @@ -4,106 +4,140 @@ # # Learn more at: https://juju.is/docs/sdk -"""MySQL-Router k8s charm.""" +"""MySQL Router kubernetes (k8s) charm""" -import json import logging -from typing import Optional, Set - -from lightkube import ApiError, Client -from lightkube.models.core_v1 import ServicePort, ServiceSpec -from lightkube.models.meta_v1 import ObjectMeta -from lightkube.resources.core_v1 import Pod, Service -from ops.charm import CharmBase -from ops.main import main -from ops.model import ActiveStatus, BlockedStatus, Relation, WaitingStatus -from ops.pebble import Layer - -from constants import ( - DATABASE_PROVIDES_RELATION, - DATABASE_REQUIRES_RELATION, - MYSQL_DATABASE_CREATED, - MYSQL_ROUTER_CONTAINER_NAME, - MYSQL_ROUTER_REQUIRES_DATA, - MYSQL_ROUTER_SERVICE_NAME, - NUM_UNITS_BOOTSTRAPPED, - PEER, - UNIT_BOOTSTRAPPED, -) -from mysql_router_helpers import MySQLRouter -from relations.database_provides import DatabaseProvidesRelation -from relations.database_requires import DatabaseRequiresRelation -from relations.tls import MySQLRouterTLS +import socket + +import lightkube +import lightkube.models.core_v1 +import lightkube.models.meta_v1 +import lightkube.resources.core_v1 +import ops +import tenacity + +import relations.database_provides +import relations.database_requires +import relations.tls +import workload logger = logging.getLogger(__name__) -class MySQLRouterOperatorCharm(CharmBase): - """Operator charm for MySQLRouter.""" +class MySQLRouterOperatorCharm(ops.CharmBase): + """Operator charm for MySQL Router""" - def __init__(self, *args): + def __init__(self, *args) -> None: super().__init__(*args) + self.database_requires = relations.database_requires.RelationEndpoint(self) + + self.database_provides = relations.database_provides.RelationEndpoint(self) + + # Set status on first start if no relations active + self.framework.observe(self.on.start, self.reconcile_database_relations) + self.framework.observe(self.on.install, self._on_install) - self.framework.observe(self.on.leader_elected, self._on_leader_elected) self.framework.observe( getattr(self.on, "mysql_router_pebble_ready"), self._on_mysql_router_pebble_ready ) - self.framework.observe(self.on[PEER].relation_changed, self._on_peer_relation_changed) - self.framework.observe(self.on.update_status, self._on_update_status) + self.framework.observe(self.on.leader_elected, self._on_leadership_change) + self.framework.observe(self.on.leader_settings_changed, self._on_leadership_change) - self.database_provides = DatabaseProvidesRelation(self) - self.database_requires = DatabaseRequiresRelation(self) - self.tls = MySQLRouterTLS(self) + # Start workload after pod restart + self.framework.observe(self.on.upgrade_charm, self.reconcile_database_relations) - # ======================= - # Properties - # ======================= + self.tls = relations.tls.RelationEndpoint(self) @property - def peers(self) -> Optional[Relation]: - """Fetch the peer relation.""" - return self.model.get_relation(PEER) - - @property - def app_peer_data(self): - """Application peer data object.""" - if not self.peers: - return {} - - return self.peers.data[self.app] + def workload(self): + """MySQL Router workload""" + # Defined as a property instead of an attribute in __init__ since this class is + # not re-instantiated between events (if there are deferred events) + container = self.unit.get_container(workload.Workload.CONTAINER_NAME) + if self.database_requires.relation: + return workload.AuthenticatedWorkload( + _container=container, + _database_requires_relation=self.database_requires.relation, + _charm=self, + ) + return workload.Workload(_container=container) @property - def unit_peer_data(self): - """Unit peer data object.""" - if not self.peers: - return {} - - return self.peers.data[self.unit] + def model_service_domain(self): + """K8s service domain for Juju model""" + # Example: "mysql-router-k8s-0.mysql-router-k8s-endpoints.my-model.svc.cluster.local" + fqdn = socket.getfqdn() + # Example: "mysql-router-k8s-0.mysql-router-k8s-endpoints." + prefix = f"{self.unit.name.replace('/', '-')}.{self.app.name}-endpoints." + assert fqdn.startswith(f"{prefix}{self.model.name}.") + # Example: my-model.svc.cluster.local + return fqdn.removeprefix(prefix) @property - def endpoint(self): - """The k8s endpoint for the charm.""" - return f"{self.model.app.name}.{self.model.name}.svc.cluster.local" + def _endpoint(self) -> str: + """K8s endpoint for MySQL Router""" + # Example: mysql-router-k8s.my-model.svc.cluster.local + return f"{self.app.name}.{self.model_service_domain}" - @property - def unit_hostname(self) -> str: - """Get the hostname.localdomain for a unit. + def _determine_status(self, event) -> ops.StatusBase: + """Report charm status.""" + if self.unit.is_leader(): + # Only report status about related applications on leader unit + # (The `data_interfaces.DatabaseProvides` `on.database_requested` event is only + # emitted on the leader unit—non-leader units may not have a chance to update status + # when the status about related applications changes.) + missing_relations = [] + for endpoint in [self.database_requires, self.database_provides]: + if endpoint.is_missing_relation(event): + missing_relations.append(endpoint.NAME) + if missing_relations: + return ops.BlockedStatus( + f"Missing relation{'s' if len(missing_relations) > 1 else ''}: {', '.join(missing_relations)}" + ) + if self.database_requires.waiting_for_resource: + return ops.WaitingStatus(f"Waiting for related app: {self.database_requires.NAME}") + if not self.workload.container_ready: + return ops.MaintenanceStatus("Waiting for container") + return ops.ActiveStatus() + + def set_status(self, event) -> None: + """Set charm status. + + Except if charm is in unrecognized state + """ + if isinstance( + self.unit.status, ops.BlockedStatus + ) and not self.unit.status.message.startswith("Missing relation"): + return + self.unit.status = self._determine_status(event) + logger.debug(f"Set status to {self.unit.status}") - Translate juju unit name to hostname.localdomain, necessary - for correct name resolution under k8s. + def wait_until_mysql_router_ready(self) -> None: + """Wait until a connection to MySQL Router is possible. - Returns: - A string representing the hostname.localdomain of the unit. + Retry every 5 seconds for up to 30 seconds. """ - return f"{self.unit.name.replace('/', '-')}.{self.app.name}-endpoints" - - # ======================= - # Helpers - # ======================= + logger.debug("Waiting until MySQL Router is ready") + self.unit.status = ops.WaitingStatus("MySQL Router starting") + try: + for attempt in tenacity.Retrying( + reraise=True, + stop=tenacity.stop_after_delay(30), + wait=tenacity.wait_fixed(5), + ): + with attempt: + for port in [6446, 6447]: + with socket.socket() as s: + assert s.connect_ex(("localhost", port)) == 0 + except AssertionError: + logger.exception("Unable to connect to MySQL Router") + raise + else: + logger.debug("MySQL Router is ready") - def _patch_service(self, name: str, ro_port: int, rw_port: int) -> None: - """Patch juju created k8s service. + def _patch_service(self, *, name: str, ro_port: int, rw_port: int) -> None: + """Patch Juju-created k8s service. The k8s service will be tied to pod-0 so that the service is auto cleaned by k8s when the last pod is scaled down. @@ -113,14 +147,15 @@ def _patch_service(self, name: str, ro_port: int, rw_port: int) -> None: ro_port: The read only port. rw_port: The read write port. """ - client = Client() + logger.debug(f"Patching k8s service {name=}, {ro_port=}, {rw_port=}") + client = lightkube.Client() pod0 = client.get( - res=Pod, + res=lightkube.resources.core_v1.Pod, name=self.app.name + "-0", namespace=self.model.name, ) - service = Service( - metadata=ObjectMeta( + service = lightkube.resources.core_v1.Service( + metadata=lightkube.models.meta_v1.ObjectMeta( name=name, namespace=self.model.name, ownerReferences=pod0.metadata.ownerReferences, @@ -128,14 +163,14 @@ def _patch_service(self, name: str, ro_port: int, rw_port: int) -> None: "app.kubernetes.io/name": self.app.name, }, ), - spec=ServiceSpec( + spec=lightkube.models.core_v1.ServiceSpec( ports=[ - ServicePort( + lightkube.models.core_v1.ServicePort( name="mysql-ro", port=ro_port, targetPort=ro_port, ), - ServicePort( + lightkube.models.core_v1.ServicePort( name="mysql-rw", port=rw_port, targetPort=rw_port, @@ -145,155 +180,71 @@ def _patch_service(self, name: str, ro_port: int, rw_port: int) -> None: ), ) client.patch( - res=Service, + res=lightkube.resources.core_v1.Service, obj=service, name=service.metadata.name, namespace=service.metadata.namespace, force=True, field_manager=self.model.app.name, ) - - def get_secret(self, scope: str, key: str) -> Optional[str]: - """Get secret from the peer relation databag.""" - if scope == "unit": - return self.unit_peer_data.get(key, None) - elif scope == "app": - return self.app_peer_data.get(key, None) - else: - raise RuntimeError("Unknown secret scope") - - def set_secret(self, scope: str, key: str, value: Optional[str]) -> None: - """Set secret in the peer relation databag.""" - if scope == "unit": - if not value: - del self.unit_peer_data[key] - return - self.unit_peer_data.update({key: value}) - elif scope == "app": - if not value: - del self.app_peer_data[key] - return - self.app_peer_data.update({key: value}) - else: - raise RuntimeError("Unknown secret scope") - - @property - def mysql_router_layer(self) -> Layer: - """Return a layer configuration for the mysql router service.""" - requires_data = json.loads(self.app_peer_data[MYSQL_ROUTER_REQUIRES_DATA]) - host, port = requires_data["endpoints"].split(",")[0].split(":") - return Layer( - { - "summary": "mysql router layer", - "description": "the pebble config layer for mysql router", - "services": { - MYSQL_ROUTER_SERVICE_NAME: { - "override": "replace", - "summary": "mysql router", - "command": "/run.sh mysqlrouter", - "startup": "enabled", - "environment": { - "MYSQL_HOST": host, - "MYSQL_PORT": port, - "MYSQL_USER": requires_data["username"], - "MYSQL_PASSWORD": self.get_secret("app", "database-password") or "", - }, - }, - }, - } - ) - - def _bootstrap_mysqlrouter(self) -> bool: - if not self.app_peer_data.get(MYSQL_DATABASE_CREATED): - return False - - pebble_layer = self.mysql_router_layer - - container = self.unit.get_container(MYSQL_ROUTER_CONTAINER_NAME) - plan = container.get_plan() - - if plan.services != pebble_layer.services: - container.add_layer(MYSQL_ROUTER_SERVICE_NAME, pebble_layer, combine=True) - container.start(MYSQL_ROUTER_SERVICE_NAME) - - MySQLRouter.wait_until_mysql_router_ready() - - self.unit_peer_data[UNIT_BOOTSTRAPPED] = "true" - - return True - - return False - - @property - def missing_relations(self) -> Set[str]: - """Return a set of missing relations.""" - missing_relations = set() - for relation_name in [DATABASE_REQUIRES_RELATION, DATABASE_PROVIDES_RELATION]: - if not self.model.get_relation(relation_name): - missing_relations.add(relation_name) - return missing_relations + logger.debug(f"Patched k8s service {name=}, {ro_port=}, {rw_port=}") # ======================= # Handlers # ======================= - def _on_install(self, _) -> None: - """Handle the install event.""" - self.unit.status = WaitingStatus() - # Try set workload version - container = self.unit.get_container(MYSQL_ROUTER_CONTAINER_NAME) - if container.can_connect(): - if version := MySQLRouter.get_version(container): - self.unit.set_workload_version(version) - - def _on_leader_elected(self, _) -> None: - """Handle the leader elected event. - - Patch existing k8s service to include read-write and read-only services. - """ - # Create the read-write and read-only services - try: - self._patch_service(f"{self.app.name}", ro_port=6447, rw_port=6446) - except ApiError: - logger.exception("Failed to patch k8s service") - self.unit.status = BlockedStatus("Failed to patch k8s service") - return - - def _on_mysql_router_pebble_ready(self, _) -> None: - """Handle the mysql-router pebble ready event.""" - if self._bootstrap_mysqlrouter(): - self.unit.status = ActiveStatus() - - def _on_peer_relation_changed(self, _) -> None: - """Handle the peer relation changed event. - - Bootstraps mysqlrouter if the relations exist, but pebble_ready event - fired before the requires relation was formed. - """ + def reconcile_database_relations(self, event=None) -> None: + """Handle database requires/provides events.""" + logger.debug( + "State of reconcile " + f"{self.unit.is_leader()=}, " + f"{isinstance(self.workload, workload.AuthenticatedWorkload)=}, " + f"{self.database_requires.relation and self.database_requires.relation.is_breaking(event)=}, " + f"{self.workload.container_ready=}, " + f"{isinstance(event, ops.UpgradeCharmEvent)=}" + ) if ( - isinstance(self.unit.status, WaitingStatus) - and self.app_peer_data.get(MYSQL_DATABASE_CREATED) - and self._bootstrap_mysqlrouter() + self.unit.is_leader() + and isinstance(self.workload, workload.AuthenticatedWorkload) + and self.workload.container_ready ): - self.unit.status = ActiveStatus() - - if self.unit.is_leader(): - num_units_bootstrapped = sum( - 1 - for _ in self.peers.units.union({self.unit}) - if self.unit_peer_data.get(UNIT_BOOTSTRAPPED) + self.database_provides.reconcile_users( + event=event, + router_endpoint=self._endpoint, + shell=self.workload.shell, ) - self.app_peer_data[NUM_UNITS_BOOTSTRAPPED] = str(num_units_bootstrapped) + if ( + isinstance(self.workload, workload.AuthenticatedWorkload) + and self.workload.container_ready + and not self.database_requires.relation.is_breaking(event) + ): + if isinstance(event, ops.UpgradeCharmEvent): + # Pod restart (https://juju.is/docs/sdk/start-event#heading--emission-sequence) + self.workload.cleanup_after_pod_restart() + self.workload.enable(tls=self.tls.certificate_saved, unit_name=self.unit.name) + elif self.workload.container_ready: + self.workload.disable() + self.set_status(event) - def _on_update_status(self, _) -> None: - """Handle update-status event.""" - if self.missing_relations: - self.unit.status = WaitingStatus( - f"Waiting for relations: {' '.join(self.missing_relations)}" - ) + def _on_mysql_router_pebble_ready(self, _) -> None: + self.unit.set_workload_version(self.workload.version) + self.reconcile_database_relations() + + def _on_leadership_change(self, _) -> None: + # The leader unit is responsible for reporting status about related applications. + # If leadership changes, all units should update status. + self.set_status(event=None) + + def _on_install(self, _) -> None: + """Patch existing k8s service to include read-write and read-only services.""" + if not self.unit.is_leader(): return - self.unit.status = ActiveStatus() + try: + self._patch_service(name=self.app.name, ro_port=6447, rw_port=6446) + except lightkube.ApiError: + logger.exception("Failed to patch k8s service") + raise if __name__ == "__main__": - main(MySQLRouterOperatorCharm) + ops.main.main(MySQLRouterOperatorCharm) diff --git a/src/constants.py b/src/constants.py deleted file mode 100644 index 508ac8e4c..000000000 --- a/src/constants.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright 2022 Canonical Ltd. -# See LICENSE file for licensing details. - -"""File containing constants to be used in the charm.""" - -CREDENTIALS_SHARED = "credentials-shared" -DATABASE_REQUIRES_RELATION = "backend-database" -DATABASE_PROVIDES_RELATION = "database" -NUM_UNITS_BOOTSTRAPPED = "num-units-bootstrapped" -MYSQL_ROUTER_CONTAINER_NAME = "mysql-router" -MYSQL_DATABASE_CREATED = "database-created" -MYSQL_ROUTER_PROVIDES_DATA = "provides-data" -MYSQL_ROUTER_REQUIRES_DATA = "requires-data" -MYSQL_ROUTER_REQUIRES_APPLICATION_DATA = "requires-application-data" -MYSQL_ROUTER_SERVICE_NAME = "mysql_router" -MYSQL_ROUTER_USER_NAME = "mysqlrouter" -PASSWORD_LENGTH = 24 -PEER = "mysql-router-peers" -ROUTER_CONFIG_DIRECTORY = "/tmp/mysqlrouter" -UNIT_BOOTSTRAPPED = "unit-bootstrapped" -TLS_RELATION = "certificates" -TLS_SSL_CONFIG_FILE = "tls.conf" -TLS_SSL_CERT_FILE = "custom-cert.pem" -TLS_SSL_KEY_FILE = "custom-key.pem" diff --git a/src/mysql_router_helpers.py b/src/mysql_router_helpers.py deleted file mode 100644 index 82fda2240..000000000 --- a/src/mysql_router_helpers.py +++ /dev/null @@ -1,135 +0,0 @@ -# Copyright 2022 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Helper class to manage the MySQL Router lifecycle.""" - -import logging -import socket -from typing import Optional - -import mysql.connector -from ops.model import Container -from tenacity import retry, stop_after_delay, wait_fixed - -logger = logging.getLogger(__name__) - - -class Error(Exception): - """Base class for exceptions in this module.""" - - def __repr__(self): - """String representation of the Error class.""" - return "<{}.{} {}>".format(type(self).__module__, type(self).__name__, self.args) - - @property - 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 MySQLRouterCreateUserWithDatabasePrivilegesError(Error): - """Exception raised when there is an issue creating a database scoped user.""" - - -class MySQLRouterPortsNotOpenError(Error): - """Exception raised when mysqlrouter is not bootstrapped and started.""" - - -class MySQLRouter: - """Encapsulates all operations related to MySQL and MySQLRouter.""" - - @staticmethod - def create_user_with_database_privileges( - username, password, hostname, database, db_username, db_password, db_host, db_port - ) -> None: - """Create a database scope mysql user. - - Args: - username: Username of the user to create - password: Password of the user to create - hostname: Hostname of the user to create - database: Database that the user should be restricted to - db_username: The user to connect to the database with - db_password: The password to use to connect to the database - db_host: The host name of the database - db_port: The port for the database - - Raises: - MySQLRouterCreateUserWithDatabasePrivilegesError - - when there is an issue creating a database scoped user - """ - try: - connection = mysql.connector.connect( - user=db_username, password=db_password, host=db_host, port=db_port - ) - cursor = connection.cursor() - - cursor.execute(f"CREATE USER `{username}`@`{hostname}` IDENTIFIED BY '{password}'") - cursor.execute(f"GRANT ALL PRIVILEGES ON {database}.* TO `{username}`@`{hostname}`") - - cursor.close() - connection.close() - except mysql.connector.Error as e: - logger.exception("Failed to create user scoped to a database", exc_info=e) - raise MySQLRouterCreateUserWithDatabasePrivilegesError(e.msg) - - @staticmethod - def delete_application_user( - username, hostname, db_username, db_password, db_host, db_port - ) -> None: - """Delete the application user. - - Args: - username: Username of the user to delete - hostname: Hostname of the user to delete - db_username: The user to connect to the database with - db_password: The password to use to connect to the database - db_host: The host name of the database - db_port: The port for the database - """ - try: - connection = mysql.connector.connect( - user=db_username, password=db_password, host=db_host, port=db_port - ) - cursor = connection.cursor() - - cursor.execute(f"DROP USER IF EXISTS `{username}`@`{hostname}`") - - cursor.close() - connection.close() - except mysql.connector.Error as e: - logger.exception("Failed to delete application user", exc_info=e) - - @staticmethod - @retry(reraise=True, stop=stop_after_delay(30), wait=wait_fixed(5)) - def wait_until_mysql_router_ready() -> None: - """Wait until a connection to MySQL router is possible. - - Retry every 5 seconds for 30 seconds if there is an issue obtaining a connection. - """ - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - result = sock.connect_ex(("127.0.0.1", 6446)) - if result != 0: - raise MySQLRouterPortsNotOpenError() - sock.close() - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - result = sock.connect_ex(("127.0.0.1", 6447)) - if result != 0: - raise MySQLRouterPortsNotOpenError() - sock.close() - - @staticmethod - def get_version(container: Container) -> Optional[str]: - """Get the MySQL Router version.""" - process = container.exec(["mysqlrouter", "-V"]) - raw_version, _ = process.wait_output() - for version in raw_version.strip().split(): - if version.startswith("8"): - return version - return None diff --git a/src/mysql_shell.py b/src/mysql_shell.py new file mode 100644 index 000000000..b4610e106 --- /dev/null +++ b/src/mysql_shell.py @@ -0,0 +1,135 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""MySQL Shell in Python execution mode + +https://dev.mysql.com/doc/mysql-shell/8.0/en/ +""" + +import dataclasses +import json +import logging +import secrets +import string + +import ops + +_PASSWORD_LENGTH = 24 +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(kw_only=True) +class Shell: + """MySQL Shell connected to MySQL cluster""" + + _container: ops.Container + username: str + _password: str + _host: str + _port: str + + _TEMPORARY_SCRIPT_FILE = "/tmp/script.py" + + def _run_commands(self, commands: list[str]) -> None: + """Connect to MySQL cluster and run commands.""" + commands.insert( + 0, f"shell.connect('{self.username}:{self._password}@{self._host}:{self._port}')" + ) + self._container.push(self._TEMPORARY_SCRIPT_FILE, "\n".join(commands)) + try: + process = self._container.exec( + ["mysqlsh", "--no-wizard", "--python", "--file", self._TEMPORARY_SCRIPT_FILE] + ) + process.wait_output() + except ops.pebble.ExecError as e: + logger.exception(f"Failed to run {commands=}\nstderr:\n{e.stderr}\n") + raise + finally: + self._container.remove_path(self._TEMPORARY_SCRIPT_FILE) + + def _run_sql(self, sql_statements: list[str]) -> None: + """Connect to MySQL cluster and execute SQL.""" + commands = [] + for statement in sql_statements: + # Escape double quote (") characters in statement + statement = statement.replace('"', r"\"") + commands.append('session.run_sql("' + statement + '")') + self._run_commands(commands) + + @staticmethod + def _generate_password() -> str: + choices = string.ascii_letters + string.digits + return "".join([secrets.choice(choices) for _ in range(_PASSWORD_LENGTH)]) + + def _get_attributes(self, additional_attributes: dict = None) -> str: + """Attributes for (MySQL) users created by this charm + + If the relation with the MySQL charm is broken, the MySQL charm will use this attribute + to delete all users created by this charm. + """ + attributes = {"created_by_user": self.username} + if additional_attributes: + attributes.update(additional_attributes) + return json.dumps(attributes) + + def create_application_database_and_user(self, *, username: str, database: str) -> str: + """Create database and user for related database_provides application.""" + attributes = self._get_attributes() + logger.debug(f"Creating {database=} and {username=} with {attributes=}") + password = self._generate_password() + self._run_sql( + [ + f"CREATE DATABASE IF NOT EXISTS `{database}`", + f"CREATE USER `{username}` IDENTIFIED BY '{password}' ATTRIBUTE '{attributes}'", + f"GRANT ALL PRIVILEGES ON `{database}`.* TO `{username}`", + ] + ) + logger.debug(f"Created {database=} and {username=} with {attributes=}") + return password + + def add_attributes_to_mysql_router_user( + self, *, username: str, router_id: str, unit_name: str + ) -> None: + """Add attributes to user created during MySQL Router bootstrap.""" + attributes = self._get_attributes( + {"router_id": router_id, "created_by_juju_unit": unit_name} + ) + logger.debug(f"Adding {attributes=} to {username=}") + self._run_sql([f"ALTER USER `{username}` ATTRIBUTE '{attributes}'"]) + logger.debug(f"Added {attributes=} to {username=}") + + def delete_user(self, username: str) -> None: + """Delete user.""" + logger.debug(f"Deleting {username=}") + self._run_sql([f"DROP USER `{username}`"]) + logger.debug(f"Deleted {username=}") + + def delete_router_user_after_pod_restart(self, router_id: str) -> None: + """Delete MySQL Router user created by a previous instance of this unit. + + Before pod restart, the charm does not have an opportunity to delete the MySQL Router user. + During MySQL Router bootstrap, a new user is created. Before bootstrap, the old user + should be deleted. + """ + logger.debug(f"Deleting MySQL Router user {router_id=} created by {self.username=}") + self._run_sql( + [ + f"SELECT CONCAT('DROP USER ', GROUP_CONCAT(QUOTE(USER), '@', QUOTE(HOST))) INTO @sql FROM INFORMATION_SCHEMA.USER_ATTRIBUTES WHERE ATTRIBUTE->'$.created_by_user'='{self.username}' AND ATTRIBUTE->'$.router_id'='{router_id}'", + "PREPARE stmt FROM @sql", + "EXECUTE stmt", + "DEALLOCATE PREPARE stmt", + ] + ) + logger.debug(f"Deleted MySQL Router user {router_id=} created by {self.username=}") + + def remove_router_from_cluster_metadata(self, router_id: str) -> None: + """Remove MySQL Router from InnoDB Cluster metadata. + + On pod restart, MySQL Router bootstrap will fail without `--force` if cluster metadata + already exists for the router ID. + """ + logger.debug(f"Removing {router_id=} from cluster metadata") + self._run_commands( + ["cluster = dba.get_cluster()", f'cluster.remove_router_metadata("{router_id}")'] + ) + logger.debug(f"Removed {router_id=} from cluster metadata") diff --git a/src/relations/database_provides.py b/src/relations/database_provides.py index 6ba920fb8..71d2414f2 100644 --- a/src/relations/database_provides.py +++ b/src/relations/database_provides.py @@ -1,142 +1,172 @@ -# Copyright 2022 Canonical Ltd. +# Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -"""Library containing the implementation of the database provides relation.""" +"""Relation(s) to one or more application charms""" -import json +import dataclasses import logging +import typing -from charms.data_platform_libs.v0.data_interfaces import ( - DatabaseProvides, - DatabaseRequestedEvent, -) -from ops.framework import Object -from ops.model import WaitingStatus - -from constants import ( - CREDENTIALS_SHARED, - DATABASE_PROVIDES_RELATION, - DATABASE_REQUIRES_RELATION, - MYSQL_DATABASE_CREATED, - MYSQL_ROUTER_PROVIDES_DATA, - MYSQL_ROUTER_REQUIRES_APPLICATION_DATA, - PEER, - UNIT_BOOTSTRAPPED, -) -from mysql_router_helpers import MySQLRouter - -logger = logging.getLogger(__name__) +import charms.data_platform_libs.v0.data_interfaces as data_interfaces +import ops +import mysql_shell -class DatabaseProvidesRelation(Object): - """Encapsulation of the relation between mysqlrouter and the consumer application.""" +if typing.TYPE_CHECKING: + import charm - def __init__(self, charm): - super().__init__(charm, DATABASE_PROVIDES_RELATION) +logger = logging.getLogger(__name__) - self.charm = charm - self.database_provides_relation = DatabaseProvides( - self.charm, relation_name=DATABASE_PROVIDES_RELATION - ) - self.framework.observe( - self.database_provides_relation.on.database_requested, self._on_database_requested +@dataclasses.dataclass(kw_only=True) +class _Relation: + """Relation to one application charm""" + + _relation: ops.Relation + _interface: data_interfaces.DatabaseProvides + _model_name: str + + @property + def id(self) -> int: + return self._relation.id + + @property + def _local_databag(self) -> ops.RelationDataContent: + """MySQL Router charm databag""" + return self._relation.data[self._interface.local_app] + + @property + def _remote_databag(self) -> dict: + """MySQL charm databag""" + return self._interface.fetch_relation_data()[self.id] + + @property + def user_created(self) -> bool: + """Whether database user has been shared with application charm""" + for key in ["database", "username", "password", "endpoints"]: + if key not in self._local_databag: + return False + return True + + @property + def _database(self) -> str: + """Requested database name""" + return self._remote_databag["database"] + + def _get_username(self, database_requires_username: str) -> str: + """Database username""" + # Prefix username with username from database requires relation. + # This ensures a unique username if MySQL Router is deployed in a different Juju model + # from MySQL. + # (Relation IDs are only unique within a Juju model.) + return f"{database_requires_username}-{self.id}" + + def _set_databag(self, *, username: str, password: str, router_endpoint: str) -> None: + """Share connection information with application charm.""" + read_write_endpoint = f"{router_endpoint}:6446" + read_only_endpoint = f"{router_endpoint}:6447" + logger.debug( + f"Setting databag {self.id=} {self._database=}, {username=}, {read_write_endpoint=}, {read_only_endpoint=}" ) - self.framework.observe( - self.charm.on[PEER].relation_changed, self._on_peer_relation_changed + self._interface.set_database(self.id, self._database) + self._interface.set_credentials(self.id, username, password) + self._interface.set_endpoints(self.id, read_write_endpoint) + self._interface.set_read_only_endpoints(self.id, read_only_endpoint) + logger.debug( + f"Set databag {self.id=} {self._database=}, {username=}, {read_write_endpoint=}, {read_only_endpoint=}" ) - self.framework.observe( - self.charm.on[DATABASE_PROVIDES_RELATION].relation_broken, self._on_database_broken - ) - - # ======================= - # Handlers - # ======================= - - def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: - """Handle the database requested event.""" - if not self.charm.unit.is_leader(): - return - - # Store data in databag to trigger DatabaseRequires initialization in database_requires.py - self.charm.app_peer_data[MYSQL_ROUTER_PROVIDES_DATA] = json.dumps( - {"database": event.database, "extra_user_roles": event.extra_user_roles} + def _delete_databag(self) -> None: + """Remove connection information from databag.""" + logger.debug(f"Deleting databag {self.id=}") + self._local_databag.clear() + logger.debug(f"Deleted databag {self.id=}") + + def create_database_and_user(self, *, router_endpoint: str, shell: mysql_shell.Shell) -> None: + """Create database & user and update databag.""" + username = self._get_username(shell.username) + password = shell.create_application_database_and_user( + username=username, database=self._database ) + self._set_databag(username=username, password=password, router_endpoint=router_endpoint) - def _on_peer_relation_changed(self, _) -> None: - """Handle the peer relation changed event.""" - if not self.charm.unit.is_leader(): - return + def delete_user(self, *, shell: mysql_shell.Shell) -> None: + """Delete user and update databag.""" + self._delete_databag() + shell.delete_user(self._get_username(shell.username)) - if self.charm.app_peer_data.get(CREDENTIALS_SHARED): - logger.debug("Credentials already shared") - return + def is_breaking(self, event): + """Whether relation will be broken after the current event is handled""" + return isinstance(event, ops.RelationBrokenEvent) and event.relation.id == self.id - if not self.charm.app_peer_data.get(MYSQL_DATABASE_CREATED): - logger.debug("Database not created yet") - return - if not self.charm.unit_peer_data.get(UNIT_BOOTSTRAPPED): - logger.debug("Unit not bootstrapped yet") - return +class RelationEndpoint: + """Relation endpoint for application charm(s)""" - if not self.charm.app_peer_data.get(MYSQL_ROUTER_REQUIRES_APPLICATION_DATA): - logger.debug("No requires application data found") - return + NAME = "database" - database_provides_relations = self.charm.model.relations.get(DATABASE_PROVIDES_RELATION) - - requires_application_data = json.loads( - self.charm.app_peer_data[MYSQL_ROUTER_REQUIRES_APPLICATION_DATA] - ) - provides_relation_id = database_provides_relations[0].id - - self.database_provides_relation.set_credentials( - provides_relation_id, - requires_application_data["username"], - self.charm.get_secret("app", "application-password"), - ) - - self.database_provides_relation.set_endpoints( - provides_relation_id, f"{self.charm.endpoint}:6446" + def __init__(self, charm_: "charm.MySQLRouterOperatorCharm") -> None: + self._interface = data_interfaces.DatabaseProvides(charm_, relation_name=self.NAME) + self._model_name = charm_.model.name + charm_.framework.observe( + self._interface.on.database_requested, + charm_.reconcile_database_relations, ) - - self.database_provides_relation.set_read_only_endpoints( - provides_relation_id, f"{self.charm.endpoint}:6447" + charm_.framework.observe( + charm_.on[self.NAME].relation_broken, + charm_.reconcile_database_relations, ) - self.charm.app_peer_data[CREDENTIALS_SHARED] = "true" - - def _on_database_broken(self, _) -> None: - """Handle the database relation broken event.""" - self.charm.unit.status = WaitingStatus( - f"Waiting for relations: {DATABASE_PROVIDES_RELATION}" - ) - if not self.charm.unit.is_leader(): - return - - # application user cleanup when backend relation still in place - if backend_relation := self.charm.model.get_relation(DATABASE_REQUIRES_RELATION): - if app_data := self.charm.app_peer_data.get(MYSQL_ROUTER_REQUIRES_APPLICATION_DATA): - username = json.loads(app_data)["username"] - - db_username = backend_relation.data[backend_relation.app]["username"] - db_password = backend_relation.data[backend_relation.app]["password"] - db_host, db_port = backend_relation.data[backend_relation.app]["endpoints"].split( - ":" - ) - - MySQLRouter.delete_application_user( - username=username, - hostname="%", - db_username=db_username, - db_password=db_password, - db_host=db_host, - db_port=db_port, - ) - # clean up departing app data - self.charm.app_peer_data.pop(MYSQL_ROUTER_REQUIRES_APPLICATION_DATA, None) - self.charm.app_peer_data.pop(MYSQL_ROUTER_PROVIDES_DATA, None) - self.charm.app_peer_data.pop(CREDENTIALS_SHARED, None) - self.charm.set_secret("app", "application-password", None) + @property + def _relations(self) -> list[_Relation]: + return [ + _Relation(_relation=relation, _interface=self._interface, _model_name=self._model_name) + for relation in self._interface.relations + ] + + def _requested_users(self, *, event) -> list[_Relation]: + """Related application charms that have requested a database & user""" + requested_users = [] + for relation in self._relations: + if isinstance(event, ops.RelationBrokenEvent) and event.relation.id == relation.id: + # Relation is being removed; delete user + continue + requested_users.append(relation) + return requested_users + + @property + def _created_users(self) -> list[_Relation]: + """Users that have been created and shared with an application charm""" + return [relation for relation in self._relations if relation.user_created] + + def is_missing_relation(self, event) -> bool: + """Whether zero relations to application charms (will) exist""" + for relation in self._relations: + if not relation.is_breaking(event): + return False + return True + + def reconcile_users( + self, + *, + event, + router_endpoint: str, + shell: mysql_shell.Shell, + ) -> None: + """Create requested users and delete inactive users. + + When the relation to the MySQL charm is broken, the MySQL charm will delete all users + created by this charm. Therefore, this charm does not need to delete users when that + relation is broken. + """ + logger.debug(f"Reconciling users {event=}, {router_endpoint=}") + requested_users = self._requested_users(event=event) + created_users = self._created_users + logger.debug(f"State of reconcile users {requested_users=}, {created_users=}") + for relation in requested_users: + if relation not in created_users: + relation.create_database_and_user(router_endpoint=router_endpoint, shell=shell) + for relation in created_users: + if relation not in requested_users: + relation.delete_user(shell=shell) + logger.debug(f"Reconciled users {event=}, {router_endpoint=}") diff --git a/src/relations/database_requires.py b/src/relations/database_requires.py index 19fa3c0b1..eaf96af28 100644 --- a/src/relations/database_requires.py +++ b/src/relations/database_requires.py @@ -1,211 +1,108 @@ -# Copyright 2022 Canonical Ltd. +# Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -"""Library containing the implementation of the database requires relation.""" - -import json -import logging -from typing import Dict, Optional - -from charms.data_platform_libs.v0.data_interfaces import ( - DatabaseCreatedEvent, - DatabaseEndpointsChangedEvent, - DatabaseRequires, -) -from ops.framework import Object -from ops.model import BlockedStatus, ModelError, WaitingStatus - -from constants import ( - CREDENTIALS_SHARED, - DATABASE_PROVIDES_RELATION, - DATABASE_REQUIRES_RELATION, - MYSQL_DATABASE_CREATED, - MYSQL_ROUTER_CONTAINER_NAME, - MYSQL_ROUTER_PROVIDES_DATA, - MYSQL_ROUTER_REQUIRES_APPLICATION_DATA, - MYSQL_ROUTER_REQUIRES_DATA, - MYSQL_ROUTER_SERVICE_NAME, - PASSWORD_LENGTH, - PEER, - UNIT_BOOTSTRAPPED, -) -from mysql_router_helpers import ( - MySQLRouter, - MySQLRouterCreateUserWithDatabasePrivilegesError, -) -from utils import generate_random_password - -logger = logging.getLogger(__name__) - - -class DatabaseRequiresRelation(Object): - """Encapsulation of the relation between mysqlrouter and mysql database.""" - - def __init__(self, charm): - super().__init__(charm, DATABASE_REQUIRES_RELATION) - - self.charm = charm - - self.framework.observe( - self.charm.on[DATABASE_REQUIRES_RELATION].relation_joined, - self._on_database_requires_relation_joined, - ) +"""Relation to MySQL charm""" - provides_data = self._get_provides_data() - if not provides_data: - logger.debug("No provides data found, not handling the relation yet.") - return +import dataclasses +import typing - self.database_requires_relation = DatabaseRequires( - self.charm, - relation_name=DATABASE_REQUIRES_RELATION, - database_name=provides_data["database"], - extra_user_roles="mysqlrouter", - ) +import charms.data_platform_libs.v0.data_interfaces as data_interfaces +import ops - self.framework.observe( - self.database_requires_relation.on.database_created, self._on_backend_database_created - ) - self.framework.observe( - self.database_requires_relation.on.endpoints_changed, self._on_endpoints_changed - ) +if typing.TYPE_CHECKING: + import charm - self.framework.observe( - self.charm.on[DATABASE_REQUIRES_RELATION].relation_broken, - self._on_backend_database_broken, - ) - self.framework.observe( - self.charm.on[PEER].relation_changed, self._on_peer_relation_changed - ) +@dataclasses.dataclass +class Relation: + """Relation to MySQL charm""" - # ======================= - # Helpers - # ======================= - - def _get_provides_data(self) -> Optional[Dict]: - """Helper to get the `provides` relation data from the app peer databag.""" - try: - provides_data = self.charm.app_peer_data.get(MYSQL_ROUTER_PROVIDES_DATA) - if not provides_data: - return None - except ModelError: - # Error raised on app removal - return None - - return json.loads(provides_data) - - def _create_application_user( - self, db_username: str, db_password: str, db_endpoint: str - ) -> None: - """Helper to create a database user for the application.""" - provides_data = self._get_provides_data() - provides_relation_id = self.charm.model.relations[DATABASE_PROVIDES_RELATION][0].id - - username = f"application-user-{provides_relation_id}" - password = generate_random_password(PASSWORD_LENGTH) - db_host, db_port = db_endpoint.split(",")[0].split(":") - - try: - MySQLRouter.create_user_with_database_privileges( - username, - password, - "%", - provides_data["database"], - db_username, - db_password, - db_host, - db_port, - ) - except MySQLRouterCreateUserWithDatabasePrivilegesError: - logger.exception("Failed to create a database scoped user") - self.charm.unit.status = BlockedStatus("Failed to create a database scoped user") - return + _interface: data_interfaces.DatabaseRequires - self.charm.app_peer_data[MYSQL_ROUTER_REQUIRES_APPLICATION_DATA] = json.dumps( - { - "username": username, - } - ) - self.charm.set_secret("app", "application-password", password) - self.charm.set_secret("app", "database-password", db_password) - self.charm.app_peer_data[MYSQL_DATABASE_CREATED] = "true" - logger.info(f"Created database user {username}.") - - # ======================= - # Handlers - # ======================= - - def _on_database_requires_relation_joined(self, event) -> None: - """Handle the backend-database relation joined event. - - Waits until the database relation with the application is formed before - triggering the database_requires relations joined event (which will request the database). - """ - provides_data = self._get_provides_data() - if not provides_data: - logger.debug("Waiting until a relation with an application is formed") - event.defer() - return + @property + def _relation(self) -> ops.Relation: + relations = self._interface.relations + assert len(relations) == 1 + return relations[0] - self.database_requires_relation._on_relation_joined_event(event) + @property + def _id(self) -> int: + return self._relation.id - def _on_backend_database_created(self, event: DatabaseCreatedEvent) -> None: - """Handle the database created event.""" - if not self.charm.unit.is_leader(): - return + @property + def _remote_databag(self) -> dict: + """MySQL charm databag""" + return self._interface.fetch_relation_data()[self._id] - if self.charm.app_peer_data.get(MYSQL_DATABASE_CREATED): - return + @property + def _endpoint(self) -> str: + """MySQL cluster primary endpoint""" + endpoints = self._remote_databag["endpoints"].split(",") + assert len(endpoints) == 1 + return endpoints[0] - self.charm.app_peer_data[MYSQL_ROUTER_REQUIRES_DATA] = json.dumps( - { - "username": event.username, - "endpoints": event.endpoints, - } - ) - self._create_application_user(event.username, event.password, event.endpoints) + @property + def host(self) -> str: + """MySQL cluster primary host""" + return self._endpoint.split(":")[0] - def _on_endpoints_changed(self, event: DatabaseEndpointsChangedEvent) -> None: - """Handle the endpoints changed event. + @property + def port(self) -> str: + """MySQL cluster primary port""" + return self._endpoint.split(":")[1] - Update the endpoint in the MYSQL_ROUTER_REQUIRES_DATA so that future - bootstrapping units will not fail. - """ - if not self.charm.unit.is_leader(): - return + @property + def username(self) -> str: + """Admin username""" + return self._remote_databag["username"] + + @property + def password(self) -> str: + """Admin password""" + return self._remote_databag["password"] + + def is_breaking(self, event): + """Whether relation will be broken after the current event is handled""" + return isinstance(event, ops.RelationBrokenEvent) and event.relation.id == self._id - if self.charm.app_peer_data.get(MYSQL_ROUTER_REQUIRES_DATA): - requires_data = json.loads(self.charm.app_peer_data[MYSQL_ROUTER_REQUIRES_DATA]) - requires_data["endpoints"] = event.endpoints +class RelationEndpoint: + """Relation endpoint for MySQL charm""" - self.charm.app_peer_data[MYSQL_ROUTER_REQUIRES_DATA] = json.dumps(requires_data) + NAME = "backend-database" + + def __init__(self, charm_: "charm.MySQLRouterOperatorCharm") -> None: + self._interface = data_interfaces.DatabaseRequires( + charm_, + relation_name=self.NAME, + # Database name disregarded by MySQL charm if "mysqlrouter" extra user role requested + database_name="mysql_innodb_cluster_metadata", + extra_user_roles="mysqlrouter", + ) + charm_.framework.observe( + self._interface.on.database_created, + charm_.reconcile_database_relations, + ) + charm_.framework.observe( + charm_.on[self.NAME].relation_broken, + charm_.reconcile_database_relations, + ) - def _on_peer_relation_changed(self, _) -> None: - """Handle the peer relation changed event.""" - if not self.charm.unit.is_leader(): + @property + def relation(self) -> typing.Optional[Relation]: + """Relation to MySQL charm""" + if not self._interface.is_resource_created(): return - if self.charm.unit_peer_data.get(UNIT_BOOTSTRAPPED) and not self.charm.app_peer_data.get( - CREDENTIALS_SHARED - ): - # App related after first bootstrap, add app user - requires_data = json.loads(self.charm.app_peer_data[MYSQL_ROUTER_REQUIRES_DATA]) - - self._create_application_user( - requires_data["username"], - self.charm.get_secret("app", "database-password"), - requires_data["endpoints"], - ) - - def _on_backend_database_broken(self, _) -> None: - """Handle the database relation broken event.""" - self.charm.unit.status = WaitingStatus() - container = self.charm.unit.get_container(MYSQL_ROUTER_CONTAINER_NAME) - container.stop(MYSQL_ROUTER_SERVICE_NAME) - - self.charm.unit_peer.data.pop(UNIT_BOOTSTRAPPED, None) - if self.charm.unit.is_leader(): - # cleanup control and connection peer data - self.charm.app_peer_data.pop(MYSQL_DATABASE_CREATED, None) - self.charm.app_peer_data.pop(MYSQL_ROUTER_REQUIRES_DATA, None) + return Relation(self._interface) + + def is_missing_relation(self, event) -> bool: + """Whether relation to MySQL charm does (or will) not exist""" + # Cannot use `self.relation.is_breaking()` in case relation exists but resource not created + if self._interface.relations and Relation(self._interface).is_breaking(event): + return True + return len(self._interface.relations) == 0 + + @property + def waiting_for_resource(self) -> bool: + """Whether resource (database & user) has not been created by the MySQL charm""" + return self.relation is None diff --git a/src/relations/tls.py b/src/relations/tls.py index 86e2ae7d5..99f4a42cd 100644 --- a/src/relations/tls.py +++ b/src/relations/tls.py @@ -1,292 +1,257 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -"""Library containing the implementation of the database requires relation.""" +"""Relation to TLS certificate provider""" import base64 +import dataclasses +import inspect +import json import logging import re import socket -from string import Template -from typing import List, Optional - -from charms.tls_certificates_interface.v1.tls_certificates import ( - CertificateAvailableEvent, - CertificateExpiringEvent, - TLSCertificatesRequiresV1, - generate_csr, - generate_private_key, -) -from ops.charm import ActionEvent, CharmBase -from ops.framework import Object -from ops.pebble import Layer, PathError - -from constants import ( - MYSQL_ROUTER_CONTAINER_NAME, - MYSQL_ROUTER_SERVICE_NAME, - MYSQL_ROUTER_USER_NAME, - ROUTER_CONFIG_DIRECTORY, - TLS_RELATION, - TLS_SSL_CERT_FILE, - TLS_SSL_CONFIG_FILE, - TLS_SSL_KEY_FILE, - UNIT_BOOTSTRAPPED, -) - -SCOPE = "unit" +import typing +import charms.tls_certificates_interface.v1.tls_certificates as tls_certificates +import ops + +if typing.TYPE_CHECKING: + import charm + +_PEER_RELATION_ENDPOINT_NAME = "mysql-router-peers" logger = logging.getLogger(__name__) -class MySQLRouterTLS(Object): - """TLS Management class for MySQL Router Operator.""" +class _PeerUnitDatabag: + """Peer relation unit databag""" - def __init__(self, charm: CharmBase): - super().__init__(charm, TLS_RELATION) - self.charm = charm - self.certs = TLSCertificatesRequiresV1(self.charm, TLS_RELATION) + key: str + # CSR stands for certificate signing request + requested_csr: str + active_csr: str + certificate: str + ca: str # Certificate authority + chain: str - self.framework.observe( - self.charm.on.set_tls_private_key_action, - self._on_set_tls_private_key, - ) - self.framework.observe( - self.charm.on[TLS_RELATION].relation_joined, self._on_tls_relation_joined - ) - self.framework.observe( - self.charm.on[TLS_RELATION].relation_broken, self._on_tls_relation_broken - ) + def __init__(self, databag: ops.RelationDataContent) -> None: + # Cannot use `self._databag =` since this class overrides `__setattr__()` + super().__setattr__("_databag", databag) - self.framework.observe(self.certs.on.certificate_available, self._on_certificate_available) - self.framework.observe(self.certs.on.certificate_expiring, self._on_certificate_expiring) + @staticmethod + def _get_key(key: str) -> str: + """Create databag key by adding a 'tls_' prefix.""" + return f"tls_{key}" @property - def container(self): - """Map to the MySQL Router container.""" - return self.charm.unit.get_container(MYSQL_ROUTER_CONTAINER_NAME) + def _attribute_names(self) -> list[str]: + """Class attributes with type annotation""" + return [name for name in inspect.get_annotations(type(self))] - @property - def hostname(self): - """Return the hostname of the MySQL Router container.""" - return socket.gethostname() + def __getattr__(self, name: str) -> typing.Optional[str]: + assert name in self._attribute_names, f"Invalid attribute {name=}" + return self._databag.get(self._get_key(name)) - # Handlers + def __setattr__(self, name: str, value: str) -> None: + assert name in self._attribute_names, f"Invalid attribute {name=}" + self._databag[self._get_key(name)] = value - def _on_set_tls_private_key(self, event: ActionEvent) -> None: - """Action for setting a TLS private key.""" - if not self.charm.model.get_relation(TLS_RELATION): - event.fail("No TLS relation available.") - return - try: - self._request_certificate(event.params.get("internal-key", None)) - except Exception as e: - event.fail(f"Failed to request certificate: {e}") + def __delattr__(self, name: str) -> None: + assert name in self._attribute_names, f"Invalid attribute {name=}" + self._databag.pop(self._get_key(name), None) - def _on_tls_relation_joined(self, _) -> None: - """Request certificate when TLS relation joined.""" - self._request_certificate(None) + def clear(self) -> None: + """Delete all items in databag.""" + for name in self._attribute_names: + delattr(self, name) - def _on_tls_relation_broken(self, _) -> None: - """Disable TLS when TLS relation broken.""" - for secret in ["cert", "chain", "ca"]: - try: - self.charm.set_secret(SCOPE, secret, None) - except KeyError: - # ignore key error for unit teardown - pass - # unset tls flag - self.charm.unit_peer_data.pop("tls") - self._unset_tls() - - def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: - """Enable TLS when TLS certificate available.""" - if self.charm.unit_peer_data.get(UNIT_BOOTSTRAPPED) != "true": - logger.debug("Unit not bootstrapped, defer TLS setup") - event.defer() - return +@dataclasses.dataclass(kw_only=True) +class _Relation: + """Relation to TLS certificate provider""" + + _charm: "charm.MySQLRouterOperatorCharm" + _interface: tls_certificates.TLSCertificatesRequiresV1 + + @property + def _peer_relation(self) -> ops.Relation: + """MySQL Router charm peer relation""" + return self._charm.model.get_relation(_PEER_RELATION_ENDPOINT_NAME) + + @property + def peer_unit_databag(self) -> _PeerUnitDatabag: + """MySQL Router charm peer relation unit databag""" + return _PeerUnitDatabag(self._peer_relation.data[self._charm.unit]) + + @property + def certificate_saved(self) -> bool: + """Whether a TLS certificate is available to use""" + for value in [self.peer_unit_databag.certificate, self.peer_unit_databag.ca]: + if not value: + return False + return True + + def save_certificate(self, event: tls_certificates.CertificateAvailableEvent) -> None: + """Save TLS certificate in peer relation unit databag.""" if ( event.certificate_signing_request.strip() - != self.charm.get_secret(SCOPE, "csr").strip() + != self.peer_unit_databag.requested_csr.strip() ): logger.warning("Unknown certificate received. Ignoring.") return - - if self.charm.unit_peer_data.get("tls") == "enabled": - logger.debug("TLS is already enabled.") + if ( + self.certificate_saved + and event.certificate_signing_request.strip() + == self.peer_unit_databag.active_csr.strip() + ): + # Workaround for https://github.com/canonical/tls-certificates-operator/issues/34 + logger.debug("TLS certificate already saved.") return - - self.charm.set_secret( - SCOPE, "chain", "\n".join(event.chain) if event.chain is not None else None + logger.debug(f"Saving TLS certificate {event=}") + self.peer_unit_databag.certificate = event.certificate + self.peer_unit_databag.ca = event.ca + self.peer_unit_databag.chain = json.dumps(event.chain) + self.peer_unit_databag.active_csr = self.peer_unit_databag.requested_csr + logger.debug(f"Saved TLS certificate {event=}") + self._charm.workload.enable_tls( + key=self.peer_unit_databag.key, certificate=self.peer_unit_databag.certificate ) - self.charm.set_secret(SCOPE, "cert", event.certificate) - self.charm.set_secret(SCOPE, "ca", event.ca) - # set member-state to avoid unwanted health-check actions - self.charm.unit_peer_data.update({"tls": "enabled"}) - self._set_tls() - - def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: - """Request the new certificate when old certificate is expiring.""" - if event.certificate != self.charm.get_secret(SCOPE, "cert"): - logger.error("An unknown certificate expiring.") - return + @staticmethod + def _parse_tls_key(raw_content: str) -> bytes: + """Parse TLS key from plain text or base64 format.""" + if re.match(r"(-+(BEGIN|END) [A-Z ]+-+)", raw_content): + return re.sub( + r"(-+(BEGIN|END) [A-Z ]+-+)", + "\n\\1\n", + raw_content, + ).encode("utf-8") + return base64.b64decode(raw_content) - key = self.charm.get_secret(SCOPE, "key").encode("utf-8") - old_csr = self.charm.get_secret(SCOPE, "csr").encode("utf-8") - new_csr = generate_csr( + def _generate_csr(self, key: bytes) -> bytes: + """Generate certificate signing request (CSR).""" + unit_name = self._charm.unit.name.replace("/", "-") + return tls_certificates.generate_csr( private_key=key, - subject=self.charm.unit_hostname, - organization=self.charm.app.name, - sans=self._get_sans(), - ) - self.certs.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, + subject=socket.getfqdn(), + organization=self._charm.app.name, + sans_dns=[ + unit_name, + f"{unit_name}.{self._charm.app.name}-endpoints", + f"{unit_name}.{self._charm.app.name}-endpoints.{self._charm.model_service_domain}", + f"{self._charm.app.name}-endpoints", + f"{self._charm.app.name}-endpoints.{self._charm.model_service_domain}", + f"{unit_name}.{self._charm.app.name}", + f"{unit_name}.{self._charm.app.name}.{self._charm.model_service_domain}", + self._charm.app.name, + f"{self._charm.app.name}.{self._charm.model_service_domain}", + ], + sans_ip=[ + str(self._charm.model.get_binding(self._peer_relation).network.bind_address), + ], ) - # Helpers - def _request_certificate(self, internal_key: Optional[str] = None) -> None: - """Request a certificate from the TLS relation.""" + def request_certificate_creation(self, internal_key: str = None): + """Request new TLS certificate from related provider charm.""" + logger.debug("Requesting TLS certificate creation") if internal_key: - key = self._parse_tls_file(internal_key) + key = self._parse_tls_key(internal_key) else: - key = generate_private_key() - - csr = generate_csr( - private_key=key, - subject=self.hostname, - organization=self.charm.app.name, - sans=self._get_sans(), + key = tls_certificates.generate_private_key() + csr = self._generate_csr(key) + self._interface.request_certificate_creation(certificate_signing_request=csr) + self.peer_unit_databag.key = key.decode("utf-8") + self.peer_unit_databag.requested_csr = csr.decode("utf-8") + logger.debug(f"Requested TLS certificate creation {self.peer_unit_databag.requested_csr=}") + + def request_certificate_renewal(self): + """Request TLS certificate renewal from related provider charm.""" + logger.debug(f"Requesting TLS certificate renewal {self.peer_unit_databag.active_csr=}") + old_csr = self.peer_unit_databag.active_csr.encode("utf-8") + key = self.peer_unit_databag.key.encode("utf-8") + new_csr = self._generate_csr(key) + self._interface.request_certificate_renewal( + old_certificate_signing_request=old_csr, new_certificate_signing_request=new_csr ) + self.peer_unit_databag.requested_csr = new_csr.decode("utf-8") + logger.debug(f"Requested TLS certificate renewal {self.peer_unit_databag.requested_csr=}") - # store secrets - self.charm.set_secret(SCOPE, "key", key.decode("utf-8")) - self.charm.set_secret(SCOPE, "csr", csr.decode("utf-8")) - # set control flag - self.charm.unit_peer_data.update({"tls": "requested"}) - self.certs.request_certificate_creation(certificate_signing_request=csr) - - def _get_sans(self) -> List[str]: - """Create a list of DNS names for a unit. - - Returns: - A list representing the hostnames of the unit. - """ - return [ - self.hostname, - socket.getfqdn(), - str(self.charm.model.get_binding(self.charm.peers).network.bind_address), - ] - @staticmethod - def _parse_tls_file(raw_content: str) -> bytes: - """Parse TLS files from both plain text or base64 format.""" - if re.match(r"(-+(BEGIN|END) [A-Z ]+-+)", raw_content): - return re.sub( - r"(-+(BEGIN|END) [A-Z ]+-+)", - "\n\\1\n", - raw_content, - ).encode("utf-8") - return base64.b64decode(raw_content) +class RelationEndpoint(ops.Object): + """Relation endpoint and handlers for TLS certificate provider""" - def _remove_file(self, path: str) -> None: - """Remove a file from container workload. + NAME = "certificates" - Args: - path: Full filesystem path to remove - """ - try: - self.container.remove_path(path) - except PathError: - # ignore file not found - pass - - def _set_tls(self) -> None: - """Enable TLS.""" - self._create_tls_config_file() - self._push_tls_files_to_workload() - # add tls layer merging with mysql-router layer - self.container.add_layer(MYSQL_ROUTER_SERVICE_NAME, self._tls_layer(), combine=True) - self.container.replan() - logger.info("TLS enabled.") - - def _unset_tls(self) -> None: - """Disable TLS.""" - for file in [TLS_SSL_KEY_FILE, TLS_SSL_CERT_FILE, TLS_SSL_CONFIG_FILE]: - self._remove_file(f"{ROUTER_CONFIG_DIRECTORY}/{file}") - # remove tls layer overriding with original layer - self.container.add_layer( - MYSQL_ROUTER_SERVICE_NAME, self.charm.mysql_router_layer, combine=True + def __init__(self, charm_: "charm.MySQLRouterOperatorCharm"): + super().__init__(charm_, self.NAME) + self._charm = charm_ + self._interface = tls_certificates.TLSCertificatesRequiresV1(self._charm, self.NAME) + + self.framework.observe( + self._charm.on.set_tls_private_key_action, + self._on_set_tls_private_key, ) - self.container.replan() - logger.info("TLS disabled.") - - def _write_content_to_file( - self, - path: str, - content: str, - owner: str, - group: str, - permission: int = 0o640, - ) -> None: - """Write content to file. - - Args: - path: filesystem full path (with filename) - content: string content to write - owner: file owner - group: file group - permission: file permission - """ - self.container.push(path, content, permissions=permission, user=owner, group=group) - - def _create_tls_config_file(self) -> None: - """Render TLS template directly to file. - - Render and write TLS enabling config file from template. - """ - with open("templates/tls.cnf", "r") as template_file: - template = Template(template_file.read()) - config_string = template.substitute( - tls_ssl_key_file=f"{ROUTER_CONFIG_DIRECTORY}/{TLS_SSL_KEY_FILE}", - tls_ssl_cert_file=f"{ROUTER_CONFIG_DIRECTORY}/{TLS_SSL_CERT_FILE}", - ) - - self._write_content_to_file( - f"{ROUTER_CONFIG_DIRECTORY}/{TLS_SSL_CONFIG_FILE}", - config_string, - owner=MYSQL_ROUTER_USER_NAME, - group=MYSQL_ROUTER_USER_NAME, - permission=0o600, + self.framework.observe( + self._charm.on[self.NAME].relation_joined, self._on_tls_relation_joined + ) + self.framework.observe( + self._charm.on[self.NAME].relation_broken, self._on_tls_relation_broken ) - def _push_tls_files_to_workload(self) -> None: - """Push TLS files to unit.""" - tls_file = {"key": TLS_SSL_KEY_FILE, "cert": TLS_SSL_CERT_FILE} - for key, value in tls_file.items(): - self._write_content_to_file( - f"{ROUTER_CONFIG_DIRECTORY}/{value}", - self.charm.get_secret(SCOPE, key), - owner=MYSQL_ROUTER_USER_NAME, - group=MYSQL_ROUTER_USER_NAME, - permission=0o600, - ) - - @staticmethod - def _tls_layer() -> Layer: - """Create a Pebble layer for TLS. - - Returns: - A Pebble layer object. - """ - return Layer( - { - "services": { - MYSQL_ROUTER_SERVICE_NAME: { - "override": "merge", - "command": f"/run.sh mysqlrouter --extra-config {ROUTER_CONFIG_DIRECTORY}/{TLS_SSL_CONFIG_FILE}", - }, - }, - }, + self.framework.observe( + self._interface.on.certificate_available, self._on_certificate_available + ) + self.framework.observe( + self._interface.on.certificate_expiring, self._on_certificate_expiring ) + + @property + def _relation(self) -> typing.Optional[_Relation]: + if not self._charm.model.get_relation(self.NAME): + return + return _Relation(_charm=self._charm, _interface=self._interface) + + @property + def certificate_saved(self) -> bool: + """Whether a TLS certificate is available to use""" + if self._relation is None: + return False + return self._relation.certificate_saved + + def _on_set_tls_private_key(self, event: ops.ActionEvent) -> None: + """Handle action to set unit TLS private key.""" + logger.debug("Handling set TLS private key action") + if self._relation is None: + event.fail("No TLS relation available.") + logger.debug("Unable to set TLS private key: no TLS relation available") + return + try: + self._relation.request_certificate_creation(event.params.get("internal-key")) + except Exception as e: + event.fail(f"Failed to request certificate: {e}") + logger.exception("Failed to set TLS private key via action") + raise + else: + logger.debug("Handled set TLS private key action") + + def _on_tls_relation_joined(self, _) -> None: + """Request certificate when TLS relation joined.""" + self._relation.request_certificate_creation() + + def _on_tls_relation_broken(self, _) -> None: + """Delete TLS certificate.""" + logger.debug("Deleting TLS certificate") + self._relation.peer_unit_databag.clear() + self._charm.workload.disable_tls() + logger.debug("Deleted TLS certificate") + + def _on_certificate_available(self, event: tls_certificates.CertificateAvailableEvent) -> None: + """Save TLS certificate.""" + self._relation.save_certificate(event) + + def _on_certificate_expiring(self, event: tls_certificates.CertificateExpiringEvent) -> None: + """Request the new certificate when old certificate is expiring.""" + if event.certificate != self._relation.peer_unit_databag.certificate: + logger.warning("Unknown certificate expiring") + return + + self._relation.request_certificate_renewal() diff --git a/src/utils.py b/src/utils.py deleted file mode 100644 index 9428b840f..000000000 --- a/src/utils.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2022 Canonical Ltd. -# See LICENSE file for licensing details. - -"""A collection of utility functions that are used in the charm.""" - -import secrets -import string - - -def generate_random_password(length: int) -> str: - """Randomly generate a string intended to be used as a password. - - Args: - length: length of the randomly generated string to be returned - - Returns: - a string with random letters and digits of length specified - """ - choices = string.ascii_letters + string.digits - return "".join([secrets.choice(choices) for i in range(length)]) diff --git a/src/workload.py b/src/workload.py new file mode 100644 index 000000000..f438bdb44 --- /dev/null +++ b/src/workload.py @@ -0,0 +1,286 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""MySQL Router workload""" + +import configparser +import dataclasses +import logging +import pathlib +import socket +import string +import typing + +import ops + +import mysql_shell + +if typing.TYPE_CHECKING: + import charm + import relations.database_requires + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(kw_only=True) +class Workload: + """MySQL Router workload""" + + _container: ops.Container + + CONTAINER_NAME = "mysql-router" + _SERVICE_NAME = "mysql_router" + _UNIX_USERNAME = "mysql" + _ROUTER_CONFIG_DIRECTORY = pathlib.Path("/etc/mysqlrouter") + _ROUTER_CONFIG_FILE = "mysqlrouter.conf" + _TLS_CONFIG_FILE = "tls.conf" + + @property + def container_ready(self) -> bool: + """Whether container is ready""" + return self._container.can_connect() + + @property + def _enabled(self) -> bool: + """Service status""" + service = self._container.get_services(self._SERVICE_NAME).get(self._SERVICE_NAME) + if service is None: + return False + return service.startup == ops.pebble.ServiceStartup.ENABLED + + @property + def version(self) -> str: + """MySQL Router version""" + process = self._container.exec(["mysqlrouter", "--version"]) + raw_version, _ = process.wait_output() + for version in raw_version.split(): + if version.startswith("8"): + return version + return "" + + def _update_layer(self, *, enabled: bool, tls: bool = None) -> None: + """Update and restart services. + + Args: + enabled: Whether MySQL Router service is enabled + tls: Whether TLS is enabled. Required if enabled=True + """ + if enabled: + assert tls is not None, "`tls` argument required when enabled=True" + command = ( + f"mysqlrouter --config {self._ROUTER_CONFIG_DIRECTORY / self._ROUTER_CONFIG_FILE}" + ) + if tls: + command = ( + f"{command} --extra-config {self._ROUTER_CONFIG_DIRECTORY / self._TLS_CONFIG_FILE}" + ) + if enabled: + startup = ops.pebble.ServiceStartup.ENABLED.value + else: + startup = ops.pebble.ServiceStartup.DISABLED.value + layer = ops.pebble.Layer( + { + "summary": "mysql router layer", + "description": "the pebble config layer for mysql router", + "services": { + self._SERVICE_NAME: { + "override": "replace", + "summary": "mysql router", + "command": command, + "startup": startup, + "user": self._UNIX_USERNAME, + "group": self._UNIX_USERNAME, + }, + }, + } + ) + self._container.add_layer(self._SERVICE_NAME, layer, combine=True) + self._container.replan() + + def disable(self) -> None: + """Stop and disable MySQL Router service.""" + if not self._enabled: + return + logger.debug("Disabling MySQL Router service") + self._update_layer(enabled=False) + logger.debug("Disabled MySQL Router service") + + +@dataclasses.dataclass(kw_only=True) +class AuthenticatedWorkload(Workload): + """Workload with connection to MySQL cluster""" + + # Database requires relation provides an admin user with permission to: + # - Create databases & users + # - Grant all privileges on a database to a user + # (Different from user that MySQL Router runs with after bootstrap.) + _database_requires_relation: "relations.database_requires.Relation" + _charm: "charm.MySQLRouterOperatorCharm" + + _TLS_KEY_FILE = "custom-key.pem" + _TLS_CERTIFICATE_FILE = "custom-certificate.pem" + + @property + def shell(self) -> mysql_shell.Shell: + """MySQL Shell""" + return mysql_shell.Shell( + _container=self._container, + username=self._database_requires_relation.username, + _password=self._database_requires_relation.password, + _host=self._database_requires_relation.host, + _port=self._database_requires_relation.port, + ) + + @property + def _router_id(self) -> str: + """MySQL Router ID in InnoDB Cluster metadata + + Used to remove MySQL Router metadata from InnoDB cluster + """ + # MySQL Router is bootstrapped without `--directory`—there is one system-wide instance. + return f"{socket.getfqdn()}::system" + + def cleanup_after_pod_restart(self) -> None: + """Remove MySQL Router cluster metadata & user after pod restart.""" + self.shell.remove_router_from_cluster_metadata(self._router_id) + self.shell.delete_router_user_after_pod_restart(self._router_id) + + def _bootstrap_router(self, *, tls: bool) -> None: + """Bootstrap MySQL Router and enable service.""" + logger.debug( + f"Bootstrapping router {tls=}, {self._database_requires_relation.host=}, {self._database_requires_relation.port=}" + ) + try: + # Bootstrap MySQL Router + process = self._container.exec( + [ + "mysqlrouter", + "--bootstrap", + self._database_requires_relation.username + + ":" + + self._database_requires_relation.password + + "@" + + self._database_requires_relation.host + + ":" + + self._database_requires_relation.port, + "--strict", + "--user", + self._UNIX_USERNAME, + "--conf-set-option", + "http_server.bind_address=127.0.0.1", + "--conf-use-gr-notifications", + ], + timeout=30, + ) + process.wait_output() + except ops.pebble.ExecError as e: + logger.exception(f"Failed to bootstrap router\nstderr:\n{e.stderr}\n") + raise + # Enable service + self._update_layer(enabled=True, tls=tls) + + logger.debug( + f"Bootstrapped router {tls=}, {self._database_requires_relation.host=}, {self._database_requires_relation.port=}" + ) + + @property + def _router_username(self) -> str: + """Read MySQL Router username from config file. + + During bootstrap, MySQL Router creates a config file at + `/etc/mysqlrouter/mysqlrouter.conf`. This file contains the username that was created + during bootstrap. + """ + config = configparser.ConfigParser() + config.read_file( + self._container.pull(self._ROUTER_CONFIG_DIRECTORY / self._ROUTER_CONFIG_FILE) + ) + return config["metadata_cache:bootstrap"]["user"] + + def enable(self, *, tls: bool, unit_name: str) -> None: + """Start and enable MySQL Router service.""" + if self._enabled: + # If the host or port changes, MySQL Router will receive topology change + # notifications from MySQL. + # Therefore, if the host or port changes, we do not need to restart MySQL Router. + return + logger.debug("Enabling MySQL Router service") + self._bootstrap_router(tls=tls) + self.shell.add_attributes_to_mysql_router_user( + username=self._router_username, router_id=self._router_id, unit_name=unit_name + ) + logger.debug("Enabled MySQL Router service") + self._charm.wait_until_mysql_router_ready() + + def _restart(self, *, tls: bool) -> None: + """Restart MySQL Router to enable or disable TLS.""" + logger.debug("Restarting MySQL Router") + assert self._enabled is True + self._bootstrap_router(tls=tls) + logger.debug("Restarted MySQL Router") + self._charm.wait_until_mysql_router_ready() + # wait_until_mysql_router_ready will set WaitingStatus—override it with current charm + # status + self._charm.set_status(event=None) + + def _write_file(self, path: pathlib.Path, content: str) -> None: + """Write content to file. + + Args: + path: Full filesystem path (with filename) + content: File content + """ + self._container.push( + str(path), + content, + permissions=0o600, + user=self._UNIX_USERNAME, + group=self._UNIX_USERNAME, + ) + logger.debug(f"Wrote file {path=}") + + def _delete_file(self, path: pathlib.Path) -> None: + """Delete file. + + Args: + path: Full filesystem path (with filename) + """ + path = str(path) + if self._container.exists(path): + self._container.remove_path(path) + logger.debug(f"Deleted file {path=}") + + @property + def _tls_config_file(self) -> str: + """Render config file template to string. + + Config file enables TLS on MySQL Router. + """ + with open("templates/tls.cnf", "r") as template_file: + template = string.Template(template_file.read()) + config_string = template.substitute( + tls_ssl_key_file=self._ROUTER_CONFIG_DIRECTORY / self._TLS_KEY_FILE, + tls_ssl_cert_file=self._ROUTER_CONFIG_DIRECTORY / self._TLS_CERTIFICATE_FILE, + ) + return config_string + + def enable_tls(self, *, key: str, certificate: str): + """Enable TLS and restart MySQL Router.""" + logger.debug("Enabling TLS") + self._write_file( + self._ROUTER_CONFIG_DIRECTORY / self._TLS_CONFIG_FILE, self._tls_config_file + ) + self._write_file(self._ROUTER_CONFIG_DIRECTORY / self._TLS_KEY_FILE, key) + self._write_file(self._ROUTER_CONFIG_DIRECTORY / self._TLS_CERTIFICATE_FILE, certificate) + if self._enabled: + self._restart(tls=True) + logger.debug("Enabled TLS") + + def disable_tls(self) -> None: + """Disable TLS and restart MySQL Router.""" + logger.debug("Disabling TLS") + for file in [self._TLS_CONFIG_FILE, self._TLS_KEY_FILE, self._TLS_CERTIFICATE_FILE]: + self._delete_file(self._ROUTER_CONFIG_DIRECTORY / file) + if self._enabled: + self._restart(tls=False) + logger.debug("Disabled TLS") diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index be455d312..7a3443dcd 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -47,12 +47,14 @@ async def test_database_relation(ops_test: OpsTest): MYSQL_APP_NAME, channel="8.0/edge", application_name=MYSQL_APP_NAME, + series="jammy", num_units=3, trust=True, # Necessary after a6f1f01: Fix/endpoints as k8s services (#142) ), ops_test.model.deploy( mysqlrouter_charm, application_name=MYSQL_ROUTER_APP_NAME, + series="jammy", resources=mysqlrouter_resources, num_units=1, ), @@ -60,27 +62,19 @@ async def test_database_relation(ops_test: OpsTest): APPLICATION_APP_NAME, channel="latest/edge", application_name=APPLICATION_APP_NAME, + series="jammy", num_units=1, ), ) mysql_app, application_app = applications[0], applications[2] - logger.info("Waiting for mysql, mysqlrouter and application to be ready") async with ops_test.fast_forward(): - await asyncio.gather( - ops_test.model.wait_for_idle( - apps=[MYSQL_APP_NAME], - status="active", - raise_on_blocked=True, - timeout=SLOW_TIMEOUT, - ), - ops_test.model.wait_for_idle( - apps=[MYSQL_ROUTER_APP_NAME, APPLICATION_APP_NAME], - status="waiting", - raise_on_blocked=True, - timeout=SLOW_TIMEOUT, - ), + logger.info("Waiting for mysqlrouter to be in BlockedStatus") + await ops_test.model.wait_for_idle( + apps=[MYSQL_ROUTER_APP_NAME], + status="blocked", + timeout=SLOW_TIMEOUT, ) logger.info("Relating mysql, mysqlrouter and application") @@ -93,6 +87,10 @@ async def test_database_relation(ops_test: OpsTest): f"{APPLICATION_APP_NAME}:database", f"{MYSQL_ROUTER_APP_NAME}:database" ) + await ops_test.model.wait_for_idle( + apps=[MYSQL_ROUTER_APP_NAME], status="active", timeout=SLOW_TIMEOUT + ) + await ops_test.model.wait_for_idle( apps=[MYSQL_APP_NAME, MYSQL_ROUTER_APP_NAME, APPLICATION_APP_NAME], status="active", diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py deleted file mode 100644 index 8cdf5a667..000000000 --- a/tests/unit/test_charm.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright 2022 Canonical Ltd. -# See LICENSE file for licensing details. - -import unittest -from unittest.mock import MagicMock, patch - -import lightkube -from ops.model import BlockedStatus, MaintenanceStatus -from ops.testing import Harness - -from charm import MySQLRouterOperatorCharm - - -class TestCharm(unittest.TestCase): - def setUp(self): - self.harness = Harness(MySQLRouterOperatorCharm) - self.addCleanup(self.harness.cleanup) - self.harness.begin() - self.peer_relation_id = self.harness.add_relation( - "mysql-router-peers", "mysql-router-peers" - ) - self.harness.add_relation_unit(self.peer_relation_id, "mysql-router-k8s/1") - self.charm = self.harness.charm - - @patch("charm.Client", return_value=MagicMock()) - def test_on_peer_relation_created(self, _lightkube_client): - self.charm.on.leader_elected.emit() - - self.assertEqual(_lightkube_client.return_value.patch.call_count, 1) - - self.assertTrue(isinstance(self.harness.model.unit.status, MaintenanceStatus)) - - @patch("charm.Client", return_value=MagicMock()) - def test_on_peer_relation_created_delete_exception(self, _lightkube_client): - response = MagicMock() - response.json.return_value = {"status": "Bad Request", "code": 400} - api_error = lightkube.ApiError(request=MagicMock(), response=response) - _lightkube_client.return_value.patch.side_effect = api_error - - self.charm.on.leader_elected.emit() - - self.assertTrue(isinstance(self.harness.model.unit.status, BlockedStatus)) - - @patch("charm.Client", return_value=MagicMock()) - def test_on_peer_relation_created_delete_nothing(self, _lightkube_client): - response = MagicMock() - response.json.return_value = {"status": "Not Found", "code": 404} - api_error = lightkube.ApiError(request=MagicMock(), response=response) - _lightkube_client.return_value.delete.side_effect = api_error - - self.charm.on.leader_elected.emit() - - self.assertTrue(isinstance(self.harness.model.unit.status, MaintenanceStatus)) - - @patch("charm.Client", return_value=MagicMock()) - def test_on_leader_elected_create_exception(self, _lightkube_client): - response = MagicMock() - response.json.return_value = {"status": "Bad Request", "code": 400} - api_error = lightkube.ApiError(request=MagicMock(), response=response) - _lightkube_client.return_value.patch.side_effect = api_error - - self.charm.on.leader_elected.emit() - - self.assertTrue(isinstance(self.harness.model.unit.status, BlockedStatus)) - - @patch("charm.Client", return_value=MagicMock()) - def test_on_leader_elected_create_existing_service(self, _lightkube_client): - response = MagicMock() - response.json.return_value = {"status": "Conflict", "code": 409} - api_error = lightkube.ApiError(request=MagicMock(), response=response) - _lightkube_client.return_value.create.side_effect = api_error - - self.charm.on.leader_elected.emit() - - self.assertTrue(isinstance(self.harness.model.unit.status, MaintenanceStatus)) diff --git a/tox.ini b/tox.ini index abbd931b9..29dd2b3f8 100644 --- a/tox.ini +++ b/tox.ini @@ -66,7 +66,6 @@ commands = coverage run --source={[vars]src_path} \ -m pytest -v --tb native -s {posargs} {[vars]tests_path}/unit coverage report - coverage lcov [testenv:integration-database] description = Run integration tests for the database relation @@ -78,6 +77,7 @@ deps = juju==2.9.38.1 pytest pytest-operator + mysql-connector-python -r {tox_root}/requirements.txt commands = pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tests_path}/integration/test_charm.py