From 5d89fb6acd08ec3ea2e65356fad520ad5d9862d9 Mon Sep 17 00:00:00 2001 From: shayancanonical <99665202+shayancanonical@users.noreply.github.com> Date: Mon, 1 Apr 2024 17:07:50 -0400 Subject: [PATCH] Port bug fixes and changes noticed while testing COS integration in k8s charm (#116) ## Issue There were a number of issues encountered while testing the COS integration in the k8s charm. The fixes for these issues are still missing from this repo ## Solution Port fixes --- poetry.lock | 22 ++++++- pyproject.toml | 1 + src/abstract_charm.py | 32 +++++----- src/container.py | 30 ++++++++- src/machine_charm.py | 7 --- src/relations/cos.py | 37 +++-------- src/relations/secrets.py | 24 +++----- src/snap.py | 23 ++++++- src/workload.py | 99 ++++++++++++++++++++++++------ tests/integration/test_exporter.py | 7 ++- 10 files changed, 192 insertions(+), 90 deletions(-) diff --git a/poetry.lock b/poetry.lock index a9e2072d..029e14a9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -851,6 +851,16 @@ 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"}, @@ -1634,6 +1644,7 @@ 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"}, @@ -1641,8 +1652,15 @@ 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"}, @@ -1659,6 +1677,7 @@ 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"}, @@ -1666,6 +1685,7 @@ 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"}, @@ -2079,4 +2099,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "4622b1846ed62d249eedb507994529fdb6e7c73c0cde3b1f0d40e69503ca68c2" +content-hash = "623db8d07c7bcc8ba1a0d7d8322b2c749895686892fb43ce83ed0b27925675a3" diff --git a/pyproject.toml b/pyproject.toml index a66522f0..ca1499c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ ops = "^2.6.0" tenacity = "^8.2.3" poetry-core = "^1.7.0" jinja2 = "^3.1.2" +requests = "^2.31.0" [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py diff --git a/src/abstract_charm.py b/src/abstract_charm.py index 9923f330..72174ffd 100644 --- a/src/abstract_charm.py +++ b/src/abstract_charm.py @@ -39,6 +39,7 @@ def __init__(self, *args) -> None: self._authenticated_workload_type = workload.AuthenticatedWorkload self._database_requires = relations.database_requires.RelationEndpoint(self) self._database_provides = relations.database_provides.RelationEndpoint(self) + self._cos_relation = relations.cos.COSRelation(self, self._container) self.framework.observe(self.on.update_status, self.reconcile) self.framework.observe( self.on[upgrade.PEER_RELATION_ENDPOINT_NAME].relation_changed, self.reconcile @@ -83,11 +84,6 @@ def _upgrade(self) -> typing.Optional[upgrade.Upgrade]: def _logrotate(self) -> logrotate.LogRotate: """logrotate""" - @property - @abc.abstractmethod - def _cos(self) -> relations.cos.COSRelation: - """COS""" - @property @abc.abstractmethod def _read_write_endpoint(self) -> str: @@ -105,23 +101,30 @@ def _tls_certificate_saved(self) -> bool: return False @property - def _tls_key(self) -> str: + def _tls_key(self) -> typing.Optional[str]: """Custom TLS key""" # TODO VM TLS: Update property after implementing TLS on machine_charm return None @property - def _tls_certificate(self) -> str: + def _tls_certificate(self) -> typing.Optional[str]: """Custom TLS certificate""" # TODO VM TLS: Update property after implementing TLS on machine_charm return None + @property + def _tls_certificate_authority(self) -> typing.Optional[str]: + # TODO VM TLS: Update property after implementing TLS on machine charm + return None + def _cos_exporter_config(self, event) -> typing.Optional[relations.cos.ExporterConfig]: """Returns the exporter config for MySQLRouter exporter if cos relation exists""" - cos_relation_exists = self._cos.relation_exists and not self._cos.is_relation_breaking( - event + cos_relation_exists = ( + self._cos_relation.relation_exists + and not self._cos_relation.is_relation_breaking(event) ) - return self._cos.exporter_user_config if cos_relation_exists else None + if cos_relation_exists: + return self._cos_relation.exporter_user_config def get_workload(self, *, event): """MySQL Router workload""" @@ -130,11 +133,11 @@ def get_workload(self, *, event): container_=self._container, logrotate_=self._logrotate, connection_info=connection_info, - cos=self._cos, + cos=self._cos_relation, charm_=self, ) return self._workload_type( - container_=self._container, logrotate_=self._logrotate, cos=self._cos + container_=self._container, logrotate_=self._logrotate, cos=self._cos_relation ) @staticmethod @@ -259,8 +262,8 @@ def reconcile(self, event=None) -> None: # noqa: C901 f"{isinstance(workload_, workload.AuthenticatedWorkload)=}, " f"{workload_.container_ready=}, " f"{self._database_requires.is_relation_breaking(event)=}, " - f"{self._upgrade.in_progress=}" - f"{self._cos.is_relation_breaking(event)=}" + f"{self._upgrade.in_progress=}, " + f"{self._cos_relation.is_relation_breaking(event)=}" ) try: @@ -289,6 +292,7 @@ def reconcile(self, event=None) -> None: # noqa: C901 exporter_config=self._cos_exporter_config(event), key=self._tls_key, certificate=self._tls_certificate, + certificate_authority=self._tls_certificate_authority, ) # Empty waiting status means we're waiting for database requires relation before # starting workload diff --git a/src/container.py b/src/container.py index 8ca1881d..ed8774dc 100644 --- a/src/container.py +++ b/src/container.py @@ -134,17 +134,33 @@ def update_mysql_router_service(self, *, enabled: bool, tls: bool = None) -> Non @abc.abstractmethod def update_mysql_router_exporter_service( - self, *, enabled: bool, config: "relations.cos.ExporterConfig" = None + self, + *, + enabled: bool, + config: "relations.cos.ExporterConfig" = None, + tls: bool = None, + key_filename: str = None, + certificate_filename: str = None, + certificate_authority_filename: str = None, ) -> None: """Update and restart the MySQL Router exporter service. Args: enabled: Whether MySQL Router exporter service is enabled config: The configuration for MySQL Router exporter + tls: Whether custom TLS is enabled on the unit + key_filename: The TLS key filename + certificate_filename: The TLS certificate filename + certificate_authority_filename: The TLS certificate authority filename """ if enabled and not config: raise ValueError("Missing MySQL Router exporter config") + if tls and not (certificate_authority_filename and certificate_filename and key_filename): + raise ValueError( + "`key`, `certificate` and `certificate_authority` required when tls=True" + ) + @abc.abstractmethod def upgrade(self, unit: ops.Unit) -> None: """Upgrade container version @@ -203,6 +219,18 @@ def set_mysql_router_rest_api_password( """Set REST API credentials using the mysqlrouter_password command.""" self.create_router_rest_api_credentials_file() + if not password: + users_credentials = self._run_command( + [ + self._mysql_router_password_command, + "list", + str(self.rest_api_credentials_file), + ], + timeout=30, + ) + if user not in users_credentials: + return + action = "set" if password else "delete" self._run_command( [ diff --git a/src/machine_charm.py b/src/machine_charm.py index 02d8acc7..70621756 100755 --- a/src/machine_charm.py +++ b/src/machine_charm.py @@ -14,7 +14,6 @@ import abstract_charm import machine_logrotate import machine_upgrade -import relations.cos import relations.database_providers_wrapper import snap import socket_workload @@ -33,8 +32,6 @@ def __init__(self, *args) -> None: self._database_provides = relations.database_providers_wrapper.RelationEndpoint( self, self._database_provides ) - self._cos_relation = relations.cos.COSRelation(self, self._container) - self._authenticated_workload_type = socket_workload.AuthenticatedSocketWorkload self.framework.observe(self.on.install, self._on_install) self.framework.observe(self.on.remove, self._on_remove) @@ -63,10 +60,6 @@ def _upgrade(self) -> typing.Optional[machine_upgrade.Upgrade]: def _logrotate(self) -> machine_logrotate.LogRotate: return machine_logrotate.LogRotate(container_=self._container) - @property - def _cos(self) -> relations.cos.COSRelation: - return self._cos_relation - @property def _read_write_endpoint(self) -> str: return f'file://{self._container.path("/run/mysqlrouter/mysql.sock")}' diff --git a/src/relations/cos.py b/src/relations/cos.py index bcd0761b..93919d67 100644 --- a/src/relations/cos.py +++ b/src/relations/cos.py @@ -34,11 +34,11 @@ class COSRelation: """Relation with the cos bundle.""" _EXPORTER_PORT = "49152" - _HTTP_SERVER_PORT = "8443" + HTTP_SERVER_PORT = "8443" _NAME = "cos-agent" _PEER_RELATION_NAME = "cos" - _MONITORING_USERNAME = "monitoring" + MONITORING_USERNAME = "monitoring" _MONITORING_PASSWORD_KEY = "monitoring-password" def __init__(self, charm_: "abstract_charm.MySQLRouterCharm", container_: container.Container): @@ -52,7 +52,7 @@ def __init__(self, charm_: "abstract_charm.MySQLRouterCharm", container_: contai ], log_slots=[f"{_SNAP_NAME}:logs"], ) - self.charm = charm_ + self._charm = charm_ self._container = container_ charm_.framework.observe( @@ -74,17 +74,17 @@ def __init__(self, charm_: "abstract_charm.MySQLRouterCharm", container_: contai def exporter_user_config(self) -> ExporterConfig: """Returns user config needed for the router exporter service.""" return ExporterConfig( - url=f"https://127.0.0.1:{self._HTTP_SERVER_PORT}", - username=self._MONITORING_USERNAME, - password=self._get_monitoring_password(), + url=f"https://127.0.0.1:{self.HTTP_SERVER_PORT}", + username=self.MONITORING_USERNAME, + password=self.get_monitoring_password(), ) @property def relation_exists(self) -> bool: """Whether relation with cos exists.""" - return len(self.charm.model.relations.get(self._NAME, [])) == 1 + return len(self._charm.model.relations.get(self._NAME, [])) == 1 - def _get_monitoring_password(self) -> str: + def get_monitoring_password(self) -> str: """Gets the monitoring password from unit peer data, or generate and cache it.""" monitoring_password = self._secrets.get_secret( relations.secrets.UNIT_SCOPE, self._MONITORING_PASSWORD_KEY @@ -109,24 +109,5 @@ def is_relation_breaking(self, event) -> bool: return ( isinstance(event, ops.RelationBrokenEvent) - and event.relation.id == self.charm.model.relations[self._NAME][0].id + and event.relation.id == self._charm.model.relations[self._NAME][0].id ) - - def setup_monitoring_user(self) -> None: - """Set up a router REST API use for mysqlrouter exporter.""" - logger.debug("Setting up router REST API user for mysqlrouter exporter") - self._container.set_mysql_router_rest_api_password( - user=self._MONITORING_USERNAME, - password=self._get_monitoring_password(), - ) - logger.debug("Set up router REST API user for mysqlrouter exporter") - - def cleanup_monitoring_user(self) -> None: - """Clean up router REST API user for mysqlrouter exporter.""" - logger.debug("Cleaning router REST API user for mysqlrouter exporter") - self._container.set_mysql_router_rest_api_password( - user=self._MONITORING_USERNAME, - password=None, - ) - self._reset_monitoring_password() - logger.debug("Cleaned router REST API user for mysqlrouter exporter") diff --git a/src/relations/secrets.py b/src/relations/secrets.py index ffab9d27..dfad79a7 100644 --- a/src/relations/secrets.py +++ b/src/relations/secrets.py @@ -31,17 +31,17 @@ def __init__( app_secret_fields: typing.List[str] = [], unit_secret_fields: typing.List[str] = [], ) -> None: - self.charm = charm - self.peer_relation_name = peer_relation_name + self._charm = charm + self._peer_relation_name = peer_relation_name - self.peer_relation_app = data_interfaces.DataPeer( + self._peer_relation_app = data_interfaces.DataPeer( charm, relation_name=peer_relation_name, additional_secret_fields=app_secret_fields, secret_field_name=self._SECRET_INTERNAL_LABEL, deleted_label=self._SECRET_DELETED_LABEL, ) - self.peer_relation_unit = data_interfaces.DataPeerUnit( + self._peer_relation_unit = data_interfaces.DataPeerUnit( charm, relation_name=peer_relation_name, additional_secret_fields=unit_secret_fields, @@ -49,25 +49,19 @@ def __init__( deleted_label=self._SECRET_DELETED_LABEL, ) - def _scope_obj(self, scope: Scopes): - if scope == APP_SCOPE: - return self.app - if scope == UNIT_SCOPE: - return self.unit - def peer_relation_data(self, scope: Scopes) -> data_interfaces.DataPeer: """Returns the peer relation data per scope.""" if scope == APP_SCOPE: - return self.peer_relation_app + return self._peer_relation_app elif scope == UNIT_SCOPE: - return self.peer_relation_unit + return self._peer_relation_unit def get_secret(self, scope: Scopes, key: str) -> typing.Optional[str]: """Get secret from the secret storage.""" if scope not in typing.get_args(Scopes): raise ValueError("Unknown secret scope") - peers = self.charm.model.get_relation(self.peer_relation_name) + peers = self._charm.model.get_relation(self._peer_relation_name) return self.peer_relation_data(scope).fetch_my_relation_field(peers.id, key) def set_secret( @@ -80,7 +74,7 @@ def set_secret( if not value: return self.remove_secret(scope, key) - peers = self.charm.model.get_relation(self.peer_relation_name) + peers = self._charm.model.get_relation(self._peer_relation_name) self.peer_relation_data(scope).update_relation_data(peers.id, {key: value}) def remove_secret(self, scope: Scopes, key: str) -> None: @@ -88,5 +82,5 @@ def remove_secret(self, scope: Scopes, key: str) -> None: if scope not in typing.get_args(Scopes): raise ValueError("Unknown secret scope") - peers = self.charm.model.get_relation(self.peer_relation_name) + peers = self._charm.model.get_relation(self._peer_relation_name) self.peer_relation_data(scope).delete_relation_data(peers.id, [key]) diff --git a/src/snap.py b/src/snap.py index 44621d27..8886bfd1 100644 --- a/src/snap.py +++ b/src/snap.py @@ -184,9 +184,26 @@ def update_mysql_router_service(self, *, enabled: bool, tls: bool = None) -> Non _snap.stop([self._SERVICE_NAME], disable=True) def update_mysql_router_exporter_service( - self, *, enabled: bool, config: "relations.cos.ExporterConfig" = None + self, + *, + enabled: bool, + config: "relations.cos.ExporterConfig" = None, + tls: bool = None, + key_filename: str = None, + certificate_filename: str = None, + certificate_authority_filename: str = None, ) -> None: - super().update_mysql_router_exporter_service(enabled=enabled, config=config) + if tls: + raise NotImplementedError + + super().update_mysql_router_exporter_service( + enabled=enabled, + config=config, + tls=tls, + key_filename=key_filename, + certificate_filename=certificate_filename, + certificate_authority_filename=certificate_authority_filename, + ) if enabled: _snap.set( @@ -213,7 +230,7 @@ def _run_command( command: typing.List[str], *, timeout: typing.Optional[int], - input: typing.Optional[str] = None, + input: str = None, ) -> str: try: output = subprocess.run( diff --git a/src/workload.py b/src/workload.py index 06d6e54a..920c3da4 100644 --- a/src/workload.py +++ b/src/workload.py @@ -12,6 +12,8 @@ import typing import ops +import requests +import tenacity import container import mysql_shell @@ -53,6 +55,9 @@ def __init__( self._tls_certificate_file = ( self._container.router_config_directory / "custom-certificate.pem" ) + self._tls_certificate_authority_file = ( + self._container.router_config_directory / "custom-certificate-authority.pem" + ) @property def container_ready(self) -> bool: @@ -98,21 +103,32 @@ def _custom_tls_enabled(self) -> bool: """Whether custom TLS certs are enabled for MySQL Router""" return self._tls_key_file.exists() and self._tls_certificate_file.exists() + def cleanup_monitoring_user(self) -> None: + """Clean up router REST API user for mysqlrouter exporter.""" + logger.debug("Cleaning router REST API user for mysqlrouter exporter") + self._container.set_mysql_router_rest_api_password( + user=self._cos.MONITORING_USERNAME, + password=None, + ) + self._cos._reset_monitoring_password() + logger.debug("Cleaned router REST API user for mysqlrouter exporter") + def _disable_exporter(self) -> None: """Stop and disable MySQL Router exporter service, keeping router enabled.""" if not self._container.mysql_router_exporter_service_enabled: return logger.debug("Disabling MySQL Router exporter service") - self._cos.cleanup_monitoring_user() self._container.update_mysql_router_exporter_service(enabled=False) + self.cleanup_monitoring_user() logger.debug("Disabled MySQL Router exporter service") - def _enable_tls(self, *, key: str, certificate: str) -> None: + def _enable_tls(self, *, key: str, certificate: str, certificate_authority: str) -> None: """Enable TLS.""" logger.debug("Creating TLS files") self._container.tls_config_file.write_text(self._tls_config_file_data) self._tls_key_file.write_text(key) self._tls_certificate_file.write_text(certificate) + self._tls_certificate_authority_file.write_text(certificate_authority) logger.debug("Created TLS files") def _disable_tls(self) -> None: @@ -124,7 +140,7 @@ def _disable_tls(self) -> None: self._tls_certificate_file, ): file.unlink(missing_ok=True) - logger.debug("Deleting TLS files") + logger.debug("Deleted TLS files") def reconcile( self, @@ -134,9 +150,10 @@ def reconcile( exporter_config: "relations.cos.ExporterConfig", key: str = None, certificate: str = None, + certificate_authority: str = None, ) -> None: """Reconcile all workloads (router, exporter, tls).""" - if tls and not (key and certificate): + if tls and not (key and certificate and certificate_authority): raise ValueError("`key` and `certificate` arguments required when tls=True") if self._container.mysql_router_service_enabled: @@ -150,11 +167,7 @@ def reconcile( logger.debug("Disabled MySQL Router service") self._disable_exporter() - - if tls: - self._enable_tls(key=key, certificate=certificate) - else: - self._disable_tls() + self._disable_tls() @property def status(self) -> typing.Optional[ops.StatusBase]: @@ -229,7 +242,7 @@ def _get_bootstrap_command( "--conf-set-option", "http_auth_backend:default_auth_backend.backend=file", "--conf-set-option", - f"http_auth_backend:default_auth_backend.filename={self._container.path(self._container.rest_api_credentials_file).relative_to_container}", + f"http_auth_backend:default_auth_backend.filename={self._container.rest_api_credentials_file.relative_to_container}", "--conf-use-gr-notifications", ] @@ -310,15 +323,20 @@ def reconcile( exporter_config: "relations.cos.ExporterConfig", key: str = None, certificate: str = None, + certificate_authority: str = None, ) -> None: """Reconcile all workloads (router, exporter, tls).""" - if tls and not (key and certificate): - raise ValueError("`key` and `certificate` arguments required when tls=True") + if tls and not (key and certificate and certificate_authority): + raise ValueError( + "`key`, `certificate`, and `certificate_authority` arguments required when tls=True" + ) - # value changes based on whether tls is enabled or disabled + # self._custom_tls_enabled` will change after we enable or disable TLS tls_was_enabled = self._custom_tls_enabled if tls: - self._enable_tls(key, certificate) + self._enable_tls( + key=key, certificate=certificate, certificate_authority=certificate_authority + ) if not tls_was_enabled and self._container.mysql_router_service_enabled: self._restart(tls=tls) else: @@ -333,20 +351,27 @@ def reconcile( logger.debug("Enabling MySQL Router service") self._cleanup_after_upgrade_or_potential_container_restart() self._container.create_router_rest_api_credentials_file() # create an empty credentials file - self._bootstrap_router(tls=self._custom_tls_enabled) + 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 ) - self._container.update_mysql_router_service(enabled=True, tls=self._custom_tls_enabled) + self._container.update_mysql_router_service(enabled=True, tls=tls) self._logrotate.enable() logger.debug("Enabled MySQL Router service") self._charm.wait_until_mysql_router_ready() - if not self._container.mysql_router_exporter_service_enabled and exporter_config: + if (not self._container.mysql_router_exporter_service_enabled and exporter_config) or ( + self._container.mysql_router_exporter_service_enabled and tls_was_enabled != tls + ): logger.debug("Enabling MySQL Router exporter service") - self._cos.setup_monitoring_user() + self.setup_monitoring_user() self._container.update_mysql_router_exporter_service( - enabled=True, config=exporter_config + enabled=True, + config=exporter_config, + tls=tls, + key_filename=str(self._tls_key_file), + certificate_filename=str(self._tls_certificate_file), + certificate_authority_filename=str(self._tls_certificate_authority_file), ) logger.debug("Enabled MySQL Router exporter service") elif self._container.mysql_router_exporter_service_enabled and not exporter_config: @@ -375,3 +400,39 @@ def upgrade(self, *, unit: ops.Unit, tls: bool) -> None: if enabled: logger.debug("Re-enabling MySQL Router service after upgrade") self.enable(tls=tls, unit_name=unit.name) + + def _wait_until_http_server_authenticates(self) -> None: + """Wait until active connection with router HTTP server using monitoring credentials.""" + logger.debug("Waiting until router HTTP server authenticates") + try: + for attempt in tenacity.Retrying( + retry=tenacity.retry_if_exception_type(RuntimeError) + | tenacity.retry_if_exception_type(requests.exceptions.HTTPError), + reraise=True, + stop=tenacity.stop_after_delay(30), + wait=tenacity.wait_fixed(5), + ): + with attempt: + response = requests.get( + f"https://127.0.0.1:{self._cos.HTTP_SERVER_PORT}/api/20190715/routes", + auth=(self._cos.MONITORING_USERNAME, self._cos.get_monitoring_password()), + verify=False, # do not verify tls certs as default certs do not have 127.0.0.1 in its list of IP SANs + ) + response.raise_for_status() + if "bootstrap_rw" not in response.text: + raise RuntimeError("Invalid response from router's HTTP server") + except (requests.exceptions.HTTPError, RuntimeError): + logger.exception("Unable to authenticate router HTTP server") + raise + else: + logger.debug("Successfully authenticated router HTTP server") + + def setup_monitoring_user(self) -> None: + """Set up a router REST API use for mysqlrouter exporter.""" + logger.debug("Setting up router REST API user for mysqlrouter exporter") + self._container.set_mysql_router_rest_api_password( + user=self._cos.MONITORING_USERNAME, + password=self._cos.get_monitoring_password(), + ) + self._wait_until_http_server_authenticates() + logger.debug("Set up router REST API user for mysqlrouter exporter") diff --git a/tests/integration/test_exporter.py b/tests/integration/test_exporter.py index 7051abb9..5ab1f672 100644 --- a/tests/integration/test_exporter.py +++ b/tests/integration/test_exporter.py @@ -64,7 +64,7 @@ async def test_exporter_endpoint(ops_test: OpsTest, mysql_router_charm_series: s ), ) - mysql_app, mysql_router_app, mysql_test_app, grafana_agent_app = applications + [mysql_app, mysql_router_app, mysql_test_app, grafana_agent_app] = applications logger.info("Relating mysqlrouter and grafana-agent with mysql-test-app") @@ -121,6 +121,7 @@ async def test_exporter_endpoint(ops_test: OpsTest, mysql_router_charm_series: s else: assert False, "❌ can connect to metrics endpoint without relation with cos" + logger.info("Relating mysqlrouter with grafana agent") await ops_test.model.relate( f"{GRAFANA_AGENT_APP_NAME}:cos-agent", f"{MYSQL_ROUTER_APP_NAME}:cos-agent" ) @@ -133,6 +134,7 @@ async def test_exporter_endpoint(ops_test: OpsTest, mysql_router_charm_series: s jmx_resp.data ), "❌ did not find expected metric in response" + logger.info("Removing relation between mysqlrouter and grafana agent") await mysql_router_app.remove_relation( f"{GRAFANA_AGENT_APP_NAME}:cos-agent", f"{MYSQL_ROUTER_APP_NAME}:cos-agent" ) @@ -141,8 +143,9 @@ async def test_exporter_endpoint(ops_test: OpsTest, mysql_router_charm_series: s try: http.request("GET", f"http://{unit_address}:49152/metrics") - assert False, "❌ can connect to metrics endpoint without relation with cos" except urllib3.exceptions.MaxRetryError as e: assert ( "[Errno 111] Connection refused" in e.reason.args[0] ), "❌ expected connection refused error" + else: + assert False, "❌ can connect to metrics endpoint without relation with cos"